肘子的 Swift 记事本

How to Determine if ScrollView, List is Currently Scrolling in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Determining whether a scrollable control (ScrollView, List) is in a scrolling state is important in some scenarios. For example, in SwipeCell, it is necessary to automatically close already opened swipe menus when a scrollable component starts scrolling. Unfortunately, SwiftUI does not provide an API for this. This article will introduce several methods for obtaining the current scrolling state in SwiftUI, each with its own advantages and limitations.

https://cdn.fatbobman.com/isScrolling_2022-09-12_10.26.06.2022-09-12%2010_28_09.gif

Method 1: Introspect

The code for this section can be found here.

In UIKit (AppKit), developers can obtain the current scroll state through the Delegate method, primarily relying on the following three methods:

  • scrollViewDidScroll(_ scrollView: UIScrollView)

    This method is called when scrolling begins.

  • scrollViewDidEndDecelerating(_ scrollView: UIScrollView)

    This method is called when scrolling decelerates and stops after the user’s finger has left the scrollable area.

  • scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool)

    This method is called when the user’s finger is lifted and dragging ends.

In SwiftUI, many view controls are secondary packages of UIKit (AppKit) controls. Therefore, we can use the way of accessing the underlying UIKit control (using Introspect) to achieve the requirements in this article.

Swift
final class ScrollDelegate: NSObject, UITableViewDelegate, UIScrollViewDelegate {
    var isScrolling: Binding<Bool>?

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if let isScrolling = isScrolling?.wrappedValue,!isScrolling {
            self.isScrolling?.wrappedValue = true
        }
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if let isScrolling = isScrolling?.wrappedValue, isScrolling {
            self.isScrolling?.wrappedValue = false
        }
    }

    // When the user slowly drags the scrollable control, decelerate is false after the user releases their finger, so the scrollViewDidEndDecelerating method is not called.
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if !decelerate {
            if let isScrolling = isScrolling?.wrappedValue, isScrolling {
                self.isScrolling?.wrappedValue = false
            }
        }
    }
}

extension View {
    func scrollStatusByIntrospect(isScrolling: Binding<Bool>) -> some View {
        modifier(ScrollStatusByIntrospectModifier(isScrolling: isScrolling))
    }
}

struct ScrollStatusByIntrospectModifier: ViewModifier {
    @State var delegate = ScrollDelegate()
    @Binding var isScrolling: Bool
    func body(content: Content) -> some View {
        content
            .onAppear {
                self.delegate.isScrolling = $isScrolling
            }
            // Support ScrollView and List at the same time.
            .introspectScrollView { scrollView in
                scrollView.delegate = delegate
            }
            .introspectTableView { tableView in
                tableView.delegate = delegate
            }
    }
}

Call method:

Swift
struct ScrollStatusByIntrospect: View {
    @State var isScrolling = false
    var body: some View {
        VStack {
            Text("isScrolling: \(isScrolling1 ? "True" : "False")")
            List {
                ForEach(0..<100) { i in
                    Text("id:\(i)")
                }
            }
            .scrollStatusByIntrospect(isScrolling: $isScrolling)
        }
    }
}

Advantages of solution one

  • Accurate
  • Timely
  • Low system burden

Disadvantages of solution one

  • Poor backward compatibility

    SwiftUI may change the internal implementation of controls at any time, and this has happened several times. Currently, the internal implementation of SwiftUI is becoming more like UIKit (AppKit), for example, the method introduced in this section has been invalid in SwiftUI 4.0.

Method two: Runloop

I first came into contact with Runloop when I was learning Combine, and it wasn’t until I found that the closure of Timer was not called as expected that I had some understanding of it.

Runloop is an event processing loop. When there is no event, Runloop enters a sleep state, and when there is an event, Runloop calls the corresponding handler.

Runloop is bound to threads. When the application starts up, the main thread’s Runloop is automatically created and started.

Runloop has multiple modes, and it only runs under one mode. If you want to switch modes, you must first exit the loop and then re-enter with a new mode.

In most cases, Runloop is in the kCFRunLoopDefaultMode (default) mode. When scrollable controls are in scrolling state, to ensure scrolling efficiency, the system switches Runloop to the UITrackingRunLoopMode (tracking) mode.

This section uses the above characteristics and creates TimerPublishers bound to different Runloop modes to implement judgment of scrolling status.

Swift
final class ExclusionStore: ObservableObject {
    @Published var isScrolling = false
    // When the Runloop is in the default (kCFRunLoopDefaultMode) mode, a time signal will be sent every 0.1 seconds.
    private let idlePublisher = Timer.publish(every: 0.1, on: .main, in: .default).autoconnect()
    // When the Runloop is in the tracking (UITrackingRunLoopMode) mode, a time signal will be sent every 0.1 seconds.
    private let scrollingPublisher = Timer.publish(every: 0.1, on: .main, in: .tracking).autoconnect()

    private var publisher: some Publisher {
        scrollingPublisher
            .map { _ in 1 } // Send 1 when scrolling
            .merge(with:
                idlePublisher
                    .map { _ in 0 } // Send 0 when not scrolling
            )
    }

    var cancellable: AnyCancellable?

    init() {
        cancellable = publisher
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { _ in }, receiveValue: { output in
                guard let value = output as? Int else { return }
                if value == 1,!self.isScrolling {
                    self.isScrolling = true
                }
                if value == 0, self.isScrolling {
                    self.isScrolling = false
                }
            })
    }
}

struct ScrollStatusMonitorExclusionModifier: ViewModifier {
    @StateObject private var store = ExclusionStore()
    @Binding var isScrolling: Bool
    func body(content: Content) -> some View {
        content
            .environment(\.isScrolling, store.isScrolling)
            .onChange(of: store.isScrolling) { value in
                isScrolling = value
            }
            .onDisappear {
                store.cancellable = nil // Prevent memory leaks
            }
    }
}

Advantages of Solution 2

  • Almost the same accuracy and timeliness as the Delegate method
  • The implementation logic is very simple

Disadvantages of Solution 2

  • Can only run on iOS systems

    The performance of this solution is not ideal in the eventTracking mode of macOS

  • Only one scrollable control can be present on the screen

    Since scrolling of any scrollable control will cause the main thread’s Runloop to switch to tracing mode, it is impossible to effectively distinguish which control caused the scrolling.

Method 3: PreferenceKey

In SwiftUI, child views can pass information to their ancestor views (PreferenceKey) through the preference view modifier. The timing of preference and onChange calls is very similar, and data is only passed after the value changes.

When ScrollView and List are scrolled, the positions of their internal child views will also change. We will use whether they can continuously receive their position information as a basis to judge whether they are currently in a scrolling state.

Swift
final class CommonStore: ObservableObject {
    @Published var isScrolling = false
    private var timestamp = Date()

    let preferencePublisher = PassthroughSubject<Int, Never>()
    let timeoutPublisher = PassthroughSubject<Int, Never>()

    private var publisher: some Publisher {
        preferencePublisher
            .dropFirst(2) // Improve the status jitter that may occur when entering the view
            .handleEvents(
                receiveOutput: { _ in
                    self.timestamp = Date()
                    // If no position change signal is received 0.15 seconds later, a signal indicating that the scrolling status has stopped is sent.
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
                        if Date().timeIntervalSince(self.timestamp) > 0.1 {
                            self.timeoutPublisher.send(0)
                        }
                    }
                }
            )
            .merge(with: timeoutPublisher)
    }

    var cancellable: AnyCancellable?

    init() {
        cancellable = publisher
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { _ in }, receiveValue: { output in
                guard let value = output as? Int else { return }
                if value == 1,!self.isScrolling {
                    self.isScrolling = true
                }
                if value == 0, self.isScrolling {
                    self.isScrolling = false
                }
            })
    }
}

public struct MinValueKey: PreferenceKey {
    public static var defaultValue: CGRect = .zero
    public static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

struct ScrollStatusMonitorCommonModifier: ViewModifier {
    @StateObject private var store = CommonStore()
    @Binding var isScrolling: Bool
    func body(content: Content) -> some View {
        content
            .environment(\.isScrolling, store.isScrolling)
            .onChange(of: store.isScrolling) { value in
                isScrolling = value
            }
        // Receive position information from child views
            .onPreferenceChange(MinValueKey.self) { _ in
                store.preferencePublisher.send(1) // We don't care about the specific position information, we just need to mark it as scrolling
            }
            .onDisappear {
                store.cancellable = nil
            }
    }
}

// Add above the child views of ScrollView and List to send information when the position changes.
func scrollSensor() -> some View {
    overlay(
        GeometryReader { proxy in
            Color.clear
                .preference(
                    key: MinValueKey.self,
                    value: proxy.frame(in: .global)
                )
        }
    )
}

Advantages

  • Supports multiple platforms (iOS, macOS, macCatalyst)
  • Has good forward and backward compatibility

Disadvantages

  • Need to add modifiers to child views of scrollable containers

    For combinations like ScrollView + VStack (HStack), just add a scrollSensor to the scrollable view. For combinations like List and ScrollView + LazyVStack (LazyHStack), you need to add a scrollSensor for each child view.

  • Less accurate judgment than the first two methods

    When the content in the scrollable component changes size or position due to non-scrolling reasons (such as the size of a view in List changes dynamically), this method will mistakenly judge that scrolling has occurred, but the state will immediately return to the scrolling end after the view changes.

    After scrolling starts (the state has changed to scrolling), keep your finger pressed and stop sliding. This method will treat it as the end of scrolling, while the first two methods will still maintain the scrolling state until the finger is released.

IsScrolling

I have packaged the last two solutions into a library called IsScrolling for everyone’s convenience. The “exclusion” option corresponds to the Runloop principle, and the “common” option corresponds to the PreferenceKey solution.

Example usage (exclusion):

Swift
struct VStackExclusionDemo: View {
    @State var isScrolling = false
    var body: some View {
        VStack {
            ScrollView {
                VStack {
                    ForEach(0..<100) { i in
                        CellView(index: i) // no need to add sensor in exclusion mode
                    }
                }
            }
            .scrollStatusMonitor($isScrolling, monitorMode: .exclusion) // add scrollStatusMonitor to get scroll status
        }
    }
}

Usage Example (common):

Swift
struct ListCommonDemo: View {
    @State var isScrolling = false
    var body: some View {
        VStack {
            List {
                ForEach(0..<100) { i in
                    CellView(index: i)
                        .scrollSensor() // Need to add sensor for each subview
                }
            }
            .scrollStatusMonitor($isScrolling, monitorMode: .common)
        }
    }
}

Summary

SwiftUI is still evolving rapidly, and many positive changes may not be immediately apparent. The true explosion of its API will come when more of SwiftUI’s underlying implementations no longer rely on UIKit (or AppKit).

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways