Search code examples
iosswiftswiftuiscrollviewios17

SwiftUI ScrollView - iOS 17


Following is the animation i want to achieve, when the view appears until the scrollview is scrolled the views are stacked one behind the other, and when they are scrolled it appears side by side.

struct ContentView: View {
    
    private let colors: [Color] = [.red, .green, .blue, .yellow, .purple, .black, .brown, .cyan, .gray, .indigo, .orange, .pink, .mint, .teal]
    
    
    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                LazyHStack(spacing: 10.0) {
                    ForEach(colors, id: \.self) { color in
                        RoundedRectangle(cornerRadius: 25.0)
                            .foregroundStyle(color)
                            .containerRelativeFrame(.horizontal)
                            .scrollTransition { content, phase in
                                content
                                    .opacity(phase.isIdentity ? 1: 0.2)
                                    .scaleEffect(y: phase.isIdentity ? 1.0 : 0.8)
                            }
                    }
                }
                .scrollTargetLayout()
            }
            .contentMargins(60.0, for: .scrollContent)
            .scrollTargetBehavior(.viewAligned)
            .scrollIndicators(.hidden)
            .frame(maxHeight: 400.0)
            Spacer()
        }
    }
}

Expected output:
enter image description here

I am able to get the views side by side, but how to get the views arranged one behind and then smoothly transition to side by side when scrolled.

Here is my code output:
enter image description here

Thanks to Mat, have got until here, need help to bring both the two looks together:

struct ColorViewModel: Identifiable {
    let id: Int
    let color: Color
}

struct ContentView: View {
    
    private let colors: [ColorViewModel] = [.init(id: 0, color: .red), .init(id: 1, color: .yellow), .init(id: 2, color: .green), .init(id: 03, color: .blue), .init(id: 04, color: .purple), .init(id: 05, color: .pink), .init(id: 06, color: .brown), .init(id: 07, color: .indigo), .init(id: 08, color: .black), .init(id: 09, color: .teal), .init(id: 10, color: .gray), .init(id: 011, color: .white), .init(id: 012, color: .cyan)]

    @State private var hasScrolled: Bool = false
    
    var body: some View {
        
        VStack {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 10.0) {
                    ForEach(0 ..< colors.count, id: \.self) { number in
                        RoundedRectangle(cornerRadius: /*@START_MENU_TOKEN@*/25.0/*@END_MENU_TOKEN@*/)
                            .frame(maxHeight: 300.0)
                            .containerRelativeFrame(.horizontal)
                            .foregroundStyle(colors[number].color)
                            .if(!hasScrolled) { view in
                                view
                                    .padding(.trailing, colors[number].id == colors.last?.id ? 0 : -220)
                                    .zIndex(CGFloat(-number))
                            }
//                            .scrollTransition { content, phase in
//                                content
//                                    .opacity(phase.isIdentity ? 1 : 0.2)
//                                    .scaleEffect(y: phase.isIdentity ? 1.0 : 0.8)
//                            }
                    }
                }
                .scrollTargetLayout()
            }
            .contentMargins(60.0, for: .scrollContent)
            .scrollTargetBehavior(.viewAligned)
            Spacer()
        }
    }
}

extension View {
    @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

enter image description here


Solution

  • To achieve that you can play with rotation values and spacings between items. You can do that by doing something like this:

    struct CoverFlowView<Content: View, Item: RandomAccessCollection>: View where Item.Element: Identifiable {
        
        //MARK: - PROPERTIES
        
        /// Customization
        var itemWidth: CGFloat
        var spacing: CGFloat = .zero
        var rotation: CGFloat = .zero
        
        var items: Item
        var content: (Item.Element) -> Content
        
        var body: some View {
            GeometryReader {
                let size = $0.size
                
                ScrollView(.horizontal, showsIndicators: false) {
                    LazyHStack(spacing: 0) {
                        ForEach(items) { item in
                            content(item)
                                .frame(width: itemWidth)
                                .visualEffect { content, geometryProxy in
                                    content
                                        .rotation3DEffect(.degrees(rotation(geometryProxy)),
                                                          axis: (x: 0, y: 1, z: 0),
                                                          anchor: .center)
                                }
                                .padding(.trailing, item.id == items.last?.id ? 0 : spacing)
                        } //: LOOP ITEMS
                    } //: LAZY HSTACK
                    .padding(.horizontal, (size.width - itemWidth) / 2)
                    .scrollTargetLayout()
                } //: SCROLL
                .scrollTargetBehavior(.viewAligned)
                .scrollClipDisabled()
                
            } //: GEOMETRY
            
        }
        
        
        //MARK: - Functions
        func rotation(_ proxy: GeometryProxy) -> Double {
            let scrollViewWidth = proxy.bounds(of: .scrollView(axis: .horizontal))?.width ?? 0
            let midX = proxy.frame(in: .scrollView(axis: .horizontal)).midX
            /// Converting into progress
            let progress = midX / scrollViewWidth
            /// Capping progress 0-1
            let cappedProgress = max(min(progress, 1), 0)
            
            /// Limit rotation between 0-90°
            let cappedRotation = max(min(rotation, 90), 0)
            
            let degree = cappedProgress * (cappedRotation * 2)
            
            return cappedRotation - degree
        }
        
    }
    

    And you use it like this:

    /// State variables
    @State private var spacing: CGFloat = 0
    @State private var rotation: CGFloat = 0
    @State private var mirrorCards = false
    
    CoverFlowView(itemWidth: 250,
                              spacing: spacing,
                              rotation: rotation,
                              items: items) { item in
                    RoundedRectangle(cornerRadius: 20)
                        .fill(item.color.gradient)
                }
                .frame(height: 180)
                .rotation3DEffect(
                    .degrees(mirrorCards ? 180 : 0),
                    axis: (x: 0.0, y: 1.0, z: 0.0)
                )
                ///Gesture
                .gesture(
                    DragGesture()
                        .onChanged({ value in
                            let predictedTranslationX = value.predictedEndTranslation.width
                            print("Predicted end: \(predictedTranslationX)")
                            if predictedTranslationX >= 60 {
                                /// Close Cards
                                withAnimation {
                                    spacing = -250
                                }
                            } else if predictedTranslationX <= 150 {
                                withAnimation {
                                    spacing = -100
                                }
                            }
                        })
                )
                /// Or if you want it to work with tap gesture
                .onTapGesture {
                    if spacing == -250 {
                        withAnimation {
                            spacing = -100
                        }
                    } else if spacing == -100 {
                        withAnimation {
                            spacing = -220
                        }
                    }
                }
    

    The item I'm using in this simple case is just an Identifiable struct with a color property, but you can use anything you like really. If you use a spacing of around -220 and the mirror effect (by using the rotation3DEffect) you can achieve a similar result to the one you are looking for:

    Overlapping Cards

    Let me know how did you find this solution!