How (not) to monitor SwiftUI @State
While I was working on the first version of my latest app SharePal ⚡️, I figured that I’d like to add haptic feedback for distinct action within the app.
Something that did not worked well…
Since I’m targeting iOS 16 as a base, I couldn’t use .sensoryFeedback
directly: it was only introduced with iOS 17.
And before trying to mock it with my own modifier implementation, I tried something like this:
struct AppView: View {
@State private var deleting: Item? {
didSet {
if deleting != nil {
UINotificationFeedbackGenerator().notificationOccurred(.warning)
}
}
}
...
}
Let spoil it now: this approach does not work. At least, not completely:
- When
deleting
State is updated by AppView, it works 🥳 - When I pass
$deleting
binding to be mutated by another view … it does not 💥
It’s the kind of moment that requires a plastic duck 🐤
SharePal
Lighting speed sharing!
Behind State and Binding and didSet
After a small period of confusion regarding this unexpected behavior, I took some time to really dig further what’s behind SwiftUI’s State and Binding property wrapper, and the magic that is hidden from us.
And it turns out that neither the State struct, nor the view actually hold my value. From what I understand, the value is held by a “source of truth” coordinator, hidden from us, aside the SwiftUI hierarchy.
This value is registered by the @State
property wrapper, and mark my view as the “holder”. And it’ll also trigger my view recalculation if the value change.
Things looks like this:
Neither State or Binding holds the value. So neither of them are mutated when the value is updated. Meaning that didSet is not guaranteed to be fired
On the view side, we have something like this, also hidden from us:
struct AppView {
var _deleting: State<Item?> = State(initialValue: nil)
var deleting: Item? {
get {
_deleting.wrappedValue
}
nonmutating set {
_deleting.wrappedValue = $0
}
}
var $deleting: Binding<Item?> {
_deleting.projectedValue
}
}
Now, a word about didSet
. It is not called when the value changes, but when the value get assigned. And those, in that case, are two very different things.
Let’s have a look to my two scenarios from before:
When I update the value from AppView, I assign a new value to deleting
directly, so the didSet on deleting is fired.
Underneath, What’s really happening is going through the state wrapped value, so SwiftUI get the new value as well
AppView.deleting = something
// translating to
AppView._deleting.wrappedValue = something
On the other hand, when I update my deleting
from another view using the binding helper $deleting
, the flow is different:
AppView.$deleting.wrappedValue = something
// translating to
AppView._deleting.projectedValue.wrappedValue = something
In that second case, at the end, the value known by SwiftUI is updated, but it’s not using deleting
assignment. So Swift is right by not firing the didSet implementation, it is actually an expected behavior.
How to actually monitor the changes of a State
A better approach for checking when the value changes is to use .onChange
modifier directly in the view body:
struct AppView: View {
@State private var deleting: Item?
var body: some View {
Text("Hello, World!")
.onChange(of: deleting) { newValue in
if newValue != nil {
UINotificationFeedbackGenerator().notificationOccurred(.warning)
}
}
}
}
.onChange
will be in charge of tracking updates made to the Equatable
state, and call the callback whenever the value change, would it be a change inferred by a Binding from a subview, or directly from that view.
This approach works, and looks closer to .sensoryFeedback
modifier from Apple, that also take an Equatable
trigger as an input.
Implementing my own sensory feedback
Like I described in my SwiftUI backward-compatibility story, I love trying to match as much as possible to Apple’s implementation for my own modifiers, and if possible, calling the system version when available.
Doing so requires a few pieces.
First, I need to describe the sensory feedback types, and how to trigger them:
@available(iOS, deprecated: 17, message: "Use the one with iOS 17 instead!")
public enum SensoryFeedback {
...
case warning
...
@available(iOS 17, *)
var swiftuiValue: SwiftUI.SensoryFeedback {
switch self {
...
case .warning:
return .warning
...
}
}
@available(iOS, deprecated: 17)
func generate() {
switch self {
...
case .warning:
UINotificationFeedbackGenerator().notificationOccurred(.warning)
...
}
}
}
This enum will describe similarly to Apple’s API the available sensory feedbacks, and have a method to get the SwiftUI version of it.
Then, I can write a backward compatibility modifier that will take full advantage of .onChange
, and that will also include the condition:
parameter from Apple’s api.
@available(iOS, deprecated: 17, message: "Use the one with iOS 17 instead!")
struct SensoryFeedbackModifier<T: Equatable, Generator: UIFeedbackGenerator>: ViewModifier {
let generate: () -> Void
let condition: ((T, T) -> Bool)?
let value: T
func body(content: Content) -> some View {
content
.onChange(of: value) { newValue in
if condition?(value, newValue) != false {
generate()
}
}
}
}
The final piece of the puzzle is the call site. Since we don’t use the same type than Apple, we can “override” SwiftUI iOS 17 modifier:
extension View {
@available(iOS, deprecated: 17, message: "Use the one with iOS 17 instead!")
@ViewBuilder
public func sensoryFeedback<T: Equatable>(_ feedback: SensoryFeedback, trigger: T, condition: ((_ oldValue: T, _ newValue: T) -> Bool)? = nil) -> some View {
if #available(iOS 17.0, *) {
if let condition {
sensoryFeedback(feedback.swiftuiValue, trigger: trigger, condition: condition)
} else {
sensoryFeedback(feedback.swiftuiValue, trigger: trigger)
}
} else {
modifier(SensoryFeedbackModifier(generate: feedback.generate, condition: condition, value: trigger))
}
}
}
and with that approach, the call side is indistinguishable from an iOS 17 only app:
struct AppView: View {
@State private var deleting: Item?
var body: some View {
Text("Hello, World!")
.sensoryFeedback(.warning, trigger: deleting) { _, newValue in
newValue != nil
}
}
}
Moral of the story? Instead of trying to be “smart” and use Swift features like didSet
, it’s sometime better to look into how Apple thinks their own SwiftUI API to inspire ourself from it, and make an actual SwiftUI implementation that works.
SharePal
Lighting speed sharing!
Don’t miss a thing!
Don't miss any of my indie dev stories, app updates, or upcoming creations!
Stay in the loop and be the first to experience my apps, betas and stories of my indie journey.