swiftuiswiftui-scrollview

State transition in SwiftUI by scrolling in onAppear can be lost


If you use the onAppear method to scroll to a SwiftUI view with the help of a ScrollViewProxy, this can lead to the loss of the state transition in the view when using published values of an ObservableObject.

The problem occurs in all current iOS SDKs (17.5 / 18.0), on the iOS simulators and on real devices.

Here is a code reduced to the minimum to reproduce the problem.

SwiftUI view:

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 {
                            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()
            }
        }
    }
}

View model:

@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
            }
        }
    }
}

When you press the "Toggle States" button, the text for state1 shows “State1: true” when the task completes, but it should show “State1: false” because state1 is set to false in the task of toggleStates. At this point, the actual values of the view model and the values displayed in the view are no longer in sync.

If you remove the scrollProxy.scrollTo(state2ID) call in the onAppear method, the problem does not occur. It therefore appears to be triggered by the scrolling process.

This seems very unexpected and a bug, or am I missing something fundamental? Any idea why this is the case and how to use the scrollTo method of a ScrollViewProxy object so that this error does not occur?


Solution

  • This problem may be a timing issue related to the view model performing changes withAnimation.

    I would suggest, it is better if a view model is not concerned with presentation details such as animation.

    In any case, you can get it working by taking the animation controls out of the view model and putting them in the view instead:

    1. In ContentViewModel, comment out the withAnimation

    // ContentViewModel
    
    if stateToggle {
        // withAnimation { // 👈 HERE
            state2 = true
        // }
    } else {
        state2 = false
    }
    

    Of course, you could simplify the code while you're at it:

    stateToggle.toggle()
    state2 = stateToggle
    

    ...or you could scrap stateToggle and just toggle state2 instead. This might help to make the view model a bit less convoluted.

    2. In ContentView, add an .animation modifier to the top-level VStack inside the ScrollView

    // ContentView
    
    ScrollView(.vertical) {
        VStack(spacing: 15) {
            // ...
        }
        .padding()
        .animation(.default, value: viewModel.state2) // 👈 HERE
    }
    

    This gives a slide animation when revealing as well as when hiding. If you want to suppress the animation for hide (like you had it before) then you could use a ternary operator in the .animation modifier:

    .animation(viewModel.state2 ? .default : nil, value: viewModel.state2)