3

I have horizontal progress bar within ScrollView and I need to change that progress bar value, when user is scrolling.

Is there any way to bind some value to current scroll position?

eugene_prg
  • 948
  • 2
  • 12
  • 25
  • 2
    Does this answer your question? [SwiftUI | Get current scroll position from ScrollView](https://stackoverflow.com/questions/62588015/swiftui-get-current-scroll-position-from-scrollview) – lorem ipsum Feb 18 '21 at 23:05

2 Answers2

3

You can do this with a few GeometryReaders.

My method:

  1. Get total height of ScrollView container
  2. Get inner height of content
  3. Find the difference for the total scrollable height
  4. Get the distance between the scroll view container top and the content top
  5. Divide the distance between tops by the total scrollable height
  6. Use PreferenceKeys to set the proportion @State value

Code:

struct ContentView: View {
    @State private var scrollViewHeight: CGFloat = 0
    @State private var proportion: CGFloat = 0

    var body: some View {
        VStack {
            ScrollView {
                VStack {
                    ForEach(0 ..< 100) { index in
                        Text("Item: \(index + 1)")
                    }
                }
                .frame(maxWidth: .infinity)
                .background(
                    GeometryReader { geo in
                        let scrollLength = geo.size.height - scrollViewHeight
                        let rawProportion = -geo.frame(in: .named("scroll")).minY / scrollLength
                        let proportion = min(max(rawProportion, 0), 1)

                        Color.clear
                            .preference(
                                key: ScrollProportion.self,
                                value: proportion
                            )
                            .onPreferenceChange(ScrollProportion.self) { proportion in
                                self.proportion = proportion
                            }
                    }
                )
            }
            .background(
                GeometryReader { geo in
                    Color.clear.onAppear {
                        scrollViewHeight = geo.size.height
                    }
                }
            )
            .coordinateSpace(name: "scroll")

            ProgressView(value: proportion, total: 1)
                .padding(.horizontal)
        }
    }
}
struct ScrollProportion: PreferenceKey {
    static let defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

Result:

Result

George
  • 25,988
  • 10
  • 79
  • 133
  • How would you go about making this code reusable to many views? – Iraklis Eleftheriadis Mar 02 '23 at 20:44
  • @IraklisEleftheriadis You could probably just pass in some view content to the view and use that instead of the `VStack { ForEach ... }` – George Mar 03 '23 at 03:14
  • I ended up creating two custom modifiers for the VStack and ScrollView. I added the two variables you provided as @Binding inside the modifiers so that I could use them to update my progress bar in my main view. – Iraklis Eleftheriadis Mar 03 '23 at 06:26
0

If you want to make George's code reusable, you can do so by creating 2 modifiers and applying them to the view you want to have the animation. Here is the solution:

ContentView.swift:

struct ContentView: View {
    @State private var scrollViewHeight: CGFloat = 0
    @State private var proportion: CGFloat = 0
    @State private var proportionName: String = "scroll"

    var body: some View {
        VStack {
            ScrollView {
                VStack {
                    ForEach(0 ..< 100) { index in
                        Text("Item: \(index + 1)")
                    }
                }
                .frame(maxWidth: .infinity)
                .modifier(
                    ScrollReadVStackModifier(
                        scrollViewHeight: $scrollViewHeight,
                        proportion: $proportion,
                        proportionName: proportionName
                    )
                )
            }
            .modifier(
                ScrollReadScrollViewModifier(
                    scrollViewHeight: $scrollViewHeight,
                    proportionName: proportionName
                )
            )
            ProgressView(value: proportion, total: 1)
                .padding(.horizontal)
        }
    }
}

ScrollViewAnimation.swift

struct ScrollReadVStackModifier: ViewModifier {
     
    @Binding var scrollViewHeight: CGFloat
    @Binding var proportion: CGFloat
    var proportionName: String
    
    func body(content: Content) -> some View {
        
        content
            .background(
                GeometryReader { geo in
                    let scrollLength = geo.size.height - scrollViewHeight
                    let rawProportion = -geo.frame(in: .named(proportionName)).minY / scrollLength
                    let proportion = min(max(rawProportion, 0), 1)
                    
                    Color.clear
                        .preference(
                            key: ScrollProportion.self,
                            value: proportion
                        )
                        .onPreferenceChange(ScrollProportion.self) { proportion in
                            self.proportion = proportion
                        }
                }
            )
        
    }
    
}

struct ScrollReadScrollViewModifier: ViewModifier {
     
    @Binding var scrollViewHeight: CGFloat
    var proportionName: String
    
    func body(content: Content) -> some View {
        
        content
            .background(
                GeometryReader { geo in
                    Color.clear.onAppear {
                        scrollViewHeight = geo.size.height
                    }
                }
            )
            .coordinateSpace(name: proportionName)
        
    }
    
}

struct ScrollProportion: PreferenceKey {
    
    static let defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
    
}