Search code examples
swiftuiswiftui-scrollviewscrolltargetbehavior

SwiftUI viewAligned scrollTargetBehavior for ScrollView where scrollTargetLayout subviews are irregular in size


I'm trying to use SwiftUI's viewAligned scrollTargetBehavior for a ScrollView where scrollTargetLayout subviews are irregular in size.

Here's an example, which I've simplified for the purpose of illustration:

VStack {
    ScrollView {
        LazyVStack {
            ForEach(0..<100) { number in
                Text(verbatim: String(number))
                    .font(.largeTitle)
                    .frame(minWidth: 0, maxWidth: .infinity, minHeight: CGFloat.random(in: 100...300))
                    .background(Rectangle().fill(Color.green))
            }
        }
        .scrollTargetLayout()
    }
    .scrollTargetBehavior(.viewAligned(limitBehavior: .always))
    .background(.red)
}
.padding(.all, 16.0)
.background(.blue)

demo

This works when minHeight is a fixed value (e.g. 100), but when sub-views vary in height (e.g. between 100 and 300 as above), the interaction works as expected for the second item (scrolling from the first item to the second item aligns the second item with the top of the scrollview), but not for the rest.

Curious if this is a know limitation of viewAligned, and if so, if it's possible to do this using a custom implementation of .scrollTargetBehavior(_:)

I've searched broadly and haven't been able to find mention of this specific issue of using irregularly sized sub-views for a viewAligned ScrollView anywhere. Thoughts, help, or a nudge in the right direction would be appreciated!


Solution

  • I was able to reproduce the problem by running your example on an iPhone 15 simulator with iOS 17.5.

    If the standard ViewAlignedScrollTargetBehavior does not work properly when the container uses lazy loading then you can try implementing your own custom ScrollTargetBehavior.

    About ScrollTargetBehavior

    A custom ScrollTargetBehavior must implement the function updateTarget(_:context:), which lets you adjust the position to scroll to. This function is called just once when a scroll gesture ends. It receives two parameters:

    • target: inout ScrollTarget The target contains the position to scroll to, which is mutable. This equates to an absolute position within the overall scrollable content. If the scroll is performed with inertia then the target position is essentially a prediction of where the content is expected to be when scrolling ends, based on the velocity of the scroll gesture.

    • context: ScrollTargetBehaviorContext The context provides information about the scroll region. This includes the contentSize, which is the overall size of the scrolled content. It also includes the velocity of the scroll gesture.

    Complications

    I had a go at trying to implement a custom behavior to improve on ViewAlignedScrollTargetBehavior. Here are a few things I discovered, which I didn't see mentioned in the documentation:

    • At the moment the function is called, the content will already have moved from its original position. What would be quite useful as input would be the scroll position at this moment, but this is not available.

    • If the container is a lazy container then the contentSize changes between calls, presumably depending on which views have been loaded or unloaded by the container. This means that determining the position for scrolling is literally a case of trying to hit a moving target.

    • If you try to change the target position, it is important to respect the direction of movement, as indicated by the velocity. If you try to change direction then it jumps to the target position, instead of performing an animated scroll.

    Additional techniques

    In order to work out the adjustment needed to the target position, we really need two other inputs:

    • The offset by which the container has been scrolled at the moment the function is called, relative to the top of the visible region. A GeometryReader in the background of the LazyVStack can be used to report this.

    • The offset of the top-most view, relative to the top of the visible region. Here too, a GeometryReader in the background of each view can be used to detect when a view is the one at the top and to report its relative offset.

    If the scroll gesture is performed by a move followed by finger lift, as opposed to a finger flick, then the velocity will be 0. In this case, you probably want to scroll to the view that is nearest to the top of the scroll region. A convenient way to identify this view is to apply a .scrollPosition to the ScrollView.

    Working implementation

    So here is a custom ScrollTargetBehavior that attempts to overcome the problems of sticky positioning for a lazy-loaded container. It is specific to the following kind of use:

    • The scroll direction is vertical only. To make it more generic, you would need to examine the axes held in the context. For horizontal scrolling, you would obviously need to work with the view widths and adjust the x-position of the target.

    • When the scroll gesture is released, it aims to stop at the nearest view. This is similar to how LimitBehavior.always works, which is what you were using in your example.

    It seems to work pretty well most of the time, although it is sometimes a bit glitchy if you scroll backwards quickly. In any case, I would say it is an improvement over ViewAlignedScrollTargetBehavior:

    struct ScrollInfo {
        var containerScrollOffset = CGFloat.zero
        var topViewOffset = CGFloat.zero
        var topViewHeight = CGFloat.zero
        var closestViewOffset = CGFloat.zero
    
        var yTargetClosest: CGFloat {
            closestViewOffset - containerScrollOffset
        }
        var yTargetPrevious: CGFloat {
            topViewOffset - containerScrollOffset
        }
        var yTargetNext: CGFloat {
            topViewOffset - containerScrollOffset + topViewHeight
        }
    }
    
    struct StickyScrollTargetBehavior: ScrollTargetBehavior {
        let scrollInfo: ScrollInfo
    
        func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
            let dy = context.velocity.dy
            if dy == 0 {
                target.rect.origin.y = scrollInfo.yTargetClosest
            } else if dy < 0 {
                target.rect.origin.y = scrollInfo.yTargetPrevious
            } else {
                target.rect.origin.y = scrollInfo.yTargetNext
            }
        }
    }
    
    struct ContentView: View {
        private let randomHeights: [CGFloat]
        private let spacing: CGFloat = 10
        @State private var scrollPosition: Int?
        @State private var scrollInfo = ScrollInfo()
    
        init() {
            var randomHeights = [CGFloat]()
            for _ in 0..<100 {
                randomHeights.append(CGFloat.random(in: 100...300))
            }
            self.randomHeights = randomHeights
        }
    
        var body: some View {
            VStack(spacing: spacing) {
                ScrollView {
                    LazyVStack {
                        ForEach(Array(randomHeights.enumerated()), id: \.offset) { index, height in
                            Text(verbatim: String(index))
                                .font(.largeTitle)
                                .frame(height: height)
                                .frame(maxWidth: .infinity)
                                .background(.green)
                                .background { rowScrollRecorder(index: index) }
                                .id(index)
                        }
                    }
                    .scrollTargetLayout()
                    .background { containerScrollRecorder }
                }
                .scrollPosition(id: $scrollPosition, anchor: .top)
                .scrollTargetBehavior(
                    StickyScrollTargetBehavior(scrollInfo: scrollInfo)
                )
                .background(.red)
            }
            .padding(.all, 16.0)
            .background(.blue)
        }
    
        private func rowScrollRecorder(index: Int) -> some View {
            GeometryReader { proxy in
                let height = proxy.size.height
                let minY = proxy.frame(in: .scrollView).minY
                Color.clear
                    .onChange(of: minY) { oldVal, newVal in
                        if newVal <= spacing && newVal + height > 0 {
                            scrollInfo.topViewOffset = newVal
                            scrollInfo.topViewHeight = height + spacing
                        }
                        if index == scrollPosition {
                            scrollInfo.closestViewOffset = newVal
                        }
                    }
            }
        }
    
        private var containerScrollRecorder: some View {
            GeometryReader { proxy in
                let minY = proxy.frame(in: .scrollView).minY
                Color.clear
                    .onChange(of: minY) { oldVal, newVal in
                        scrollInfo.containerScrollOffset = newVal
                    }
            }
        }
    }
    

    Animation