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 ($) through intermediate wrappers like @StateObject@ObservedObject, or @Bindable exhibit a unique lifecycle pattern on their first parent state update.

Only projected bindings created directly off @State bypass this first-toggle re-render.

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 testing three binding sources:

  1. Projection via @StateObject ($model1.counter)
  2. Projection via @Bindable ($bindableModel.counter)
  3. Direct projection via @State ($model2.counter)
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 {
        @Bindable var bindableModel = model2

        Form {
            Toggle("isOn \(toggle.description)", isOn: $toggle)

            BindingTestA(counter: $model1.counter)
            BindingTestB(counter: $bindableModel.counter)
            BindingTestC(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)")
    }
}

struct BindingTestC: View {
    @Binding var counter: Int

    var body: some View {
        let _ = Self._printChanges()
        Text("Value C: \(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:

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, all three child views execute their body properties:

  • BindingTestA (@StateObject / @ObservedObject):
    • body executed.
    • LocationBox Address: 0x10be379c0
  • BindingTestB (@Bindable with @Observable):
    • body executed.
    • LocationBox Address: 0x10be38120
  • BindingTestC (Direct @State with @Observable):
    • body executed.
    • LocationBox Address: 0x10bfe2100

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 (@Bindable with @Observable):
    • Self._printChanges() output: BindingTestB: _counter changed.
    • body executed.
    • LocationBox Address: 0x10bfe1940 (Address changed)
  • BindingTestC (Direct @State with @Observable):
    • Self._printChanges() output: (No output)
    • body skipped.
    • LocationBox Address: 0x10bfe2100 (Address unchanged)

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 (@Bindable with @Observable):
    • Self._printChanges() output: (No output)
    • body skipped.
    • LocationBox Address: 0x10bfe1940 (Address unchanged)
  • BindingTestC (Direct @State with @Observable):
    • Self._printChanges() output: (No output)
    • body skipped.
    • LocationBox Address: 0x10bfe2100 (Address unchanged)

Fact Summary Table

Render EventBindingTestA (@StateObject / @ObservedObject)BindingTestB (@Bindable)BindingTestC (Direct @State)
Initial Renderbody called (0x10be379c0)body called (0x10be38120)body called (0x10bfe2100)
1st Togglebody called (0x10bfe1680 – Address changed)body called (0x10bfe1940 – Address changed)body skipped(0x10bfe2100 – Unchanged)
2nd Togglebody skipped (0x10bfe1680)body skipped (0x10bfe1940)body skipped(0x10bfe2100)
3rd+ Togglebody skipped (0x10bfe1680)body skipped (0x10bfe1940)body skipped(0x10bfe2100)

Empirical Observations

  1. @Bindable exhibits the exact same first-toggle re-render as @StateObject and @ObservedObject: Passing an @Observable instance through @Bindable produces a LocationBox whose memory address changes between initial render and the first parent state update, triggering a child view re-evaluation.
  2. The re-render occurs exactly once: On the first parent state change, Self._printChanges() reports _counter changed for both @StateObject and @Bindable projections.
  3. Subsequent state updates stabilize: From the second parent update onward, the LocationBox address remains identical across passes for all projection types, and child view evaluations are skipped.
  4. Direct @State projections do not exhibit the first-toggle re-render: Creating a binding directly from an @Stateproperty ($model2.counter) maintains a stable LocationBox memory address starting from onAppear, skipping the child view’s body on the very first toggle.
Categories: SwiftUI