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?
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)