Search code examples
animationswiftuiuiscrollviewonchangescroll-position

SwiftUI / Animation based on scrollPosition in ScrollView


I am working on a IOS SwiftUI project where I’m trying to replicate this kind of animate on scroll behaviour (not sure if it has a name):

https://www.lemonde.fr/les-decodeurs/visuel/2023/06/07/comprendre-le-rechauffement-comment-nous-avons-bouleverse-le-climat_6176490_4355770.html

What I want : As the user scroll, multiple views appears, disappears (with fades, animations), multiple text views are scrolling on top, etc.. The exemple has been done in CSS, I want to do it in SwiftUI.

My first attempt is this :

  • Scrollview

  • Vstack for scrolling content (text)

  • ScrollId to observe user position

  • .Id set manually on the different content

  • ZStack for the fixed content .allowsHitTesting(false)

  • If condition on ScrollId to make fixed content appear and disappear

(SEE CODE BELOW)

It works, but it becomes messy very fast (manual ID attribution, fixed elements being at the end of the code etc...). Is that really the best way ?

Then, I want those blue charts to be animated on scroll (like in the exemple). Doing that solely by observing the individual ScrollID changes with if conditions seems extremely heavy.

  • Animating the blue chart : Nothing triggers the appearance of the blue chart except the ScrollID change. How can I make each of those rectangle appear gradually in scale for ex? Or animate their opacity etc ? Do I need to create a variable for each of them?

  • Cant use onappear to animate anything : I read somewhere that in a scrollview, views are loading before appearing on a screen, therefore, not really working. Indeed, it would have been handy but doesn't work as expected.

Some screenshots :

The scroll content

The scroll content

The fixed content that appears when ScrollID reaches 7

The fixed content that appears when ScrollID reaches 7

Thanks a lot for the help


import SwiftUI

struct ContentView: View {
    @State private var scrollID: Int?
    
    var body: some View {
        ZStack {
            Color.black
            
            ScrollView (.vertical) {
                VStack {
                    //   SCROLL CONTENT HERE
                    Spacer()
                        .frame(height: 200)
                        .id(1)
                    
                    Text("Curabitur in facilisis ex. Fusce eget molestie ante, eget varius arcu. ")
                        .font(.caption)
                        .frame(width: 200)
                        .padding(20)
                        .background(Color.white)
                        .id(2)
                    
                    Spacer()
                        .frame(height: 200)
                        .id(3)
                    
                    Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque dictum ligula non arcu iaculis bibendum. Curabitur in facilisis ex. Fusce eget molestie ante, eget varius arcu. ")
                        .font(.caption)
                        .frame(width: 200)
                        .padding(20)
                        .background(Color.white)
                        .id(4)
                    
                    Spacer()
                        .frame(height: 200)
                        .id(5)
                    
                    Rectangle()
                        .fill(Color.gray)
                        .frame(width:300, height: 300)
                        .id(6)
                    
                    // Break in the content to display the fixed element (blue rectangle)
                    
                    Spacer()
                        .frame(height: 1200)
                        .id(7)
                    
                    // end of the break
                    
                    Text("Pellentesque dictum ligula non arcu iaculis bibendum. Curabitur in facilisis ex. Fusce eget molestie ante, eget varius arcu. ")
                        .font(.caption)
                        .frame(width: 200)
                        .padding(20)
                        .background(Color.white)
                        .id(8)
                    
                    
                    Spacer()
                        .frame(height: 200)
                        .id(9)
                    
                    Rectangle()
                        .fill(Color.gray)
                        .frame(width:300, height: 300)
                        .id(10)
                    
                }
                .ignoresSafeArea()
                .scrollTargetLayout()
                
            }
            .scrollPosition(id: $scrollID)
            .onChange(of: scrollID) {
                oldValue, newValue in
                print(newValue ?? "")}
            
            // FIXED CONTENT HERE (blue chart)
            Group {
                if scrollID == 7 {
                    HStack{
                        ForEach(0..<10) { index in
                            Rectangle()
                                .fill(Color.blue)
                                .frame(width: 20, height: 200)
                        }
                    }
                }
            }
//            To prevent scroll to be stuck
            .allowsHitTesting(false)
            
        }
        .ignoresSafeArea()
        
    }
}

#Preview {
    ContentView()
}


Solution

  • If you want the updates to be more granular then I would suggest basing it on the scrolled offset. This can be found by using a GeometryReader in the background of the scrolled content.

    Another GeometryReader at the outer level allows the screen height to be measured. Using this, the scrolled fraction can be computed.

    You can keep the ids and scrollID for the purpose of progammatic scrolling, if you want to, but they're no longer needed for the graphical effect.

    @State private var scrollID: Int?
    @State private var scrolledFraction = CGFloat.zero
    private let nProgressBars = 10
    
    private func scrollDetector(screenHeight: CGFloat) -> some View {
        GeometryReader { proxy in
            let minY = proxy.frame(in: .scrollView).minY
            Color.clear
                .onChange(of: minY) { oldVal, newVal in
                    scrolledFraction = -minY / (proxy.size.height - screenHeight)
                }
        }
    }
    
    private func opacityForBar(n: Int) -> CGFloat {
        let result: CGFloat
        let progress = scrolledFraction * CGFloat(nProgressBars)
        let nFullBars = Int(progress)
        if n < nFullBars {
            result = 1
        } else if n == nFullBars {
            result = progress - CGFloat(nFullBars)
        } else {
            result = 0
        }
        return result
    }
    
    var body: some View {
        GeometryReader { outer in
            ZStack {
                Color.black
    
                ScrollView(.vertical) {
                    VStack {
                        // SCROLL CONTENT HERE (as before)
                    }
                    .background(scrollDetector(screenHeight: outer.size.height))
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $scrollID)
    
                // FIXED CONTENT HERE (blue chart)
                HStack{
                    ForEach(0..<nProgressBars, id: \.self) { index in
                        Rectangle()
                            .fill(Color.blue)
                            .frame(width: 20, height: 200)
                            .opacity(opacityForBar(n: index))
                    }
                }
                // To prevent scroll to be stuck
                .allowsHitTesting(false)
            }
        }
        .ignoresSafeArea()
    }
    

    Animation


    EDIT Following from your comment, what you can do is replace the Spacer with the scroll detector at the point where you want the graphic to show. Then change the logic for computing the scroll position and the opacity. The outer GeometryReader needs to be put around the ScrollView instead of the ZStack, so that it is delivering the height of the scroll region rather than the height of the screen (although the size might actually be the same).

    I had a go:

    @State private var scrollID: Int?
    @State private var scrolledFraction = CGFloat.zero
    private let nProgressBars = 10
    private let fullBarHeight: CGFloat = 200
    
    private func computeScrolledFraction(
        spacerFrame: CGRect,
        spacerHeight: CGFloat,
        scrollRegionHeight: CGFloat
    ) -> CGFloat {
        let result: CGFloat
        let yBegin = scrollRegionHeight / 2
        let yEnd = (scrollRegionHeight + fullBarHeight) / 2
        if spacerFrame.minY < yBegin {
            if spacerFrame.maxY > yEnd {
    
                // graphic region has been reached, compute the fraction to show
                result = (yBegin - spacerFrame.minY) / (spacerHeight - (yEnd - yBegin))
            } else {
    
                // reached the end, start to fade out (use a fraction > 1)
                let dy = yEnd - spacerFrame.maxY
                result = dy < fullBarHeight
                    ? 1 + ((fullBarHeight - dy) / fullBarHeight)
                    : 0
            }
        } else {
    
            // graphic region not reached yet
            result = 0
        }
        return result
    }
    
    private func scrollDetector(scrollRegionHeight: CGFloat) -> some View {
        GeometryReader { proxy in
            let frame = proxy.frame(in: .scrollView)
            let fraction = computeScrolledFraction(
                spacerFrame: frame,
                spacerHeight: proxy.size.height,
                scrollRegionHeight: scrollRegionHeight
            )
            Color.clear
                .onChange(of: fraction) { oldVal, newVal in
                    scrolledFraction = newVal
                }
        }
    }
    
    private func opacityForBar(n: Int) -> CGFloat {
        let result: CGFloat
        if scrolledFraction > 1 {
            result = max(0, min(1, scrolledFraction - 1))
        } else {
            let progress = scrolledFraction * CGFloat(nProgressBars)
            let nFullBars = Int(progress)
            if n < nFullBars {
                result = 1
            } else if n == nFullBars {
                result = progress - CGFloat(nFullBars)
            } else {
                result = 0
            }
        }
        return result
    }
    
    var body: some View {
        ZStack {
            Color.black
    
            GeometryReader { outer in
                ScrollView(.vertical) {
                    VStack {
                        // SCROLL CONTENT HERE
                        // As before, except for the break:
    
                        // Break in the content to display the fixed element (blue rectangle)
    
                        scrollDetector(scrollRegionHeight: outer.size.height)
                            .frame(height: 1200)
                            .id(7)
    
                        // end of the break
    
                    }
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $scrollID)
            }
            // FIXED CONTENT HERE (blue chart)
            HStack{
                ForEach(0..<nProgressBars, id: \.self) { index in
                    Rectangle()
                        .fill(Color.blue)
                        .frame(width: 20, height: fullBarHeight)
                        .opacity(opacityForBar(n: index))
                }
            }
            // To prevent scroll to be stuck
            .allowsHitTesting(false)
        }
        .ignoresSafeArea()
    }
    

    Animation

    If you want the graphic to start appearing a bit earlier or later, or start fading out either earlier or later, you just need to change the way that yBegin and yEnd and being determined in the function computeScrolledFraction.