SwiftUI inspired state observing without SwiftUI
So, like ObservableObject
but without any of that SwiftUI or Combine stuff
class MyView: UIView {
/// Conform your model classes to `ObservableState`
class ViewModel: ObservableState {
/// Use `ObservedValue` for your model’s properties
@ObservedValue var count: Int = 0
}
class StepperView: UIView {
/// Store passed through bindings with `ValueProxy`
@ValueProxy var count: Int
lazy var minusButton = UIButton(frame: .zero, primaryAction: UIAction { [unowned self] _ in
count -= 1
})
lazy var plusButton = UIButton(frame: .zero, primaryAction: UIAction { [unowned self] _ in
count += 1
})
init(count: ValueProxy<Int>) {
self._count = count
super.init(frame: .zero)
self.addSubview(minusButton)
self.addSubview(plusButton)
}
}
@ObservedState var viewModel = ViewModel()
var observers: [StateValueObserver] = []
lazy var countLabel = UILabel()
// Pass value proxy to ViewModel’s count property
lazy var stepper = StepperView(count: $viewModel.count)
func setupView() {
addSubview(stepper)
// Use an update handler to set the label’s text when count updates
$viewModel.count.didUpdate { [weak self] count in
self?.countLabel.text = "\(count)"
}.add(to: &observers)
}
}
(basic counter sample code demonstrating updating a ValueProxy
and didUpdate
logic)
To add this dependency to your Xcode project, select File -> Add Package and enter this repository’s URL: https://github.com/PimCoumans/DidUpdate
SwiftUI is great, but for now I feel more comfortable using plain old UIKit for the more complex parts of my apps. I do love how SwiftUI lets you define state and have it automatically update all your views when anything changes. I wanted that, but not with the overhead of importing SwiftUI or Combine and using a bunch of publishers, or learning a whole new reactive library.
So I reverse-over-engineered the parts I liked and introduced the ability to add update handlers to your bindings (ValueProxy
in DidUpdate land).
Now you can have a tiny reactive-ish architecture for your UIKit views too!
The two main features are
- Inform you when a specific property in your model class has been updated. If your value conforms to
Equatable
you’ll know when its value was actually changed. - Pass along two-way binding property wrappers that can read and update properties on your model class, making sure its
didSet { }
is called as well. There’s also the convenient availability to create bindings to nested properties using KeyPath subscripts (like$viewModel.someFrame.size.width
).
To enable this magic, make sure your model object conforms to ObservableState
and hold onto it using the @ObservedState
property wrapper in your view (controller). For all your model’s properties use @ObservedValue
when you want these to be observable. Take another gander at the example above to see how it all fits together.
On all value properties you get a bunch of didUpdate
methods, allowing you to provide update handlers that are executed when the property is updated.
let observer = $viewModel.username.didUpdate { username in
print("Username updated to: \(username)")
}
or when you have a @ValueProxy
set in some other view:
let observer = $username.didUpdate { username in
print("Username updated to: \(username)")
}
Ideally you’d store those returned observers in an array, much like [AnyCancellable]
:
var observers: [StateValueObserver] = []
func addObservers() {
$username.didUpate { newValue in
// ...
}.add(to: &observers)
}
Besides didUpdate
there’s also didChange
indicating the value has actually changed (meaning not considered equal when conforming to Equatable
):
let observer = $viewModel.username.didChange { username in
print("Username has changed to: \(username)")
}
and didChange(comparing:)
to compare the values at a given key path:
// Update handler only called when username.isEmpty changes
let observer = $viewModel.username.didChange(comparing: \.isEmpty) { username in
if !username.isEmpty {
print("Username no longer empty")
} else {
print("Username empty again")
}
}
To pass around two-way bindings to these values, you can create a ValueProxy
by accessing the projected value (with $
) of your object’s property wrapper:
class SubView: UIView {
@ValueProxy var username: String
init(username: ValueProxy<String>) {
_username = username
}
}
// in your main view, access the projected value using the `$` prefix
let someSubView = SubView(username: $viewModel.username)
Changing the username property in SubView
in this example would automatically update the property in your viewModel. Reading the username
property in SubView
would give you the actual up-to-date value, even when changed from somewhere else (just like you’d expect from @Binding
).
That’s about it! Please let me know if you have any questions.