iosswiftuiscrollview

Scrolling more than one item in SwiftUI ScrollView


I have the following scrollview that displays a certain number of cards. I want the scrollview to display only 2 cards at a time with slight peeking on the right to indicate more cards. The scrollview should also be scrollable by 2 cards at a time. For example, show card 1 & 2 with card 3 peeking, then on scroll show card 3 & 4 with card 5 peeking and then on scroll show card 5. If the number of cards are odd, the last card should display by itself but with leading alignment in the scrollview. I'd also like each card to be sized such that the 2 card display takes up most of the space such that each card is sized equally and with a spacing of 15 points between the cards and the edges (horizontally & vertically). How can I achieve this?

Here's my code:

struct CardGallery: View {
    @State var cards: [Int] = [1,2,3,4,5]
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                
                let width = geometry.size.width
                let height = geometry.size.height / 1.5
                let cardWidth = width / 2.2
                let cardHeight = height / 1.2
                
                ZStack {
                    ScrollView(.horizontal, showsIndicators: false) {
                        LazyHStack(spacing: 20) {
                            ForEach(cards, id: \.self) { level in
                                CardView(level: level)
                                    .frame(width: cardWidth,height: cardHeight)
                            }
                        }
                        .padding(.horizontal)
                    }
                    .scrollTargetBehavior(.paging)
                    .background {
                        Color.yellow
                    }
                    .clipShape(RoundedRectangle(cornerRadius: 15))
                    .overlay {
                        RoundedRectangle(cornerRadius: 15)
                            .strokeBorder(Color.black, lineWidth: 5)
                    }
                }
                .frame(width: width, height: height)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .background {
            Color.green.ignoresSafeArea(.all)
        }
    }
}

struct CardView: View {
    let level: Int
    
    var body: some View {
        VStack(alignment: .leading) {
            RoundedRectangle(cornerRadius: 15)
                .fill(Color.blue)
        }
        .padding()
    }
}


Solution

  • I would suggest showing the cards in pairs, using an HStack to combine each pair. Then use ViewAlignedScrollTargetBehavior to align the pairs to the left side of the scroll view.

    Ways of iterating over an array in pairs are shown in the post Iterate over collection two at a time in Swift.

    Other changes:

    Here is the updated example to show it working:

    struct CardGallery: View {
        @State var cards: [Int] = [1,2,3,4,5]
        let gapSize: CGFloat = 15
        let peekSize: CGFloat = 40
    
        private var cardsInPairs: [(firstCard: Int, secondCard: Int?)] {
            stride(from: 0, to: cards.endIndex, by: 2).map {
                (cards[$0], $0 < cards.index(before: cards.endIndex) ? cards[$0.advanced(by: 1)] : nil)
            }
        }
    
        var body: some View {
            GeometryReader { geometry in
                let width = geometry.size.width
                let height = geometry.size.height / 1.5
                let cardWidth = (width - (3 * gapSize) - peekSize) / 2
                let cardHeight = height - (2 * gapSize)
                ZStack {
                    ScrollView(.horizontal, showsIndicators: false) {
                        LazyHStack(spacing: gapSize) {
                            ForEach(Array(cardsInPairs.enumerated()), id: \.offset) { offset, cardPair in
                                HStack(spacing: gapSize) {
                                    CardView(level: cardPair.firstCard)
                                        .frame(width: cardWidth, height: cardHeight)
                                    if let secondCard = cardPair.secondCard {
                                        CardView(level: secondCard)
                                            .frame(width: cardWidth, height: cardHeight)
                                    } else {
                                        Color.clear
                                            .frame(width: cardWidth, height: cardHeight)
                                    }
                                }
                            }
                        }
                        .scrollTargetLayout()
                        .padding(.leading, gapSize)
                        .padding(.trailing, gapSize + peekSize)
                    }
                    .scrollTargetBehavior(.viewAligned)
                    .background {
                        Color.yellow
                    }
                    .clipShape(RoundedRectangle(cornerRadius: 15))
                    .overlay {
                        RoundedRectangle(cornerRadius: 15)
                            .strokeBorder(.black, lineWidth: 5)
                    }
                }
                .frame(width: width, height: height)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            .background(.green)
        }
    }
    
    struct CardView: View {
        let level: Int
    
        var body: some View {
            RoundedRectangle(cornerRadius: 15)
                .fill(.blue)
                .overlay {
                    Text("\(level)")
                        .font(.title)
                        .foregroundStyle(.white)
                }
        }
    }
    

    Animation