Migrating your view models from the @ObservedObject
protocol to the @Observable
macro should reduce the amount of ceremony in your view models (e.g., @Published
property wrappers) while increasing the performance of your app as the @Observable
macro promises to more intelligent only invalidate the views that need an update. However, when I bind to a string value in an @Observable
view model, the opposite seems to happen:
- Binding directly to the string value in the ViewModel through
$viewModel.text
causes all views to be invalided, even the ones that do not depend on the text value. - Trying to create a
@Bindable
reference to the viewModel as per the docs gives a compiler error. - Introducing subviews that allow for a
@Bindable
view model work (in the sense that now only the views that depend on the text value get invalidated), but does not seem like a sustainable approach as we now have a lot of ceremony again – be it in the form of unnecessary subviews rather than e.g.,@Published
property wrappers.
Below is a simple app demonstrating the issues. When running this on a simulator the let _ = Self._printChanges()
calls will show which views get invalided when in the logs.
I’m using Xcode 15.4 and targeting iOS 17+.
import SwiftUI
// MARK: - ContentView
struct ContentView: View {
var body: some View {
let _ = Self._printChanges()
NavigationStack {
UserListView()
.navigationTitle("View Invalidations...")
}
}
}
// MARK: - Model
@Observable
class User: Identifiable {
var id = UUID()
}
// MARK: - Mock
extension User {
static func random() -> User {
return .init()
}
static func randomLot() -> [User] {
var result: [User] = .init()
for _ in 1..<50 {
result.append(.random())
}
return result
}
}
// MARK: - UserListView
struct UserListView: View {
// ✅ Use @State because this view is the source of truth for this view model.
@State var viewModel = ViewModel()
// ❌ Direclty initialize a Bindable -> all views in list still get invalidated when using textfield.
// @Bindable private var viewModel: ViewModel = .init()
var body: some View {
let _ = Self._printChanges()
List {
ForEach(viewModel.items) {
UserRowView(item: $0)
}
}
// ❌ Bind directly to property of viewmodel -> all views in list get invalidated when using textfield.
.overlay(alignment: .bottom) {
SomeCustomTextView(text: $viewModel.text)
}
// ❌ create @Bindable in view -> compiler error
// .overlay(alignment: .bottom) {
// VStack {
// @Bindable var model = viewModel
// SomeCustomTextView(text: $model.text)
//
// }
// }
// ✅ create @bindable in another view
// .overlay(alignment: .bottom) {
// SomeOverLayView(viewModel: viewModel)
// }
}
}
// MARK: UserListView - ViewModel
extension UserListView {
@Observable class ViewModel {
var items: [User] = User.randomLot()
var text: String = ""
}
}
// MARK: - SomeCustomTextView
struct SomeCustomTextView: View {
@Binding var text: String
var body: some View {
let _ = Self._printChanges()
TextField("Can we type without invalidating all rows?", text: $text)
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 25.0))
.padding()
.shadow(radius: 20)
}
}
// MARK: - UserRowView
struct UserRowView: View {
@State private var viewModel: ViewModel
init(item: User) {
_viewModel = .init(wrappedValue: .init(item: item))
}
var body: some View {
let _ = Self._printChanges()
Text(viewModel.user.id.uuidString)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: UserRowView - ViewModel
extension UserRowView {
@Observable class ViewModel {
var user: User
init(item: User) {
self.user = item
}
}
}
// 🔍 MARK: - Alternative, but not ideal solutions
// ✅ If we create a @Bindable of the viewmodel, we can type text without invalidating the rows...
// FIXME: But this approach woudld introduce a lot of overhead in production applications.
struct SomeOverLayView: View {
var viewModel: UserListView.ViewModel
var body: some View {
let _ = Self._printChanges()
@Bindable var model = viewModel
SomeCustomTextView(text: $model.text)
}
}
// ✅ Another approach would be to let the view take the viewmodel as a dependency.
// FIXME: but this again introduces a lot of overhead.
struct SomeCustomTextViewV2: View {
@Bindable var viewModel: UserListView.ViewModel
var body: some View {
let _ = Self._printChanges()
TextField("test", text: $viewModel.text)
}
}
#Preview {
ContentView()
}