Aleph
Aleph

Reputation: 593

Access and modify a @EnvironmentObject on global functions

I have an ObservableObject declared on my main view (ContentView.swift).

final class DataModel: ObservableObject {
    @AppStorage("stuff") public var notes: [NoteItem] = []
}

Then I declare it in the main entry of the app as (removed extra code not needed for this example):

@main struct The_NoteApp: App {

    private let dataModel = DataModel()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(self.dataModel)
        }
}

In the ContentView.swift, I can use it on the different views I declared there:

struct NoteView: View {
    @EnvironmentObject private var data: DataModel

    // more code follows...
}

Now, I have a collection of global functions saved on FileFunctions.swift, which essentially are functions that interact with files on disk. One of them is to load those files and their content into my app.

Now, I'm trying to use @EnvironmentObject private var data: DataModel in those functions so at loading time, I can populate the data model with the actual data from the files. And when I declare that either as a global declaration in FileFunctions.swift or inside each function separately, I get two behaviors.

With the first one I get an error:

Global 'var' declaration requires an initializer expression or an explicitly stated getter`,

and

Property wrappers are not yet supported in top-level code

I tried to initialize it in any way, but it goes nowhere. With the second one, adding them to each function, Xcode craps on me with a segfault. Even if I remove the private and try to declare it in different ways, I get nowhere.

I tried the solution in Access environment variable inside global function - SwiftUI + CoreData, but the more I move things around the worse it gets.

So, how would I access this ObservableObject, and how would I be able to modify it within global functions?

Below is an example of a global function and how it's being called.

In FileFunctions.swift I have:

func loadFiles() {    
    var text: String = ""
    var title: String = ""
    var date: Date
    
    do {
        let directoryURL = try resolveURL(for: "savedDirectory")
        if directoryURL.startAccessingSecurityScopedResource() {
            let contents = try FileManager.default.contentsOfDirectory(at: directoryURL,
                                                        includingPropertiesForKeys: nil,
                                                        options: [.skipsHiddenFiles])
            for file in contents {
                text = readFile(filename: file.path)
                date = getModifiedDate(filename: file.absoluteURL)
                title = text.components(separatedBy: NSCharacterSet.newlines).first!
                
                // I need to save this info to the DataModel here

            }
            directoryURL.stopAccessingSecurityScopedResource()
        } else {
            Alert(title: Text("Couldn't load notes"),
                  message: Text("Make sure the directory where the notes are stored is accessible."),
                  dismissButton: .default(Text("OK")))
        }
        
        
    } catch let error as ResolveError {
        print("Resolve error:", error)
        return
    } catch {
        print(error)
        return
    }
}

And I call this function from here:

@main struct The_NoteApp: App {

    private let dataModel = DataModel()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(self.dataModel)
                .onAppear {
                    loadFiles()
                }
        }
}

Upvotes: 1

Views: 2469

Answers (3)

CouchDeveloper
CouchDeveloper

Reputation: 19114

As discussed in the comments, here a basic approach which makes some changes to the structure by defining dedicated "components" which have a certain role and which are decoupled as far as necessary.

I usually define a namespace for a "feature" where I put every "component" which is related to it. This offers a couple of advantages which you might recognise soon later:

enum FilesInfo {}

Using a "DataModel" or a "ViewModel" to separate your "Data" from the View makes sense. A ViewModel - as opposed to DataModel - just obeys the rules from the MVVM pattern. A ViewModel should expose a "binding". I call this "ViewState", which completely describes what the view should render:

extension FilesInfo {

    enum ViewState {
         struct FileInfo {
             var date: Date 
             var title: String
         }
         
         case undefined
         case idle([FileInfo])

         init() { self = .undefined } // note that!
    }
}

Why ViewState is an enum? Because you might want to represent also a loading state when your load function is asynchronous (almost always the case!) and an error state later. As you can see, you start with a state that's "undefined". You can name it also "zero" or "start", or however you like. It just means: "no data loaded yet".

A view model basically looks like this:

extension FilesInfo {
   
    final class ViewModel: ObservableObject {
        @Published private(set) var viewState: ViewState = .init()   

        ...
    }
}

Note, that there is a default initialiser for ViewState.

It also may have public functions where you can send "events" to it, which may originate in the view, or elsewhere:

extension FilesInfo.ViewModel {
    // gets the view model started.
    func load() -> Void {
        ...
    }

    // func someAction(with parameter: Param) -> Void
   
}

Here, the View Model implements load() - possibly in a similar fashion you implemented your loadFiles.

Almost always, a ViewModel operates (like an Actor) on an internal "State", which is not always the same as the ViewState. But your ViewState is a function of the State:

extension FilesInfo.ViewModel {
    private struct State {  
        ...
    }

    private func view(_ state: State) -> ViewState {
        //should be a pure function (only depend on state variable)
        // Here, you likely just transform the FilesInfo to 
        // something which is more appropriate to get rendered.

        // You call this function whenever the internal state
        // changes, and assign the result to the published 
        // property.
    }
   
}

Now you can define your FileInfosView:

extension FilesInfo {

    struct ContentView: View { 
       let state: ViewState
       let action: () -> Void // an "event" function
       let requireData: () -> Void // a "require data" event

       var body: some View { 
           ...
           .onAppear { 
              if case .undefined = state {
                  requireData()
              }
           }
       }
    }
}

When you look more closely on the ContentView, it has no knowledge from a ViewModel, neither from loadFiles. It only knows about the "ViewState" and it just renders this. It also has no knowledge when the view model is ready, or provides data. But it knows when it should render data but has none and then calls requireData().

Note, it does not take a ViewModel as parameter. Those kind of setups are better done in some dedicated parent view:

extension FilesInfo {
    struct CoordinatorView: View {
        @ObservedObject viewModel: ViewModel 

        var body: some View {
            ContentView(
                state: viewModel.viewState, 
                action: {},
                requireData: viewModel.load
            )
        }
    }
}

Your "coordinator view" deals with separating ViewModel from your specific content view. This is not strictly necessary, but it increases decoupling and you can reuse your ContentView elsewhere with a different ViewModel.

Your CoordinatorView may also be responsible for creating the ViewModel and creating target views for navigation. This depends on what convention you establish.

IMHO, it may make sense, to restrict the access to environment variables to views with a certain role, because this creates a dependency from the view to the environment. We should avoid such coupling.

Also, I would consider mutating environment variables from within Views a "smell". Environment variables should be kind of a configuration which you setup in a certain place in your app (also called "CompositionRoot"). You may end up with an uncontrollable net of variables if you allow that everyone can change any environment variable at any time. When you have "ViewModels" in your environment, these of course get not "mutated" when they change their state - these are classes - for a reason.

Basically, that's it for a very basic but functional MVVM pattern.

Upvotes: 0

keyvan yaghoubian
keyvan yaghoubian

Reputation: 322

I would do something like this :

final class DataModel: ObservableObject {

    public static let shared = DataModel()

    @AppStorage("stuff") public var notes: [NoteItem] = []
}


@main struct The_NoteApp: App {

    private let dataModel = DataModel.shared
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(self.dataModel)
        }
}

now in your viewModel you can access it like this

class AnyClass {
   
   init (){
      print(DataModel.shared.notes)
   }
   // or 
   func printNotes(){
      print(DataModel.shared.notes)
   }
}

Upvotes: 2

Cristik
Cristik

Reputation: 32846

You could change the signature of the global functions to allow receiving the model:

func loadFiles(dataModel: DataModel) { ... }

This way, you have access to the model instance within the function, what's left to do is to pass it at the call site:

var body: some Scene {
    WindowGroup {
        ContentView()
            .environmentObject(self.dataModel)
            .onAppear {
                loadFiles(dataModel: self.dataModel)
            }

You can do the same if the global functions calls originate from the views.

Upvotes: 2

Related Questions