Repeated SwiftUI row initialization does not prove that a row disappeared or lost its state. To diagnose a scrolling Mac interface, track construction, retained state, appearance callbacks, and viewport visibility separately.

This walkthrough is for a developer investigating repeated row logs in their own macOS app. The concrete task is to determine whether an update merely creates another view value, resets row state, or changes which rows occupy the scroll viewport. The example uses synthetic labels and ordinary buttons.

What does each lifecycle signal actually tell you?

A SwiftUI row typically uses a structure, which is a Swift value type. Constructing another structure value is different from creating a persistent reference object. Swift’s documentation explains this distinction in Value And Reference Types In Swift.

SwiftUI adds its own identity model. Apple explains that temporary view values can represent the same identity across updates, and that a view’s lifetime follows that identity. Consequently, an initializer log cannot establish that the previous row’s lifetime ended. See Demystify SwiftUI.

Use these signals to answer different questions:

Signal Question it answers Interpretation limit
Row initializer Was another row value constructed? Does not establish appearance or state replacement.
body evaluation Was this view description evaluated? Does not count displayed frames.
Retained row state Did this diagnostic value survive the update? Describes that state’s continuity, not viewport occupancy.
onAppear and onDisappear Did SwiftUI report an appearance transition? Timing depends on the view type.
onScrollVisibilityChange Did the row cross a chosen viewport threshold? Measures the documented scroll visibility condition.

Apple documents that onAppear completes before the first rendered frame, with exact timing depending on the view type. onDisappear runs after disappearance, also with view-dependent timing. Neither method documents state deallocation. Keep those callback events separate from conclusions about storage lifetime. See onAppear(perform:) and onDisappear(perform:).

For a viewport question, choose a threshold explicitly. On macOS 15 and later, onScrollVisibilityChange(threshold:_:) reports whether the view crosses the supplied visibility threshold; the default is 0.5. Apple also documents an initial callback when the view appears with the threshold already reached. See onScrollVisibilityChange(threshold:_:).

In this article, “viewport-visible” means satisfying that scroll threshold. It is not a measurement of whether someone looked at the row.

Build a small experiment that separates the signals

Create a macOS SwiftUI app using an SDK that includes macOS 15 APIs, and set its deployment target to macOS 15 or later. Replace the generated app and content-view declarations with the complete program below. Keep only one @main entry point.

The example uses ScrollView and LazyVStack so viewport behavior is explicit. Apple describes LazyVStack as creating items as needed. This experiment does not assume that List has identical loading or retention behavior. See LazyVStack.

import SwiftUI
import Foundation

@main
struct RowLifetimeLabApp: App {
    var body: some Scene {
        WindowGroup("Row Lifetime Lab") {
            ProbeView()
        }
    }
}

struct ProbeView: View {
    @State private var revision = 0
    @State private var generation = 0

    var body: some View {
        VStack(spacing: 12) {
            HStack {
                Button("Update rows") {
                    revision += 1
                }
                Button("Reset identity") {
                    generation += 1
                }
                Text("Revision \(revision)")
            }

            ScrollView {
                LazyVStack(spacing: 8) {
                    ForEach(1...60, id: \.self) { rowID in
                        ProbeRow(rowID: rowID, revision: revision)
                    }
                }
                .padding()
            }
            .id(generation)
        }
        .padding()
        .frame(minWidth: 560, minHeight: 360)
    }
}

struct ProbeRow: View {
    let rowID: Int
    let revision: Int
    private let constructionID: UUID

    @State private var appearanceToken: UUID? = nil
    @State private var count = 0

    init(rowID: Int, revision: Int) {
        self.rowID = rowID
        self.revision = revision
        self.constructionID = UUID()
        print("init row=\(rowID) value=\(constructionID)")
    }

    var body: some View {
        let _ = print("body row=\(rowID) value=\(constructionID)")

        HStack {
            VStack(alignment: .leading) {
                Text("Sample row \(rowID)")
                Text("Revision \(revision)")
                Text(appearanceToken?.uuidString ?? "Awaiting appearance")
                    .font(.caption.monospaced())
            }
            Spacer()
            Button("Count \(count)") {
                count += 1
            }
        }
        .padding(12)
        .frame(height: 80)
        .background(Color.gray.opacity(0.12))
        .onAppear {
            if appearanceToken == nil {
                appearanceToken = UUID()
            }
            print("appear row=\(rowID) token=\(tokenDescription)")
        }
        .onDisappear {
            print("disappear row=\(rowID) token=\(tokenDescription)")
        }
        .onScrollVisibilityChange(threshold: 0.5) { visible in
            print("viewport row=\(rowID) visible=\(visible)")
        }
    }

    private var tokenDescription: String {
        appearanceToken?.uuidString ?? "unassigned"
    }
}

The two UUIDs serve different purposes. constructionID labels each initializer invocation. appearanceToken starts empty and is assigned during the first observed appearance for the retained row state. It does not timestamp state allocation. The count gives you another visible value whose continuity you can inspect.

Apple documents that SwiftUI manages State storage and updates dependent interface content when its value changes. The diagnostic therefore keeps its token and count in private state, while construction information remains an ordinary stored property. See State.

The program was inspected against the documented APIs, but was not compiled or run here because a Swift toolchain was unavailable. The log examples below describe how to interpret your run; they are not captured results.

Run three controlled checks and interpret the results

1. Update row inputs without changing identity

Run the app in Xcode and keep its debug console open. Click a visible row’s Count button twice, note its token, and then click Update rows. The revision passed to the rows changes while the fixed integer identifiers remain the same.

If initializer messages appear again but the selected row retains its token and count, you have evidence of repeated value construction with retained diagnostic state. Do not classify those initializer messages as separate onscreen lifetimes. The displayed revision confirms that the row received the new input.

The appearance token is assigned through state, which can itself cause another update. That is intentional diagnostic behavior. Avoid expecting an exact number of initializer or body messages, and remove these probes when the investigation is complete.

2. Scroll a row out of the viewport and back

Scroll slowly until the selected row passes the 50% threshold. Compare the viewport messages with the appearance messages, then return to the row. Inspect its token and count again.

A retained token after returning shows continuity of this diagnostic state across that excursion. A different token means the earlier token was not retained. To explain a reset in a real app, investigate identity changes, conditional removal, and the container’s retention behavior rather than inferring the cause from scrolling alone.

Current Apple guidance explains that lazy stacks can prefetch a view’s body and layout before appearance. If scrolling reverses, a prefetched view may never receive onAppear. Apple also explains that offscreen lazy-stack state can eventually be discarded, and recommends keeping data that must survive scrolling in a model or an outer view. See Dive into lazy stacks and scrolling with SwiftUI.

This makes the example’s row-local count useful as a probe. For an actual editing interface, store changes that must survive row removal in a longer-lived owner keyed by the item’s stable identifier.

3. Reset identity deliberately

Click Reset identity. The program changes the identity attached to the entire scroll view, providing a deliberate comparison with the ordinary revision update. Apple explicitly documents that changing the value supplied to id(_:) resets the view’s identity, including its state. See id(_:).

Inspect the newly appearing rows for fresh tokens and initial counts. Because the reset applies to the scroll-view subtree, do not use it as a controlled test of scroll-position preservation.

Use this decision aid when reviewing your own trace:

Observation Supported conclusion Next check
New initializer messages; token and count persist Construction occurred while diagnostic state persisted. Inspect initializer cost if updates are slow.
Appearance callbacks repeat; token persists Repeated appearance did not replace that token. Use viewport callbacks for threshold-dependent behavior.
Token and count reset after an ordinary update Row state did not survive as expected. Inspect data IDs, changing id modifiers, and conditional branches.
A row is initialized but never reports appearance Construction alone did not establish appearance. Consider lazy preparation and subsequent scrolling changes.
Viewport visibility becomes false The chosen scroll threshold is no longer satisfied. Decide whether that threshold matches the intended behavior.

For dynamic data, keep identifiers attached to the underlying items across updates. A newly generated identifier on every read prevents continuity. Also inspect ancestor identities: stable row IDs cannot preserve a row inside a subtree whose identity you deliberately replace. Apple discusses stable data identifiers and structural identity in Demystify SwiftUI.

What is the supported path on earlier macOS versions?

The sample’s scroll visibility modifier requires macOS 15. Lowering the deployment target requires an availability branch with a separate implementation; onAppear is not an equivalent threshold measurement.

If your app already owns an AppKit scrolling interface, NSScrollView.documentVisibleRect supplies the portion of its document view visible through the content view, expressed in document-view coordinates. A geometry-based alternative is to compare that rectangle with your own row rectangles in the same coordinate system, recalculating after scrolling and layout changes. See documentVisibleRect.

For example, an explicitly chosen rule could classify a row as visible when at least half its rectangular area intersects the document-visible rectangle. This is a proposed application rule, not a claim that it reproduces SwiftUI’s implementation. Handle empty rectangles and coordinate conversion explicitly.

SwiftUI provides NSViewRepresentable to integrate an AppKit view that your app creates and manages. Its documented creation, update, coordination, and teardown methods provide a supported bridge. Respect SwiftUI’s control of the represented view’s layout; Apple warns against directly changing that view’s frame or bounds. See NSViewRepresentable.

This fallback concerns an AppKit document view you own. It does not require assumptions about the private backing views of a SwiftUI List.

Does TextKit layout prove that text is onscreen?

TextKit needs the same separation between preparation and viewport geometry. This matters if your own row contains an AppKit text view and the question becomes which text segments intersect the visible area.

TextKit 2’s NSTextViewportLayoutController, available on macOS 12 and later, manages an active layout area that generally includes the visible area plus an overscroll region. Its viewportBounds includes overdraw, and its optional viewportRange describes the current viewport layout. Neither should be relabeled as an exact set of visible characters. See NSTextViewportLayoutController.

For a TextKit 2 implementation, use text-layout geometry rather than allocation events. NSTextLayoutManager can enumerate text segments for a supplied text range. Compare the resulting segment rectangles with the actual clipping rectangle after putting both in the same coordinate system. Account for the text view’s textContainerOrigin; do not assume text-container coordinates equal document-view coordinates. See enumerateTextSegments(in:type:options:using:) and textContainerOrigin.

For an existing TextKit 1 implementation, NSLayoutManager.glyphRange(forBoundingRect:in:) accepts a rectangle in text-container coordinates. Apple states that the returned contiguous range can include glyphs outside that rectangle, so it is a candidate range for further inspection rather than an exact visibility list. See glyphRange(forBoundingRect:in:).

Preserve the text engine while diagnosing it. Apple documents that accessing an NSTextView or text container’s layoutManager property on macOS 12 and later causes fallback to compatibility mode using NSLayoutManager. For a TextKit 2 path, use textLayoutManager and its APIs instead. See NSTextView.

Choose the signal that answers the interface question: initializer logs for construction, retained state for continuity, appearance callbacks for appearance transitions, and viewport geometry for visibility. A stable-ID experiment with one deliberate identity reset makes those differences observable without guessing from the volume of console output.

Sources