SwiftUI’s First-Toggle Re-Render: Documenting @Binding Location Box Behavior

Published by malhal on

When profiling SwiftUI view trees with Self._printChanges(), child views receiving projected bindings ($) from an ObservableObject (@StateObject or @ObservedObject) exhibit a unique lifecycle pattern on their first parent state update.

Here is a minimal reproducible test case documenting the exact behavior, memory addresses, and diffing output across consecutive render passes.

The Minimal Test Setup

Consider a view hierarchy with two models: one using ObservableObject (CombineModel) and one using Swift 5.9’s @Observable macro (ObservableModel).

Swift

import SwiftUI

class CombineModel: ObservableObject {
    @Published var counter = 0
}

@Observable
class ObservableModel {
    var counter = 0
}

struct ContentView: View {
    @StateObject var model1 = CombineModel()
    @State var model2 = ObservableModel()
    @State var toggle = false

    var body: some View {
        Form {
            Toggle("isOn \(toggle.description)", isOn: $toggle)

            BindingTestA(counter: $model1.counter)
            BindingTestB(counter: $model2.counter)
        }
    }
}

struct BindingTestA: View {
    @Binding var counter: Int

    var body: some View {
        let _ = Self._printChanges()
        Text("Value A: \(counter)")
    }
}

struct BindingTestB: View {
    @Binding var counter: Int

    var body: some View {
        let _ = Self._printChanges()
        Text("Value B: \(counter)")
    }
}

To inspect the internal pointer identity of the binding, we can use Swift’s Mirror API to log the underlying LocationBoxmemory address inside the child views:

Swift

func printBindingAddress<T>(_ binding: Binding<T>, label: String) {
    let mirror = Mirror(reflecting: binding)
    if let box = mirror.children.first(where: { $0.label == "location" })?.value as? AnyObject {
        let address = Unmanaged.passUnretained(box).toOpaque()
        print("📍 [\(label)] LocationBox Address: \(address)")
    }
}

The Observed Execution Log

Here is the exact trace of Self._printChanges() and memory addresses across three consecutive UI events:

Pass 1: Initial Render (onAppear)

When the application first loads, both child views execute their body properties:

  • BindingTestA (@StateObject / @ObservedObject):
    • body executed.
    • LocationBox Address: 0x10be379c0
  • BindingTestB (@Observable):
    • body executed.

Pass 2: First Parent State Change (Toggle false $\rightarrow$ true)

When the user taps the toggle for the first time, ContentView re-evaluates its body.

  • BindingTestA (@StateObject / @ObservedObject):
    • Self._printChanges() output: BindingTestA: _counter changed.
    • body executed.
    • LocationBox Address: 0x10bfe1680 (Address changed)
  • BindingTestB (@Observable):
    • Self._printChanges() output: (No output)
    • body skipped.

Pass 3: Second Parent State Change (Toggle true $\rightarrow$ false)

When the user taps the toggle a second time, ContentView re-evaluates its body again.

  • BindingTestA (@StateObject / @ObservedObject):
    • Self._printChanges() output: (No output)
    • body skipped.
    • LocationBox Address: 0x10bfe1680 (Address unchanged)
  • BindingTestB (@Observable):
    • Self._printChanges() output: (No output)
    • body skipped.

Fact Summary Table

Render EventBindingTestA (@StateObject / @ObservedObject)BindingTestA LocationBox AddressBindingTestB (@Observable)
Initial Renderbody called0x10be379c0 (Address A)body called
1st Togglebody called (_counter changed)0x10bfe1680 (Address B)body skipped
2nd Togglebody skipped0x10bfe1680 (Address B)body skipped
3rd+ Togglebody skipped0x10bfe1680 (Address B)body skipped

Empirical Observations

  1. Both @StateObject and @ObservedObject share this behavior: Projecting a property binding ($) from either property wrapper type produces a LocationBox<...ScopedLocation> that changes memory address between the initial render and the first parent state update.
  2. The re-render occurs exactly once: On the first parent state change, Self._printChanges() reports _counter changed for the ObservableObject binding, executing the child view’s body a second time.
  3. Subsequent state updates stabilize: From the second parent update onward, the LocationBox address remains identical across passes, and SwiftUI prunes/skips the child view evaluation.
  4. @Observable does not exhibit the first-toggle re-render: Projected bindings derived from @Observable model properties skip child view re-evaluation starting from the very first parent state change.
Categories: SwiftUI