24

I need to find out the exact moment when my ScrollView stops moving. Is that possible with SwiftUI?

Here would be an equivalent for UIScrollView.

I have no idea after thinking a lot about it...

A sample project to test things out:

struct ContentView: View {
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(0...100, id: \.self) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)"))
                }
            }
            .frame(maxWidth: .infinity)
        }
    }
}

Thanks!

Kai Zheng
  • 6,640
  • 7
  • 43
  • 66

5 Answers5

36

Here is a demo of possible approach - use publisher with changed scrolled content coordinates with debounce, so event reported only after coordinates stopped changing.

Tested with Xcode 12.1 / iOS 14.1

UPDATE: verified as worked with Xcode 13.3 / iOS 15.4

Note: you can play with debounce period to tune it for your needs.

demo

import Combine

struct ContentView: View {
    let detector: CurrentValueSubject<CGFloat, Never>
    let publisher: AnyPublisher<CGFloat, Never>

    init() {
        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.publisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.detector = detector
    }
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(0...100, id: \.self) { i in
                    Rectangle()
                        .frame(width: 200, height: 100)
                        .foregroundColor(.green)
                        .overlay(Text("\(i)"))
                }
            }
            .frame(maxWidth: .infinity)
            .background(GeometryReader {
                Color.clear.preference(key: ViewOffsetKey.self,
                    value: -$0.frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) { detector.send($0) }
        }.coordinateSpace(name: "scroll")
        .onReceive(publisher) {
            print("Stopped on: \($0)")
        }
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Hey, awkward, this doesn't seem to work in my case. the `print` is never called. Also using an additional gesture to detect when it starts by doing this ```.simultaneousGesture( DragGesture().onChanged({ _ in print("Started Scrolling") }))``` – Oleg G. Jan 19 '21 at 04:43
  • What is the implementation of `ViewOffsetKey` ? – Oleg G. Jan 19 '21 at 04:45
  • @OlegG. ViewOffsetKey is taken from my another post https://stackoverflow.com/a/62588295/12299030 – Asperi Jan 19 '21 at 04:46
  • Ok, so yeah basically I implemented the same one. But still doesn't work. `Started scrolling` is called but not the stop – Oleg G. Jan 19 '21 at 04:49
  • Genius, thank you so much. Using the well known introspectScrollView happen to crash due to SwiftUI dynamic dealloc and this native solution is actually the best to go. – ElyesDer Feb 11 '21 at 06:31
  • I found a strange bug here. When we package it as a view like `TrackScrollView` with a `binding` / `environmentObject` / `objectedObject`, and we update binding state in `.onPreferenceChange(ViewOffsetKey.self) { self.theBindingState = "foo" detector.send($0) }`, then `.onReceive` will not trigger:( – Eriice Apr 02 '21 at 13:50
  • 2
    The print statements never fire for me in Xcode 13 and iOS 15 – Subcreation Sep 26 '21 at 01:09
  • 1
    I had problems (in similar code) with onReceive not being called. (Removing the debounce() call avoided it -- but that's not an option, of course.) I eventually fixed it by making `detector` and `publisher` @State variables instead of let-properties. – ecp May 08 '23 at 04:06
  • Why apple doesn't provide its own set of functions just like UIScrollView? And will it do so in future? – Shubham Ojha May 26 '23 at 06:14
4

One more variant that worked for me, based on @mdonati answer

The ZStack solved my problem when I was using LazyHStack or LazyVStack

struct ScrollViewOffsetReader: View {
    private let onScrollingStarted: () -> Void
    private let onScrollingFinished: () -> Void
    
    private let detector: CurrentValueSubject<CGFloat, Never>
    private let publisher: AnyPublisher<CGFloat, Never>
    @State private var scrolling: Bool = false
    
    @State private var lastValue: CGFloat = 0
    
    init() {
        self.init(onScrollingStarted: {}, onScrollingFinished: {})
    }
    
    init(
        onScrollingStarted: @escaping () -> Void,
        onScrollingFinished: @escaping () -> Void
    ) {
        self.onScrollingStarted = onScrollingStarted
        self.onScrollingFinished = onScrollingFinished
        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.publisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .eraseToAnyPublisher()
        self.detector = detector
    }
    
    var body: some View {
        GeometryReader { g in
            Rectangle()
                .frame(width: 0, height: 0)
                .onChange(of: g.frame(in: .global).origin.x) { offset in
                    if !scrolling {
                        scrolling = true
                        onScrollingStarted()
                    }
                    detector.send(offset)
                }
                .onReceive(publisher) {
                    scrolling = false
                    
                    guard lastValue != $0 else { return }
                    lastValue = $0
                    
                    onScrollingFinished()
                }
        }
    }
    
    func onScrollingStarted(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: closure,
            onScrollingFinished: onScrollingFinished
        )
    }
    
    func onScrollingFinished(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: onScrollingStarted,
            onScrollingFinished: closure
        )
    }
}

Usage

ScrollView(.horizontal, showsIndicators: false) {
    ZStack {
        ScrollViewOffsetReader(onScrollingStarted: {
            isScrolling = true
        }, onScrollingFinished: {
            isScrolling = false
        })
        Text("More content...")
    }
}
Lachezar Todorov
  • 903
  • 9
  • 21
  • Thanks for the answer, can you elaborate about what `lastValue` is doing here. Where is it set? Why is it needed? Thanks – TMin Apr 04 '23 at 16:21
  • 1
    It's defined as State variable on top. It's used to make sure we do not call onScrollingFinished more than once per event. – Lachezar Todorov Apr 05 '23 at 08:44
  • This this not always trigger the `onScrollingFinished`, and sometimes it comes just before a new `onScrollingStarted`. iOS 16.4 Simulator. – StackUnderflow Jun 01 '23 at 20:46
3

For me the publisher also didn't fire when implementing Asperi's answer into a more complicated SwiftUI view. To fix it I created a StateObject with a published variable set with a certain debounce time.

To my best of knowledge, this is what happens: the offset of the scrollView is written to a publisher (currentOffset) which then handles it with a debounce. When the value gets passed along after the debounce (which means scrolling has stopped) it's assigned to another publisher (offsetAtScrollEnd), which the view (ScrollViewTest) receives.

import SwiftUI
import Combine

struct ScrollViewTest: View {
    
    @StateObject var scrollViewHelper = ScrollViewHelper()
    
    var body: some View {
        
        ScrollView {
            ZStack {
                
                VStack(spacing: 20) {
                    ForEach(0...100, id: \.self) { i in
                        Rectangle()
                            .frame(width: 200, height: 100)
                            .foregroundColor(.green)
                            .overlay(Text("\(i)"))
                    }
                }
                .frame(maxWidth: .infinity)
                
                GeometryReader {
                    let offset = -$0.frame(in: .named("scroll")).minY
                    Color.clear.preference(key: ViewOffsetKey.self, value: offset)
                }
                
            }
            
        }.coordinateSpace(name: "scroll")
        .onPreferenceChange(ViewOffsetKey.self) {
            scrollViewHelper.currentOffset = $0
        }.onReceive(scrollViewHelper.$offsetAtScrollEnd) {
            print($0)
        }
        
    }
    
}

class ScrollViewHelper: ObservableObject {
    
    @Published var currentOffset: CGFloat = 0
    @Published var offsetAtScrollEnd: CGFloat = 0
    
    private var cancellable: AnyCancellable?
    
    init() {
        cancellable = AnyCancellable($currentOffset
                                        .debounce(for: 0.2, scheduler: DispatchQueue.main)
                                        .dropFirst()
                                        .assign(to: \.offsetAtScrollEnd, on: self))
    }
    
}

struct ViewOffsetKey: PreferenceKey {
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}
rikkm
  • 31
  • 1
3

Based on some of the answers posted here, I came up with this component that only reads x-offset so it won't work for vertical scroll, but can easily be tweaked to adjust to your needs.

import SwiftUI
import Combine

struct ScrollViewOffsetReader: View {
    private let onScrollingStarted: () -> Void
    private let onScrollingFinished: () -> Void
    
    private let detector: CurrentValueSubject<CGFloat, Never>
    private let publisher: AnyPublisher<CGFloat, Never>
    @State private var scrolling: Bool = false
    
    init() {
        self.init(onScrollingStarted: {}, onScrollingFinished: {})
    }
    
    private init(
        onScrollingStarted: @escaping () -> Void,
        onScrollingFinished: @escaping () -> Void
    ) {
        self.onScrollingStarted = onScrollingStarted
        self.onScrollingFinished = onScrollingFinished
        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.publisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.detector = detector
    }
    
    var body: some View {
        GeometryReader { g in
            Rectangle()
                .frame(width: 0, height: 0)
                .onChange(of: g.frame(in: .global).origin.x) { offset in
                    if !scrolling {
                        scrolling = true
                        onScrollingStarted()
                    }
                    detector.send(offset)
                }
                .onReceive(publisher) { _ in
                    scrolling = false
                    onScrollingFinished()
                }
        }
    }
    
    func onScrollingStarted(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: closure,
            onScrollingFinished: onScrollingFinished
        )
    }
    
    func onScrollingFinished(_ closure: @escaping () -> Void) -> Self {
        .init(
            onScrollingStarted: onScrollingStarted,
            onScrollingFinished: closure
        )
    }
}

Usage

ScrollView {
    ScrollViewOffsetReader()
        .onScrollingStarted { print("Scrolling started") }
        .onScrollingFinished { print("Scrolling finished") }

}
mdonati
  • 1,049
  • 11
  • 24
  • your solution is working perfectly for me. However, it seems to not be able to maintain state of its children. I have added Lottie animation inside the ScrollView and the animation always restarts when I scroll up and down. Details about my issue here: https://stackoverflow.com/questions/75338453/swiftui-scrollview-with-listeners-not-maintaining-content-state. – Paras Gorasiya Feb 06 '23 at 12:39
  • This works better then the solution from @lachezar-todorov – StackUnderflow Jun 01 '23 at 20:55
0

I implemented a scollview with the following code. And the "Stopped on: \($0)" is never called. Did i do something wrong?

func scrollableView(with geometryProxy: GeometryProxy) -> some View {
        let middleScreenPosition = geometryProxy.size.height / 2

        return ScrollView(content: {
            ScrollViewReader(content: { scrollViewProxy in
                VStack(alignment: .leading, spacing: 20, content: {
                    Spacer()
                        .frame(height: geometryProxy.size.height * 0.4)
                    ForEach(viewModel.fragments, id: \.id) { fragment in
                        Text(fragment.content) // Outside of geometry ready to set the natural size
                            .opacity(0)
                            .overlay(
                                GeometryReader { textGeometryReader in
                                    let midY = textGeometryReader.frame(in: .global).midY

                                    Text(fragment.content) // Actual text
                                        .font(.headline)
                                        .foregroundColor( // Text color
                                            midY > (middleScreenPosition - textGeometryReader.size.height / 2) &&
                                                midY < (middleScreenPosition + textGeometryReader.size.height / 2) ? .white :
                                                midY < (middleScreenPosition - textGeometryReader.size.height / 2) ? .gray :
                                                .gray
                                        )
                                        .colorMultiply( // Animates better than .foregroundColor animation
                                            midY > (middleScreenPosition - textGeometryReader.size.height / 2) &&
                                                midY < (middleScreenPosition + textGeometryReader.size.height / 2) ? .white :
                                                midY < (middleScreenPosition - textGeometryReader.size.height / 2) ? .gray :
                                                .clear
                                        )
                                        .animation(.easeInOut)
                                }
                            )
                            .scrollId(fragment.id)
                    }
                    Spacer()
                        .frame(height: geometryProxy.size.height * 0.4)
                })
                .frame(maxWidth: .infinity)
                .background(GeometryReader {
                    Color.clear.preference(key: ViewOffsetKey.self,
                                           value: -$0.frame(in: .named("scroll")).origin.y)
                })
                .onPreferenceChange(ViewOffsetKey.self) { detector.send($0) }
                .padding()
                .onReceive(self.fragment.$currentFragment, perform: { currentFragment in
                    guard let id = currentFragment?.id else {
                        return
                    }
                    scrollViewProxy.scrollTo(id, alignment: .center)
                })
            })
        })
        .simultaneousGesture(
            DragGesture().onChanged({ _ in
                print("Started Scrolling")
            }))
        .coordinateSpace(name: "scroll")
        .onReceive(publisher) {
            print("Stopped on: \($0)")
        }
    }

I am not sure if I should do a new Stack post or not here, since I am trying to make the code here works.

Edit: Actually it works if I paused the audio player playing at the same time. By pausing it, it allows the publisher to be called. Awkward.

Edit 2: removing .dropFirst() seems to fix it but over calling it.

Oleg G.
  • 550
  • 5
  • 25