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:

    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:

    Additional techniques

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

    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:

    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