Keystrokes in TextFields using @ObservedObject+@Published very choppy, but fine with @State

I'm working on an iPad app that's essentially a large form (with text fields, pickers, etc).


Initially I made the whole UI using @State variables just to get the UI behaviour working, now I want to transition to using @Published vars in view models, etc. I do desire bidirectional binding.


When I moved all the @State Strings to @ObservedObject and @Published the performance of typing in the text fields became very slow and choppy.


It seems worse the more I add. Performance was buttery smooth with @State, but as soon as I made them the view model @ObservedObject things went downhill.


Using Xcode 11.2.1 and iPad simulator (though it's still choppy in an iPhone simulator). 2018 MBP.


Either I'm misunderstanding the way @ObservedObject and @Published works and it's chasing it's tail or something, or there could be a problem in Combine/SwiftUI somewhere?


I've included some demo code, even half this many elements still results in a noticable choppy experience.


import SwiftUI
import Combine

struct ContentView: View {
    //Slow
    @ObservedObject var viewModel: ViewModel = ViewModel()
    //Smooth
    //@State var viewModel: ViewModel = ViewModel()
    var body: some View {
        Form {
            Section {
                Group {
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field1)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field2)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field3)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field4)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field5)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field6)
                    }
                }
                Group {
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field7)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field8)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field9)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field10)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field11)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field12)
                    }
                }
                Group {
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field13)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field14)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field15)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field16)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field17)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field18)
                    }
                }
                Group {
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field19)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field20)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field21)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field22)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field23)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field24)
                    }
                }
                Group {
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field19)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field20)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field21)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field22)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field23)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field24)
                    }
                }
                Group {
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field19)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field20)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field21)
                    }
                    HStack {
                        Text("Derp")
                        TextField("Derp", text: $viewModel.field22)
                    }
                }
            }
        }
    }
}

class ViewModel: ObservableObject {
    @Published var field1: String = ""
    @Published var field2: String = ""
    @Published var field3: String = ""
    @Published var field4: String = ""
    @Published var field5: String = ""
    @Published var field6: String = ""
    @Published var field7: String = ""
    @Published var field8: String = ""
    @Published var field9: String = ""
    @Published var field10: String = ""
    @Published var field11: String = ""
    @Published var field12: String = ""
    @Published var field13: String = ""
    @Published var field14: String = ""
    @Published var field15: String = ""
    @Published var field16: String = ""
    @Published var field17: String = ""
    @Published var field18: String = ""
    @Published var field19: String = ""
    @Published var field20: String = ""
    @Published var field21: String = ""
    @Published var field22: String = ""
    @Published var field23: String = ""
    @Published var field24: String = ""
}

Replies

Running your example through the SwiftUI profiling tools shows a stark difference between the two approaches.


Using @ObservedObject every key-press causes the observed object to be updated—the ViewModel itself—which causes SwiftUI to re-invoke your body property. This then triggers a complete attribute graph update, which isn't a small amount of work since every other sub-view is referencing the updated object, so SwiftUI can't quickly exclude any of the from the update.


Using @State, however, I don't see a single view update being triggered.


This is likely due to the behavior of @State, which is designed to work with value types. Since you're dropping a class in there, the @State's wrapped value is never changing; the reference it holds to an instance of the ViewModel class isn't being replaced. Instead, just the internals are being modified. When you use @ObservedObject though, the @Published attributes all kick in, triggering a publisher for their owning object. @ObservedObject in SwiftUI causes it to subscribe to that object, not to an individual @Published item. Instead, it's seeing an update to everything whenever anything changes.


So, ultimately the culprit is the use of @Published on all your properties. If you remove that property from the properties, then you will see exactly the same performance characteristics that you had with @State. In fact, this is because the exact same things are happening under the hood: both @State and @ObservedObject use their $-prefix syntax to return bindings to their contents; i.e. $viewModel.field1 will return a value of Binding<String> whether your viewModel uses @State or @ObservedObject. In both cases, the object itself isn't modified, only some single content value, which is only being observed by the text field itself.


In fact, if you use @State and make ViewModel a struct type, you'll see the exact same slow behavior: every modification of a struct's properties is a modification of the struct itself, triggering a full view update.


So, we have one potential solution already: use @ObservedObject but drop the @Published attributes. This is fine if the values only need to be bound to text fields, and there are no static views that need to use their content (i.e. a Text() somewhere reading from the same value). If that's the case, you're good to go. If not, then you'll still need the publishing behavior to trigger a redraw where necessary.


Ultimately I'd suggest working at ways to break apart your view model into bite-sized pieces which can be updated independently, rather than putting everything into one big bag. Having it all in one object using @Published on all the sub-items means that the entire view hierarchy is being re-evaluated on every single key-press, because that's what @Published is for. It's more common to have a couple of @Published properties, and for them to be updated only relatively rarely—certainly not as often as a quick-typing user can type.


If you can give an example of the actual types of data you're using and the view hierarchy built from it, I'd be happy to try out a few approaches and see what works best.

@Jim Dovey: Thank for that good explanation. I had this problem as well more then once and I also found out, that SwiftUI completely rerenders the whole UI altough only one property is changed. The example of tom.king can be easily splitted up into @State so you can avoid this problem.

But how to do with sth. more complex like this?

Code Block
class ViewModel: ObservableObject {
@Published var documents: [Document]
struct Document {
var fileName: String = ""
var documentName: String = ""
var someOtherInfo = 4
}
init(fieldCount: Int) {
self.documents = Array<Document>.init(repeating: Document(), count: fieldCount)
}
}
struct ContentView: View {
@ObservedObject var viewModel = ViewModel(fieldCount: 20)
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading) {
ForEach(self.viewModel.documents.indices) { index in
DocumentView(viewModel: self.viewModel, index: index)
}
}
}
}
}
struct DocumentView: View {
@ObservedObject var viewModel: ViewModel
var index: Int
var body: some View {
VStack(alignment: .leading) {
Text("\(index)")
HStack {
Text("Filename:")
.frame(width: 200)
TextField("", text: self.$viewModel.documents[index].fileName)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
HStack {
Text("DocumentName:")
.frame(width: 200)
TextField("", text: self.$viewModel.documents[index].documentName)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
.padding()
.background(Rectangle().fill(Color(#colorLiteral(red: 0.2549019754, green: 0.2745098174, blue: 0.3019607961, alpha: 1))).cornerRadius(15))
}
}