swiftswiftuibindingobservableobjectenvironmentobject

SwiftUI: Binding on property of struct derived from environment object


I have two structs in my task manager app:

struct Task {
    var identifier: String
    var title: String
    var tags: [String] // Array of tag identifiers
}
struct Tag {
    var identifier: String
    var title: String
}

I then have a class to store them:

class TaskStore: ObservableObject {
    @Published var tasks = [String:Task]()
    @Published var tags = [String:Tag]()
}

which I pass to my root view as an .environmentObject(taskStore).

Correct me if any of the following is wrong (against bad practices):

In my TaskView I have:

    @EnvironmentObject var taskStore: TaskStore

    var taskIdentifier: String // Passed from parent view

    private var task: Task {
        get {
            return taskStore.tasks[taskIdentifier]! // Looks up the task in the store
        }
    }
    private var tags: [Tag] {
        get {
            return taskStore.tags
        }
    }

The issue is, when learning SwiftUI I was told when making certain components (like a picker that let's you alter the tags array in this case) that it should accept a binding to the value/collection, or say I want to make the task title editable, I need a binding to the task.title property, both of which I can't do because (based on the way I'm defining and computing task) I can't get a binding on task.

Am I doing something against best practices here? Or where along this path did I diverge from the right way of storing points of truth in an environment object, and make them editable in sub views.


Solution

  • No, you're not necessarily doing something against best practices. I think in SwiftUI, the concepts of data model storage and manipulation quickly become more complex than, for example, what Apple tends to show in its demo code. With a real app, with a single source of truth, like you seem to be using, you're going to have to come up with some ways to bind the data to your views.

    One solution is to write Bindings with your own get and set properties that interact with your ObservableObject. That might look like this, for example:

    struct TaskView : View {
        var taskIdentifier: String // Passed from parent view
        
        @EnvironmentObject private var taskStore: TaskStore
        
        private var taskBinding : Binding<Task> {
            Binding {
                taskStore.tasks[taskIdentifier] ?? .init(identifier: "", title: "", tags: [])
            } set: {
                taskStore.tasks[taskIdentifier] = $0
            }
        }
        
        var body: some View {
            TextField("Task title", text: taskBinding.title)
        }
    }
    

    If you're averse to this sort of thing, one way to avoid it is to use CoreData. Because the models are made into ObservableObjects by the system, you can generally avoid this sort of thing and directly pass around and manipulate your models. However, that doesn't necessarily mean that it is the right (or better) choice either.

    You may also want to explore TCA which is an increasingly popular state management and view binding library that provides quite a few built-in solutions for the type of thing you're looking at doing.