I have a basic paged ScrollView using scrollTargetBehavior
as .viewAligned(limitBehavior: .alwaysByOne)
or .paging
. It is a full screen vertical pager i.e.
GeometryReader { geo in
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(items, id: \.self) { item in
<make an item view here>
}
.frame(
width: geo.size.width,
height: geo.size.height
)
}
.scrollTargetLayout()
}
.scrollPosition(id: $selectedItem, anchor: .top)
.animation(.spring, value: selectedItem)
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
}
.animation
only appears to affect programmatic scrolling (i.e. if I remove it then there is no animation if I change selectedItem
).
When a user manually scrolls the list, and then releases, there is an animation as the correct page snaps to screen. It's pretty slow at the end (feels like an ease out). I want to make it a bit snappier but my assumed approach (.animation(..)
) doesn't appear to do anything.
Afaik, there is no modifier that allows you to change the default animation of a scroll view for when a scroll gesture has been released.
However, if your target is iOS 18 then there are some ways to override the animation:
Use onScrollVisibilityChange(threshold:_:)
to detect when a view is being scrolled into view. Update the selected item when this happens.
Use onScrollPhaseChange(_:)
to detect when the scroll gesture is released, this being when the .interacting
phase changes to some other phase. When this happens, perform a programmatic scroll to the currently selected item. This can use the animation of your choice.
More notes:
It is important that the programmatic scroll is not performed until the scroll gesture has been released, otherwise it can lead to view flipping.
.onScrollVisibilityChange
and if a programmatic scroll is performed immediately in response to the change of visibility, the views switch back and forth between the old and new selection.Another issue is the use of .scrollPosition
. This not only provides a way of performing programmatic scroll, it also tracks the view that is currently selected.
ScrollViewProxy
for programmatic scrolling, instead of .scrollPosition
. The selected item will still be tracked by virtue of the onScrollVisibilityChange
callback, described above.Here is how it all comes together:
// For example purposes
private let items: [Color] = [.red, .orange, .yellow, .green, .blue, .indigo, .purple]
@State private var selectedItem: Color?
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
ForEach(items, id: \.self) { item in
// <make an item view here>
item
.onScrollVisibilityChange(threshold: 0.2) { isVisible in
if isVisible {
selectedItem = item
} else if selectedItem == item {
selectedItem = nil
}
}
}
.frame(
width: geo.size.width,
height: geo.size.height
)
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
.onAppear { selectedItem = items.first }
.onScrollPhaseChange { oldPhase, newPhase in
if oldPhase == .interacting, let selectedItem {
withAnimation(.spring(duration: 0.2)) {
proxy.scrollTo(selectedItem)
}
}
}
}
}
As explained, this solution requires iOS 18. If you still need to support earlier iOS versions, you could use the default animation for earlier versions but snappy animation for iOS 18. Something like:
GeometryReader { geo in
if #available(iOS 18.0, *) {
snappyScrollLayout(pageSize: geo.size)
} else {
regularScrollLayout(pageSize: geo.size)
}
}