swiftuiscrollviewscroll-pagingswiftui-zstacklazyhstack

HStack Move Only One Item in Scroll SwiftUI


I am trying to create a horizontal scroll view in SwiftUI where I can move only one item at a time, and I want to maintain the proportions of the peeking view (i.e., the portion of the item that remains visible when scrolling).

I would like to avoid using GeometryReader if possible, and the solution should support iOS 16 and above (so, I cannot use .scrollTargetBehavior(.viewAligned) or .scrollTargetLayout() since these features are only available in iOS 17 and above).

Here's the code I've written, which only works for iOS 17 and above. How can I modify it to make it compatible with iOS 16 and still keep the same layout logic without breaking the view or functionality?

let itemCount = 5 // Number of items

var body: some View {
    let screenWidth = UIScreen.main.bounds.width
    let itemWidth: CGFloat = screenWidth - (16 + 30 + 48)
    let spacingValue: CGFloat = 8 // Define your spacing value
    
    ZStack {
        RoundedRectangle(cornerRadius: 25)
            .fill(Color.black)
            .frame(width: screenWidth - 48, height: 400)
        
        ScrollView(.horizontal, showsIndicators: false) {
            if #available(iOS 17.0, *) {
                LazyHStack(spacing: spacingValue) {
                    ForEach(0..<itemCount, id: \.self) { i in
                        RoundedRectangle(cornerRadius: 25)
                            .fill(Color(hue: Double(i) * 10, saturation: 1, brightness: 1).gradient)
                            .frame(width: itemWidth, height: 200)
                    }
                }
                .padding(.horizontal)
                .scrollTargetLayout()
            } else {
                // Fallback on earlier versions
            }

        }
       
        .scrollTargetBehavior(.viewAligned)
        .safeAreaPadding(.horizontal, itemCount == 1 ? 8 : 0)
        .environment(\.layoutDirection, .rightToLeft)
        .frame(width: screenWidth - 48, height: 200)
        .padding(.top, 100)
    }
    .onAppear {
        // Print the item width and spacing value to the console
        print("Item width: \(itemWidth), screen Width: \(screenWidth), spacing: \(spacingValue)")
    }
}

}

**

**

How can I implement the ability to move only one item at a time (while maintaining the proportions of the peeking view) without using iOS 17-specific methods? What alternative strategies can I use to achieve the desired behavior in iOS 16, such as snapping items or limiting the scrolling speed? Is there any way to maintain the visual consistency and layout (including the safe area and padding) without .scrollTargetBehavior(.viewAligned) and .scrollTargetLayout()? Additional Context:

I also tried using UIScrollView.appearance().isPagingEnabled = true, but this causes the scroll view to behave incorrectly, skipping over items and sometimes snapping to the middle or end of each item instead of moving one item at a time proportionally. How can I fix this behavior while maintaining a smooth and consistent scroll?

Any help or suggestions would be appreciated!


Solution

  • If you know the width of the screen and the width of the items then you can implement your own scrollable viewport using an HStack with a DragGesture.

    Here is a simplified version of your example to show it working. Some notes:

    struct ContentView: View {
        let itemCount = 5 // Number of items
        let spacingValue: CGFloat = 8 // Define your spacing value
        let sideMargin: CGFloat = 24
        @State private var selectedIndex: Int?
        @GestureState private var dragOffset = CGFloat.zero
    
        var body: some View {
            GeometryReader { proxy in
                let screenWidth = proxy.size.width
                let itemWidth: CGFloat = screenWidth - (16 + 30 + 48)
                HStack(spacing: spacingValue) {
                    ForEach(0..<itemCount, id: \.self) { i in
                        RoundedRectangle(cornerRadius: 25)
                            .fill(Color(hue: Double(i) * 0.1, saturation: 1, brightness: 1).gradient)
                            .overlay {
                                Text("\(i)")
                                    .font(.largeTitle)
                                    .foregroundStyle(.white)
                            }
                            .frame(width: itemWidth, height: 200)
                    }
                }
                .padding(.horizontal, ((screenWidth - itemWidth) / 2) - sideMargin)
                .fixedSize()
                .offset(x: -CGFloat(selectedIndex ?? 0) * (itemWidth + spacingValue))
                .offset(x: dragOffset)
                .frame(width: screenWidth - (2 * sideMargin), alignment: .leading)
                .clipped()
                .animation(.easeInOut, value: selectedIndex)
                .animation(.easeInOut(duration: 0.1), value: dragOffset)
                .gesture(
                    DragGesture(minimumDistance: 0)
                        .updating($dragOffset) { val, state, trans in
                            state = val.translation.width
                        }
                        .onEnded { val in
                            let dx = val.translation.width
                            if dx > 0 {
                                selectedIndex = max(0, (selectedIndex ?? 0) - 1)
                            } else if dx < 0 {
                                selectedIndex = min(itemCount - 1, (selectedIndex ?? 0) + 1)
                            }
                        }
                )
                .frame(maxHeight: .infinity)
                .background {
                    RoundedRectangle(cornerRadius: 25)
                        .fill(Color.black)
                        .frame(maxWidth: .infinity)
                }
                .padding(.horizontal, sideMargin)
            }
            .frame(height: 400)
            // .environment(\.layoutDirection, .rightToLeft)
        }
    }
    

    Animation