ScrollViewProxy scrollTo in onAppear may cause state changes to be lost

Scrolling to a SwiftUI view with the onAppear method can cause state changes of published values from an ObservableObject to be lost and a view to be displayed in an incorrect state.

Here is a demo showing the problem:

struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()
    @Namespace private var state2ID

    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView(.vertical) {
                VStack(spacing: 15) {
                    if viewModel.state2 {
                        VStack {
                            Text("State2 is set")
                        }
                        .id(state2ID)
                        .onAppear {
                            print("scrolling")
                            withAnimation {
                                scrollProxy.scrollTo(state2ID)
                            }
                        }
                    }

                    VStack(spacing: 0) {
                        Text("State1: \(viewModel.state1)")
                        Text("State1 changes from 'false -> true -> false' when the button is pressed.")
                            .font(.footnote)
                    }

                    Button("Toggle States") {
                        viewModel.toggleStates()
                    }
                    .buttonStyle(.bordered)

                    Color.teal
                        .frame(height: 900)
                }
                .padding()
            }
        }
    }
}

@MainActor
final class ContentViewModel: ObservableObject {
    @Published private(set) var state1 = false
    @Published private(set) var state2 = false

    private var stateToggle = false

    func toggleStates() {
        Task { @MainActor in
            state1 = true
            defer {
                // This change never becomes visible in the view!
                // state1 will be improperly shown as 'true' when this method returns while it actually is 'false'.
                print("Resetting state1")
                state1 = false
            }

            stateToggle.toggle()

            if stateToggle {
                withAnimation {
                    state2 = true
                }
            } else {
                state2 = false
            }
        }
    }
}

After pressing the button, “State1: true” is displayed in the view, which no longer corresponds to the actual value of the view model property, which is false.

Effectively, the change in the defer call of the toggleStates method is lost and the view is no longer in sync with the actual state values of the view model.

Tested with iOS 17.5 and 18.0 (devices + simulators)

Hi @wiedem

    .onAppear {
        print("scrolling")
        withAnimation {
            scrollProxy.scrollTo(state2ID)
        }
    }

The onAppear(perform:) modifier doesn’t necessarily equate view visibility. It’s triggered when the view is added to the view hierarchy and isn’t tied to its visibility. The onAppear(perform:) view modifier is intended to enable you to perform actions before the view is inserted into the view hierarch and the The system makes no guarantees on the specific timeframes on when either onAppear(perform:) or onDisappear(perform:) modifiers are called.

The correct approach is to use the .task modifier, which is asynchronous, or the onChange modifier so that SwiftUI enqueues a new update once the value has propagated through. For instance, you can use it like this:

    .onChange(of: viewModel.state2) {
        if viewModel.state2 {
            print("scrolling")
            withAnimation {
                scrollProxy.scrollTo(state2ID)
            }
        }
    }
ScrollViewProxy scrollTo in onAppear may cause state changes to be lost
 
 
Q