SwiftUI and vars outside the SwiftUI world

We inherited some code that has a variable that begins in the "SwiftUI world", so to speak, and is copied over to a global variable in the "Swift world" for use in non-SwiftUI classes (POSOs? Plain Ol' Swift Objects?).

Here's a contrived example showing the basic gist of it. Note how there's an AppViewModel that maintains the state, and an .onChange that copies the value to a global var, which is used in the plain class DoNetworkStuff. I would like to weed out the redundant global var, but I kind of see why it was done this way--how DO you bridge between the 2 worlds? I don't think you can add a ref to AppViewModel inside DoNetworkStuff. I was thinking you could add a function to the AppViewModel that returns devid, and stash a ref to the function in a var for use whenever devid is needed., so at least you're eliminating the var value being stored in 2 places, but that might be confusing a year from now. I'm trying to think of a way to rewrite this without ripping out too much code (it could be that maybe it's better to leave it).

var gblDevID = "" //global var

class AppViewModel: ObservableObject {
    @Published var devid = ""
    ...
}

struct ContentView: View {
    @StateObject var appViewModel = AppViewModel()    
    var body: some View {
        TextField("Enter device id", text: $appViewModel.devid)
            .onChange(of: appViewModel.devid) { newVal in
                gblDevID = newVal
            }
        ...
    }
}

class DoNetworkStuff {
   func networkingTask() {
       doSomeTask(gblDevID)
   }
}

Answered by DTS Engineer in 773980022

There's quite a lot to unpack here.

You have one of 2 possibilities in your app:

  1. You have only once instance of AppViewModel in your app's lifecycle. This means you must prevent the creation of more than one window (aka scene) using that ContentView.

  2. You allow the creation of multiple instances of your window, which means you'll have a different ContentView in each possible window, and so possibly more than one AppViewModel at a time. In this case, your code is already broken, since you have only one global, and you can't tell which instance of AppViewModel it's related to.

If you want to do #1, assuming you've found a way to prevent creation of more than one instance, then you can solve your problem in a couple of ways:

Possible solution: Create your AppViewModel outside of ContentView, and pass it in as a parameter or via an .environmentObject modifier on ContentView. You can store your AppViewModel reference in a place that your non-view code can find it.

Possible solution: Use a singleton model, where (for example) AppViewModel has a shared static property that returns the one and only instance. Your non-view code can get this reference as AppViewModel.shared.

For a slightly different perspective, consider that you've perhaps "under-designed" your solution here, because the fact that you need a "devID" for your networking code means that it's something that should be in an app data model, not in a view model. You may need both, too — for example, while a user is editing some input in a text field, you might need a temporary state variable in a view model to bind to in SwiftUI, but you don't want to update the corresponding data model value until the user has finished editing the value.

Sometimes, when you feel discomfort with the code you've already written, it's necessary to go back and think about the design more than the current implementation. :)

Bridging is done by using UIHostingController

See how here: https://sarunw.com/posts/swiftui-view-as-uiview/

But inheriting code that you don't fully understand is often a cause of problem. It is better to rewrite it.

Accepted Answer

There's quite a lot to unpack here.

You have one of 2 possibilities in your app:

  1. You have only once instance of AppViewModel in your app's lifecycle. This means you must prevent the creation of more than one window (aka scene) using that ContentView.

  2. You allow the creation of multiple instances of your window, which means you'll have a different ContentView in each possible window, and so possibly more than one AppViewModel at a time. In this case, your code is already broken, since you have only one global, and you can't tell which instance of AppViewModel it's related to.

If you want to do #1, assuming you've found a way to prevent creation of more than one instance, then you can solve your problem in a couple of ways:

Possible solution: Create your AppViewModel outside of ContentView, and pass it in as a parameter or via an .environmentObject modifier on ContentView. You can store your AppViewModel reference in a place that your non-view code can find it.

Possible solution: Use a singleton model, where (for example) AppViewModel has a shared static property that returns the one and only instance. Your non-view code can get this reference as AppViewModel.shared.

For a slightly different perspective, consider that you've perhaps "under-designed" your solution here, because the fact that you need a "devID" for your networking code means that it's something that should be in an app data model, not in a view model. You may need both, too — for example, while a user is editing some input in a text field, you might need a temporary state variable in a view model to bind to in SwiftUI, but you don't want to update the corresponding data model value until the user has finished editing the value.

Sometimes, when you feel discomfort with the code you've already written, it's necessary to go back and think about the design more than the current implementation. :)

@Polyphonic This is great stuff Polyphonic, thank you! Data Model vs View Model makes a lot of sense in particular. Do you think, conceptually, this could be seen as the "SwiftUI world" (aka what is handled by the View Model), and the "non-SwiftUI world" (aka the Data Model)? To me it seemed like that causes 2 sources of truth, like we have now...but maybe that is normal? As in, the View Model only manages the data as long as the view (the UI world) is alive, but once it disappears and is done, then its data must be transferred over to the Data Model?

If that's the case, it still makes me wonder how a plain vanilla class like DoNetworkStuff has access to that data. I see what you're saying that starting the app with an AppViewModel as an .environmentObject is the way to persist the whole app's data in a single, non-replicated class, but it still seems like SwiftUI isn't really designed for passing that data to plain classes. To quote this StackOverflow comment, https://stackoverflow.com/a/65370757/1359088,

A possible approach is to make it shared (and don't use @EnvironmentObject anywhere outside SwiftUI view - it is not designed for that)

I found it interesting that he/she said the @EnvironmentObject isn't designed for being used outside the view. His/her answer is the same as your suggested solution, which is to create a shared instance of the ObservableObject, which serves both worlds:

  • In the SwiftUI world, it uses its @Published vars to publish updates to views that need to display the appViewModel's values as they change;
  • In the non-SwiftUI world, it makes itself available via the appViewModel.shared instance, which is where DoNetworkStuff should go to fetch the devId.

Why is it that plain classes like DoNetworkStuff can't see an environmentObject? Why wasn't that made a feature? Why must you "bridge the gap" between the SwiftUI ObservableObject and this plain class by making the shared instance?

Or, maybe you could make all classes like DoNetworkStuff "opt-in" to the SwiftUI world by importing SwiftUI and making them conform to the ObservableObject protocol--in which case they WOULD be able to see the environmentObject? Do you think that's a better solution, is to make all classes operate within the SwiftUI world, or is it better to leave them as plain classes and bring the data over to them via the shared instance?

DataModel + ViewModel doesn't automatically imply any duplication in sources of truth. For example, in an app that tracks the behavior of my pets, the set of pets is not specific to any view, so belongs in the data model. The display order in which pets are listed in a particular view seems like something whose source of truth belongs in a view model. The view itself can use data from both kinds of model to display an actual list of pets, but there's no duplication in the source of truth. Conversely, non-SwiftUI code shouldn't be using view-specific state, so there's no need to expose the view model outside of the views — and you can safely have multiple views with separate view model instances to display the pets in different orders in different views.

You can't use the @EnvironmentObject property wrapper outside of SwiftUI, but you can use a reference to the object itself anywhere in your app. (I'm ignoring any considerations of concurrent access to the data, which may be important, but is an unrelated issue.)

Note that although ObservableObject is a conformance that's primarily intended for SwiftUI functionality (because it enables the use of the @Published property wrapper on properties of the class), instances of such a class exists perfectly normally "outside" SwiftUI. You can use them in normal ways — you just can't make use of the @Published value-change notification behavior except inside SwiftUI••.

For that reason, the thing you describe in your last paragraph just isn't an issue. ObservableObject doesn't "import" instances into the SwiftUI world. It just adds some standard behavior that only SwiftUI can make use of.


** Actually, you can use @Published behaviors outside SwiftUI because they're actually based on the Combine framework. If you're a Combine wiz, you can subscribe to these property change notifications directly. However, SwiftUI is moving on to other things in the future, notably the @Observable macro, so I honestly would not recommend direct use of Combine if you're going to continue to use SwiftUI in the most natural ways.

@Polyphonic I guess my perception of the @EnvironmentObject (or @Environment in iOS 17) as the place where the app's data should be persisted is not quite right. It does seem telling that in Apple's article (https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro) about migrating to the Observation frameworks, the @Environment is only used in structs and not in classes. In your example of the pets app, is this the basic data flow:

  • Data starts in long-term disk storage (either SQLite/Core Data or UserDefaults).
  • On app startup, any data needed during app running is copied into the AppDataModel class.
  • When a view opens, any data needed for display is copied into the view's XYZViewModel class, which only exists while XYZ view exists. If any changes are made to data during that time, that changes are migrated back to the AppDataModel class (and from there to SQLite/Core Data or UserDefaults).

It makes me wonder if you really need the view model class, when you could just use@State vars for that view. I have read that ViewModel classes make for easier unit tests, which we do use in our app, and which makes sense I suppose since you can instantiate the class independent of the view for testing.

SwiftUI and vars outside the SwiftUI world
 
 
Q