亲宝软件园·资讯

展开

ScrollView List 滚动判断

东坡肘子 人气:0

正文

判断一个可滚动控件( ScrollView、List )是否处于滚动状态在某些场景下具有重要的作用。比如在 SwipeCell 中,需要在可滚动组件开始滚动时,自动关闭已经打开的侧滑菜单。遗憾的是,SwiftUI 并没有提供这方面的 API 。本文将介绍几种在 SwiftUI 中获取当前滚动状态的方法,每种方法都有各自的优势和局限性。

方法一:Introspect

可在 此处 获取本节的代码

在 UIKit( AppKit )中,开发者可以通过 Delegate 的方式获知当前的滚动状态,主要依靠以下三个方法:

scrollViewDidScroll(_ scrollView: UIScrollView)

开始滚动时调用此方法

scrollViewDidEndDecelerating(_ scrollView: UIScrollView)

手指滑动可滚动区域后( 此时手指已经离开 ),滚动逐渐减速,在滚动停止时会调用此方法

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

手指拖动结束后( 手指离开时 ),调用此方法

在 SwiftUI 中,很多的视图控件是对 UIKit( AppKit )控件的二次包装。因此,我们可以通过访问其背后的 UIKit 控件的方式( 使用 Introspect )来实现本文的需求。

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
        }
    }
    // 手指缓慢拖动可滚动控件,手指离开后,decelerate 为 false,因此并不会调用 scrollViewDidEndDecelerating 方法
    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
            }
            // 同时支持 ScrollView 和 List
            .introspectScrollView { scrollView in
                scrollView.delegate = delegate
            }
            .introspectTableView { tableView in
                tableView.delegate = delegate
            }
    }
}

调用方法:

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

方案一优点

方案一缺点

方法二:Runloop

我第一次接触 Runloop 是在学习 Combine 的时候,直到我碰到 Timer 的闭包并没有按照预期被调用时才对其进行了一定的了解

Runloop 是一个事件处理循环。当没有事件时,Runloop 会进入休眠状态,而有事件时,Runloop 会调用对应的 Handler。

Runloop 与线程是绑定的。在应用程序启动的时候,主线程的 Runloop 会被自动创建并启动。

Runloop 拥有多种模式( Mode ),它只会运行在一个模式之下。如果想切换 Mode,必须先退出 loop 然后再重新指定一个 Mode 进入。

在绝大多数的时间里,Runloop 都处于 kCFRunLoopDefaultMode( default )模式中,当可滚动控件处于滚动状态时,为了保证滚动的效率,系统会将 Runloop 切换至 UITrackingRunLoopMode( tracking )模式下。

本节采用的方法便是利用了上述特性,通过创建绑定于不同 Runloop 模式下的 TimerPublisher ,实现对滚动状态的判断。

final class ExclusionStore: ObservableObject {
    @Published var isScrolling = false
    // 当 Runloop 处于 default( kCFRunLoopDefaultMode )模式时,每隔 0.1 秒会发送一个时间信号
    private let idlePublisher = Timer.publish(every: 0.1, on: .main, in: .default).autoconnect()
    // 当 Runloop 处于 tracking( UITrackingRunLoopMode )模式时,每隔 0.1 秒会发送一个时间信号
    private let scrollingPublisher = Timer.publish(every: 0.1, on: .main, in: .tracking).autoconnect()
    private var publisher: some Publisher {
        scrollingPublisher
            .map { _ in 1 } // 滚动时,发送 1
            .merge(with:
                idlePublisher
                    .map { _ in 0 } // 不滚动时,发送 0
            )
    }
    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 // 防止内存泄露
            }
    }
}

方案二优点

方案二缺点

方法三:PreferenceKey

在 SwiftUI 中,子视图可以通过 preference 视图修饰器向其祖先视图传递信息( PreferenceKey )。preference 与 onChange 的调用时机非常类似,只有在值发生改变后才会传递数据。

在 ScrollView、List 发生滚动时,它们内部的子视图的位置也将发生改变。我们将以是否可以持续接收到它们的位置信息为依据判断当前是否处于滚动状态。

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) // 改善进入视图时可能出现的状态抖动
            .handleEvents(
                receiveOutput: { _ in
                    self.timestamp = Date() 
                    // 如果 0.15 秒后没有继续收到位置变化的信号,则发送滚动状态停止的信号
                    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
            }
        // 接收来自子视图的位置信息
            .onPreferenceChange(MinValueKey.self) { _ in
                store.preferencePublisher.send(1) // 我们不关心具体的位置信息,只需将其标注为滚动中
            }
            .onDisappear {
                store.cancellable = nil
            }
    }
}
// 添加与 ScrollView、List 的子视图之上,用于在位置发生变化时发送信息
func scrollSensor() -> some View {
    overlay(
        GeometryReader { proxy in
            Color.clear
                .preference(
                    key: MinValueKey.self,
                    value: proxy.frame(in: .global)
                )
        }
    )
}

方案三优点

方案三缺点

IsScrolling

我将后两种解决方案打包做成了一个库 —— IsScrolling 以方便大家使用。其中 exclusion 对应着 Runloop 原理、common 对应着 PreferenceKey 解决方案。

使用范例( exclusion ):

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

使用范例( common ):

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

总结

SwiftUI 仍在高速进化中,很多积极的变化并不会立即体现出来。待 SwiftUI 更多的底层实现不再依赖 UIKit( AppKit )之时,才会是它 API 的爆发期。

加载全部内容

相关教程
猜你喜欢
用户评论