Kai Zheng
Kai Zheng

Reputation: 8130

SwiftUI - Detect when ScrollView has finished scrolling?

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!

Upvotes: 30

Views: 30075

Answers (8)

I wrote reused solution based on Asperi's answer

import SwiftUI
import Combine

struct TrackableScrollView<Content> : View where Content : View {

    private let axes: Axis.Set
    private let showsIndicators: Bool
    private let onStartScroll: () -> Void
    private let onEndScroll: () -> Void
    private let content: () -> Content

    private let detector: CurrentValueSubject<CGFloat, Never>
    private let smallDelayOffsetChangePublisher: AnyPublisher<CGFloat, Never>
    private let bigDelayOffsetChangePublisher: AnyPublisher<CGFloat, Never>

    @State private var isScrolling = false

    init(
        _ axes: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        onStartScroll: @escaping () -> Void,
        onEndScroll: @escaping () -> Void,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.axes = axes
        self.showsIndicators = showsIndicators
        self.onStartScroll = onStartScroll
        self.onEndScroll = onEndScroll
        self.content = content

        let detector = CurrentValueSubject<CGFloat, Never>(0)
        self.smallDelayOffsetChangePublisher = detector
            .debounce(for: .seconds(0.01), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.bigDelayOffsetChangePublisher = detector
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .dropFirst()
            .eraseToAnyPublisher()
        self.detector = detector
    }

    var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators, content: {
            content()
                .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(smallDelayOffsetChangePublisher) { _ in
            guard !isScrolling else { return }
            isScrolling = true
            onStartScroll()
        }
        .onReceive(bigDelayOffsetChangePublisher) { _ in
            self.isScrolling = false
            onEndScroll()
        }
    }
}

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

and simple usage here:

var body: some View {
        TrackableScrollView(
            onStartScroll: {
                print("scroll started")
            },
            onEndScroll: {
                print("scroll ended")
            },
            content: {
                Text("my content")
            }
        )
    }

Upvotes: 0

David B.
David B.

Reputation: 740

Add this PreferenceKey to track the vertical scroll offset of a View:

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

Add this ViewModifier to allow tracking of a View's vertical offset and call a scrollPostionUpdate closure when scrolling has stopped:

extension View {
    
    func onScrollEnded(in coordinateSpace: CoordinateSpace, onScrollEnded: @escaping (CGFloat) -> Void) -> some View {
        modifier(OnVerticalScrollEnded(coordinateSpace: coordinateSpace, scrollPostionUpdate: onScrollEnded))
    }
}

final class OnVerticalScrollEndedOffsetTracker: ObservableObject {
    let scrollViewVerticalOffset = CurrentValueSubject<CGFloat, Never>(0)
    
    func updateOffset(_ offset: CGFloat) {
        scrollViewVerticalOffset.send(offset)
    }
}

struct OnVerticalScrollEnded: ViewModifier {
    let coordinateSpace: CoordinateSpace
    let scrollPostionUpdate: (CGFloat) -> Void
    @StateObject private var offsetTracker = OnVerticalScrollEndedOffsetTracker()
    
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader(content: { geometry in
                    Color.clear.preference(key: VerticalScrollOffsetKey.self, value: abs(geometry.frame(in: coordinateSpace).origin.y))
                })
            )
            .onPreferenceChange(VerticalScrollOffsetKey.self, perform: offsetTracker.updateOffset(_:))
            .onReceive(offsetTracker.scrollViewVerticalOffset.debounce(for: 0.1, scheduler: DispatchQueue.main).dropFirst(), perform: scrollPostionUpdate)
    }
}

Usage: Add the .onScrollEnded modifier to the content of the ScrollView and give the ScrollView a coordinateSpace name:

struct ScrollingEndedView: View {
    private let coordinateSpaceName = "scrollingEndedView_coordinateSpace"
    
    var body: some View {
        ScrollView {
            VStack {
                ForEach(0...100, id: \.self) { rowNum in
                    Text("Row \(rowNum)")
                        .frame(maxWidth: .infinity)
                        .padding(.vertical)
                        .background(Color.orange)
                }
            }
            .onScrollEnded(in: .named(coordinateSpaceName), onScrollEnded: updateScrollPosition(_:))
        }
        .coordinateSpace(name: coordinateSpaceName) // add the coordinateSpaceName to the ScrollView itself
    }
    
    private func updateScrollPosition(_ position: CGFloat) {
        print("scrolling ended @: \(position)")
    }
}

Upvotes: 0

None of the Answer above worked for me. So with the help of this medium article I updated my code, I need to pagination for my messages (I am sharing my actual code here that I am using in app). Comments are added for each line to understand what code is doing.

@SwiftUI.State private var scrollPosition: CGPoint = .zero

    var chatView: some View { // Made the chat/messages view separate I am adding this chat view in the body view directly, it was not compiling so I break all views in separate variables.
    VStack {
        if viewModel.isFetchingData {
            LoadingView().frame(maxWidth: .infinity, maxHeight: 50)
        } // With the help of this condition I am showing a loader at the top of scroll view
        ScrollViewReader { value in
            ScrollView(showsIndicators: false) {
                VStack {
                    ForEach(0..<(viewModel.messageModelWithSection.count), id: \.self) { index in // Messages has section so used two loops
                        Section(header: headerFor(group: viewModel.messageModelWithSection[index])) {  // This will add the date at the top of section
                            ForEach(viewModel.messageModelWithSection[index].messages) { message in  // These are the messages that contains in the decided date
                                if "\(message.user_id ?? 0)" == "\(viewModel.currentUserId)" { // Sender Id is mine than show messages at my side
                                    MyMessageView(message: Binding.constant(message)) // This structure is define in my app
                                        .onTapGesture { // When user tap on message open the image/pdf in detail
                                            messageHasbeenClicked(message: message) // This method is defined in my app
                                        }
                                } else { // sender id does not match with me, than show the message on the other side
                                    OtherMessageView(message: Binding.constant(message)) // This structure is define in my app
                                        .onTapGesture {// When user tap on message open the image/pdf in detail
                                            messageHasbeenClicked(message: message) // This method is defined in my app
                                        }
                                }
                            }
                        }
                    }
                }
                .rotationEffect(.degrees(180))  // This is done to show the messages from the bottom
                // Detect that is scroll change by user
                .background(GeometryReader { geometry in
                    Color.clear
                        .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin) // ScrollOffset Preference Key definition is available below
                })
                .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                    self.scrollPosition = value
                    if value.y < 10, value.y > 0, viewModel.isFetchingData == false { // Checking that its at top and not other pagination call is send to the server
                        print("Scroll is at top position")
                        viewModel.fetchPreviousMessage(forPagination: true) // It will fetch the pagination result and update the Published Object of messageModelWithSection.
                    }
                }
                .onAppear {
                    value.scrollTo(viewModel.messageModelWithSection.last?.messages.last)
                }
            }
            
        }
        .rotationEffect(.degrees(180)) // This is done to show the messages from the bottom
        .coordinateSpace(name: "scroll")
    }
}

Definition of the ScrollOfSerPrefrenceKey

struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

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

I hope so it will help out, or you can get more details in the article I provided.

Upvotes: 0

Lachezar Todorov
Lachezar Todorov

Reputation: 923

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...")
    }
}

Upvotes: 5

Asperi
Asperi

Reputation: 257573

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()
    }
}

Upvotes: 48

mdonati
mdonati

Reputation: 1099

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") }

}

Upvotes: 3

rikkm
rikkm

Reputation: 31

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()
    }
}

Upvotes: 3

Oleg G.
Oleg G.

Reputation: 588

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.

Upvotes: 0

Related Questions