swiftuiscrollviewscrolltargetbehavior

SwiftUI ScrollView - Animation of snapping when scrollTargetBehavior is `.paging` or `.viewAligned(limitBehavior: .alwaysByOne)`


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.


Solution

  • 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:


    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.

    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.


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

    Animation


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