How to snap to every second content item in a SwiftUI ScrollView?

255 Views Asked by At

Background

At WWDC 2023, Apple introduced a new API for ScrollViews that lets us neatly snap views into position with little effort. The following code example snaps the Rectangles into position when the user lifts the finger after a scroll (drag) gesture.

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(1..<100) { number in
                    Rectangle()
                        .fill(.mint)
                        .frame(height: 200)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
    }
}

Question

Now what if I want to only snap to every second Rectangle and scroll over the others? Or more generally speaking, how can I control exactly which items (child views) the ScrollView should snap to and which one it should ignore for snapping?

It seems like a very trivial thing, but I couldn't figure out how to accomplish this.

A Hint?

The documentation for the scrollTargetLayout(isEnabled:) modifier states:

Scroll target layouts act as a convenience for applying a View/scrollTarget(isEnabled:) modifier to each views in the layout.

This tells me that there is another modifier called scrollTarget(isEnabled:) that I can attach to individual items to granularly enable or disable snapping. However, this modifier doesn't seem to exist. ‍♂️

Is the documentation simply wrong or did I misunderstand it? How can I make this work (without falling back to UIKit)?

1

There are 1 best solutions below

5
MatBuompy On

It turns out it's possible to that with the new iOS 17 APIs for ScrollViews. For that you can use the new .scrollPosition(id: Hashable) modifier on a scroll view. I have been a bit of fun with it. Here's what I have accomplished so far: Scrolling by two using ActiveID

Here's the code I've used for it. I'm using an itemCount variable to generate numbers from 1 to 100. As for the non-programatic part I've used the onChange modifier and handled the case there. I've had to use a flag that I've called "onChangedExecuted" because otherwise the event would fire more times then we need.

    struct ScrollTransition: View {
    
    // MARK: - UI
    @State private var activeID: Int?
    @State private var selectedStep: Int = 1
    @State private var onChangedExecuted = false
    private var steps: [Int] = [1, 2, 3, 4, 5]
    private var itemCount: Int = 100
    
    var body: some View {
        VStack {
            
            Button("Move of: \(selectedStep)") {
                withAnimation {
                    if let activeID, (activeID + selectedStep) < itemCount {
                        self.activeID! += selectedStep
                    } else {
                        self.activeID = 1
                    }
                }
            }
            
            Picker("Steps", selection: $selectedStep) {
                ForEach(steps, id: \.self) { step in
                    Text("\(step)")
                }
            }
            .pickerStyle(.wheel)
            
            Text("Active ID: \(self.activeID ?? 0)")
                .padding(.top, 20)
            
            Spacer(minLength: 0)
            
            /// Circular Slider
            ScrollView(.horizontal) {
                HStack(spacing: 35) {
                    ForEach(1...itemCount, id: \.self) { index in
                        Text("\(index)")
                            .font(.title)
                            .fontWeight(.semibold)
                            .foregroundStyle(index == activeID ? .orange : .primary)
                            .containerRelativeFrame(.horizontal) // <-- I've used this like to center the number (but only one number can appear now)
                    } //: LOOP IMAGES
                } //: HSTACK
                .scrollTargetLayout()
            } //: SCROLL
            .scrollIndicators(.hidden)
            // Snapping
            .scrollTargetBehavior(.viewAligned)
            .scrollPosition(id: $activeID)
            .onAppear {
                // Place carousel at the middle item
                activeID = itemCount/2
            }
            .frame(height: 200)
            .onChange(of: activeID) { oldValue, newValue in
                guard let oldValue, let newValue,
                        !onChangedExecuted else { return }
                if newValue > oldValue {
                    if ((oldValue + selectedStep) != newValue) {
                        activeID! += (selectedStep - 1)
                        onChangedExecuted = true
                    }
                } else {
                    if ((oldValue - selectedStep) != newValue) {
                        activeID! -= (selectedStep - 1)
                        onChangedExecuted = true
                    }
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    onChangedExecuted = false
                }
            }
                
            } //: VSTACK
            .ignoresSafeArea(.container, edges: .bottom)
            
        }
        
    }

Modifying the activeID integer number the scroll view will automatically navigate to the element at that index. In this version of the code I've used the new .containerRelativeFrame(.horizontal) to center the activeID text in the middle, not sure you wanted it that way but it seemed the easiest solution to me. Now you can choose with a picker how many items should be "skipped". You can apply the same principle as per your needs. Hope I was able to help you, and let me know!