0

I've developing the UI for my app which uses Realm for the backend. Text is entered by the user in a customised TextField that uses a "floating" label to make efficient use of the screen. The label animation (backed by very simple logic) is triggered by checking the state of a Binding between the properties of the Realm object and the custom View.

When I was prototyping the view I used a @State or @Binding property backed by a struct and the view behaves exactly as expected. However, when using this in my main app, @State or @Binding wrap a class (a Realm Object) and the transition is never triggered, certainly because the view doesn't register the change in its internal state so it isn't re-rendered with the changes in colour and offset.

I thought I had the solution when I realised that I should be using @ObservableObject when working with classes but this doesn't work either. Thinking it through, this seems to make sense although I get a bit confused about how the various property wrappers are working.

I'm very much suspect there's a workaround for this and my first port of call is likely to hooking into willChange to modify the necessary state. But, in the meantime, could someone explain what is going on here as I'm a bit hazy. If you happen to have a solution to hand, this might stop me going off on some mad tangent?

Custom view:

struct FloatingLabelTextField: View {
let title: String
let text: Binding<String>
let keyboardType: UIKeyboardType?
let contentType: UITextContentType?

var body: some View {
    ZStack(alignment: .leading) {
        Text(title)
            .foregroundColor(text.wrappedValue.isEmpty ? Color(.placeholderText) : .accentColor)
            .offset(y:text.wrappedValue.isEmpty ? 0 : -25)
            .scaleEffect(text.wrappedValue.isEmpty ? 1 : 0.8, anchor: .leading)

        TextField("", text: text, onEditingChanged: { _ in print("Edit change") }, onCommit: { print("Commit") })
            .keyboardType(keyboardType ?? .default)
            .textContentType(contentType ?? .none)
        
    }
    .padding(15)
    .border(Color(.placeholderText), width: 1)
    .animation(.easeIn(duration: 0.3))
}
}

Functional implementations:

struct MyView: View {
@State private var test = ""

var body: some View {
    VStack {
        FloatingLabelTextField(title: "Test", text: $test, keyboardType: nil, contentType: nil)
    }
}
}

or...

struct TestData {
    var name: String = ""
}

struct ContentView: View {
    @State private var test = TestData()
    
    var body: some View {
        VStack {
            FloatingLabelTextField(title: "Test", text: $test.name, keyboardType: nil, contentType: nil)
    }
}

With struct

Using @State/@Binding with an object:

class TestData: ObservableObject {
    var name: String = ""
}

struct ContentView: View {
    @ObservedObject private var test = TestData()
    
    var body: some View {
        VStack {
            FloatingLabelTextField(title: "Test", text: $test.name, keyboardType: nil, contentType: nil)
    }
}

Using an object

rustproofFish
  • 931
  • 10
  • 32

2 Answers2

1

Added the missing @Published and brought Object back in (original code posted to show the problem in simplest terms possible).

@objcMembers
class TestData: Object {
    @Published dynamic var name: String = ""
}

struct ContentView: View {
    @ObservedObject private var test = TestData()
    
    var body: some View {
        VStack {
            FloatingLabelTextField(title: "Test", text: $test.name, keyboardType: nil, contentType: nil)
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
}

Above crashes with following error:

error message

As noted in comments above, when creating an Object for the first time, standard practice is not to save to the Realm (and thus make it managed) until client-side validation has completed. It looks like a different approach is required.

UPDATED:

Solution 1 - create the Object only once the data field(s) have been populated.

I've thrown together the following using Realm's SwiftUI example code. It's very messy as I'm not up to speed with the new SwiftUI @App wrappers, etc but the UI works as expected (I'm no longer binding the UI and the Object, relying instead on @State properties) and I've confirmed that the Realm store is being updated correctly.

@main
struct TestApp: App {
    var body: some Scene {
        let realm = try! Realm()
        var dogs = realm.object(ofType: Dogs.self, forPrimaryKey: 0)
        if dogs == nil {
            dogs = try! realm.write { realm.create(Dogs.self, value: []) }
        }
        
        return WindowGroup {
            ContentView(dogs: dogs!.allDogs)
        }
    }
}

Realm Objects:

final class Dog: Object {
    @objc dynamic var id: String = UUID().uuidString
    @objc dynamic var name: String = ""
}

final class Dogs: Object {
    @objc dynamic var id: Int = 0
    
    // all data objects stored in the realm
    let allDogs = RealmSwift.List<Dog>()
    
    override class func primaryKey() -> String? {
        "id"
    }
}

Main view:

struct ContentView: View {
    let dogs: RealmSwift.List<Dog>
    
    // MARK: UI state
    @State private var name = ""
    
    var body: some View {
        Form {
            FloatingLabelTextField(title: "Name", text: $name, keyboardType: nil, contentType: nil)
            
            Button("Commit") {
                var dog = Dog()
                dog.name = name
                self.dogs.realm?.beginWrite()
                self.dogs.append(dog)
                try! self.dogs.realm?.commitWrite()
                self.name = ""
            }
        }
    }
}

SOLUTION 2 (unsuccessful):

My attempts to approach the problem by following the error message (Realm Object needs to be managed by Realm to permit change notifications) didn't go well. When creating a new Object from scratch, a "blank" version with temporary values set on the properties must be written to the Realm and then the text field used to edit the properties, writing the changes back to the Realm in a separate transaction.

Because using Realm with SwiftUI is a bit more complex than is the case with UIKit (new Objects are written via a master List object rather than independently) this got complicated very quickly and so I've decided it's not worth the time and effort as the first solution seems to be OK. Of course, I'd be very interested in anyone else's views (or solutions) if they have one...

rustproofFish
  • 931
  • 10
  • 32
0

I believe you just have to add a few missing pieces.

class TestData: ObservableObject {
    @Published var name: String = ""
 }
  • That rectifies the original issue (as already pointed out by Asperi( but @Published can't be used with Realm Objects unless they are managed (see my additional code). As is so often the case, I've solved one issue and then run straight into another :-) – rustproofFish Jun 24 '20 at 15:28