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:

It’s the kind of moment that requires a plastic duck 🐤

SharePal app icon

SharePal

Lighting speed sharing!

Learn more

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:

A diagram showing the relations between State, Binding, and the underlying value stored by SwiftUI

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 app icon

SharePal

Lighting speed sharing!

Learn more

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.

Thank you for registering!
You’ll retrieve all of my latest news!

Your email is sadly invalid.
Can you try again?

An error occurred while registering.
Please try again