0

I have a view, TimeEditView, that edits an object of an Item class I made. The class does not conform to ObservableObject, since if it does, then it will cause issues with using GRDB.

I am currently using that for my database, since for objects to be compatible with saving to and reading from a GRDB database, their classes must conform to Codable, and using the @Published property wrapper with variables makes the class no longer automatically conform to Codable. Manually adding conformance is something I would like to avoid unless it is absolutely necessary.

Because of this, I don't know how best to be able to bind to / observe the object so that I can edit it and observe its changes.

import SwiftUI

struct TimeEditView: View {
    @StateObject var vm = PreviewViewModel()
    @ObservedObject var item: Observable<Item>
    //@State var item: Item = Item()
    @Binding var selectedTime: Date
    @State var includesTimes: Bool = false
    var body: some View {
        NavigationStack {
            VStack {
                Text(item.name)
                TextField("Name", text: $item.name)
                    
                Toggle("Time", isOn: $item.includesTimes)
                    .padding()
                    .background(
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.ultraThinMaterial)
                    )
                if item.includesTimes {
                    DatePicker("Time", selection: $selectedTime, displayedComponents: [.hourAndMinute])
                        .datePickerStyle(WheelDatePickerStyle())
                        .labelsHidden()
                        .frame(maxWidth: .infinity)
                        .background(
                            RoundedRectangle(cornerRadius: 10)
                                .fill(.ultraThinMaterial)
                                
                        )
                }
            }
            .padding()
            .navigationTitle("Time")
            .navigationBarTitleDisplayMode(.inline)
            .presentationDetents([.medium])
            .animation(.easeInOut(duration: 0.2), value: item.includesTimes)
        }
    }
}

The behaviour I am expecting to see is the item.name Text updating when I type into the textfield (that is just a test to ensure the binding is working), and the DatePicker being hidden and shown when I switch the toggle (this is what I actually need to work within my app).

In other views, I have simply bound the object and it seems to have worked okay, updating when I make changes to the item. But when using @State var item: Item here, this does not work. The DatePicker remains hidden when I switch the toggle.

#Preview {
    @Previewable @State var item = Item()
    @Previewable @State var selectedTime = Date.now
    TimeEditView(item: $vm.item, selectedTime: $selectedTime)
}

I then tried using a View Model to store an Item to pass into the Preview as a binding, as this is the approach I will use in the view that contains TimeEditView:

final class PreviewViewModel: ObservableObject {
    @Published var item: Item = Item()
}

#Preview {
    @Previewable @StateObject var vm = PreviewViewModel()
    @Previewable @State var selectedTime = Date.now
    TimeEditView(item: $vm.item, selectedTime: $selectedTime)
}

But this didn't work either. Still no updates when changing the name or toggling the switch.

The only thing that did seem to work is using a custom Observable class provided here: How to make a non-ObservableObject observable? and using it with Item.

@dynamicMemberLookup
class Observable<M: AnyObject>: ObservableObject {
    var model: M
    init(_ model: M) {
        self.model = model
    }
    
    subscript<T>(dynamicMember kp: WritableKeyPath<M, T>) -> T {
        get { model[keyPath: kp] }
        set {
            self.objectWillChange.send() // signal change on property update
            model[keyPath: kp] = newValue
        }
    } 
}

// In TimeEditView
@ObservedObject var item: Observable<Item>

#Preview {
    @Previewable @StateObject var item = Observable(Item())
    @Previewable @State var selectedTime = Date.now
    TimeEditView(item: item, selectedTime: $selectedTime)
}

But I don't really understand how it works, and am not entirely sure if this is the correct way in my case.

Is that the correct way to achieve this? Or is there a better way I should use? I am very confused how I can achieve this as different methods seem to work in different views and I want a common approach I can use across multiple views. I would just pass the parent View Model into DateEditView and access the item from that, but I would like this view to be reusable within other views, so that approach would not work. I'm still pretty new at SwiftUI and Swift so sorry if I am missing something obvious.

0

2 Answers 2

1

I think you're missing a model layer. You need a model store object (ObservableObject) that loads and saves the GRDB types into your own model types. Then you can bind these types to the UI. Then when you set the updated model type on the model store, it can then create or update the GRDB type (if it's cached) and save it. If you are good with Combine pipelines this can be set up in the init to run automatically.

If your model type has relationships use class, otherwise use struct. Class will be tricky for the model store to track so you might want to go down the UndoManager route that ReferenceFileDocument uses. Basically instead of the store tracking every object it just tracks the undo manager and in SwiftUI it makes any changes via the undo manager.

It would be better if the GRDB type was a struct instead of a class too, since its identity is in the database.

By the way the reason you need a model layer vs using GRDB directly from the UI. Is eventually you'll probably want 2 bits of UI to use the same data in the database, so either they will go out of sync, or you need to tie the UI to monitoring the disk which is not feasible on iOS.

Sign up to request clarification or add additional context in comments.

Comments

0

For deployment targets macOS 14+ / iOS 17+, move away from ObservedObject/ObservableObject to the newer Observation. This would also solve your Codable dilemma. Practically, you mark your Item class with @Observable, then in the owning view you simply declare:

var item: Item

If you still need to support the older observation mechanism, I recommend to embrace finally manually conforming to Codable, instead of using a pyramid of hacks.

1 Comment

If you do need back porting though, I'd recommend checking out github.com/pointfreeco/swift-perception

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.