swiftswiftuiscrollview

Paged ScrollView miss some items due to scroll process


I'm trying to build custom WheelPicker. All almost done, but....

It's can be scrolled to each element except 2 and 3 for some reason. In the same time I can select any other element. Problem is only with 2 and 3.

Reproduced on iOS and macOS both

WheelPicker sample

Test view:

struct TestWheel: View {
    @State var t: Int! = 1
    var tList = [1,2,3,4,5,6,7,8,9,10,11,12]
    
    var body: some View {
        #if os(iOS)
        if #available(iOS 17.0, *) {
            UksWheelPicker(selected: $t, items: tList, config: UksWheelConfig(itemHeight: 32)) {
                Text("Text - \($0)")
                    .frame(width: 100)
            }
        }
        #endif
        
        #if os(macOS)
        if #available(macOS 14.0, *) {
            UksWheelPicker(selected: $t, items: tList, config: UksWheelConfig(itemHeight: 32)) {
                Text("Text - \($0)")
                    .frame(width: 100)
            }
        }
        #endif
    }
}

Custom WheelPicker:


import SwiftUI
import Essentials

@available(macOS 14.0, *)
@available(iOS 17.0, *)
public struct UksWheelPicker<T, ItemView>: View where T: CustomStringConvertible, T: Hashable, ItemView: View {
    @Binding var selected: T?
    var items: [T]
    
    let config: UksWheelConfig
    
    var itemView: (T) -> ItemView
    
    public init(selected: Binding<T?>, items: [T], config: UksWheelConfig, itemView: @escaping (T) -> ItemView) {
        _selected = selected
        self.items = items
        self.config = config
        self.itemView = itemView
    }
    
    public var body: some View {
        ScrollViewReader { reader in
            ScrollView(.vertical) {
                LazyVStack(spacing: 0) {
                    SpacingElem()
                        .id("_______1__")
                    
                    ForEach(items, id: \.self) { item in
                        itemView(item)
                            .frame(height: config.itemHeight)
                            .id(item)
                            .onTapGesture {
                                withAnimation {
                                    selected = item
                                }
                            }
                    }
                    
                    SpacingElem()
                        .id("_______2__")
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.viewAligned)
            .scrollIndicators(.hidden)
            .scrollPosition(id: $selected, anchor: .center)
            .frame(height: config.itemHeight * 7)
            .mask(LinearGradient(gradient: Gradient(colors: [.clear, .clear, .black, .black, .clear, .clear]), startPoint: .top, endPoint: .bottom))
            .onAppear {
                scrollToNeededElem(reader: reader)
            }
        }
    }
}

//////////////////////
///HELPER VIEWs
/////////////////////

@available(macOS 14.0, *)
@available(iOS 17.0, *)
extension UksWheelPicker {
    @ViewBuilder
    private func SpacingElem() -> some View {
        Spacer()
            .frame(width: 100, height: config.itemHeight * 3)
    }
}

//////////////////////
///HELPERS
/////////////////////

@available(macOS 14.0, *)
@available(iOS 17.0, *)
extension UksWheelPicker {
    func scrollToNeededElem(reader: ScrollViewProxy) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            withAnimation {
                if let selected {
                    reader.scrollTo(selected, anchor: .center)
                }
            }
        }
    }
}

public struct UksWheelConfig {
    public let itemHeight: CGFloat
    public let selectionEnabled: Bool
    public let selectionColor: Color?
    public let selectionIsOverlay: Bool
    
    public init(itemHeight: CGFloat, selectionEnabled: Bool = true, selectionColor: Color? = nil, selectionIsOverlay: Bool = false) {
        self.itemHeight = itemHeight
        self.selectionEnabled = selectionEnabled
        self.selectionColor = selectionColor
        self.selectionIsOverlay = selectionIsOverlay
    }
}


Solution

  • The reason for the problem is that ViewAlignedScrollTargetBehavior aligns to the top edge of the content. Since you are using a (large) spacing element as the first item, this is stopping it from working properly for items 2 and 3 near the top of the list.

    The way to fix is to remove the spacers that are currently inside the ScrollView and set .contentMargins on the ScrollView instead.

    Here is the updated view with the changes applied. I also added a horizontal bar in the background, for visual confirmation that it is working correctly:

    ScrollViewReader { reader in
        ScrollView(.vertical) {
            LazyVStack(spacing: 0) { // 👈 spacers removed
                ForEach(items, id: \.self) { item in
                    itemView(item)
                        .frame(height: config.itemHeight)
                        .id(item)
                        .onTapGesture {
                            withAnimation {
                                selected = item
                            }
                        }
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.vertical, config.itemHeight * 3) // 👈 added
        .frame(height: config.itemHeight * 7)
        .scrollTargetBehavior(.viewAligned)
        .scrollIndicators(.hidden)
        .scrollPosition(id: $selected, anchor: .center)
        .mask(LinearGradient(gradient: Gradient(colors: [.clear, .clear, .black, .black, .clear, .clear]), startPoint: .top, endPoint: .bottom))
        .onAppear {
            scrollToNeededElem(reader: reader)
        }
    }
    .background { // 👈 for testing purposes
        Color.gray
            .opacity(0.2)
            .frame(height: config.itemHeight)
    }
    

    Animation