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

原文宣告在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

怎么判别 ScrollView、List 是否正在翻滚中

方法一: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)
        }
    }
}

方案一利益

  • 精确
  • 及时
  • 体系担负小

方案一缺点

  • 向后兼容性

    SwiftUI 随时可能会改动控件的内部完结方法,这种情况现已多次出现。现在 SwiftUI 在内部的完结上去 UIKit( AppKit )化很明显,比如,本节介绍的方法在 SwiftUI 4.0 中现已失效

方法二: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 // 防止内存走漏
            }
    }
}

方案二利益

  • 具有与 Delegate 方法几乎一同的精确性和及时性
  • 完结的逻辑十分简略

方案二缺点

  • 只能运转于 iOS 体系

    在 macOS 下的 eventTracking 方式中,该方案的表现并不抱负

  • 屏幕中只能有一个可翻滚控件

    因为恣意可翻滚控件翻滚时,都会导致主线程的 Runloop 切换至 tracing 方式,因此无法有效地区分翻滚是由那个控件构成的

方法三: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)
                )
        }
    )
}

方案三利益

  • 支撑多渠道( iOS、macOS、macCatalyst )
  • 具有较好的前后兼容性

方案三缺点

  • 需要为可翻滚容器的子视图添加润饰器

    关于 ScrollView + VStack( HStack )这类的组合,只需为可翻滚视图添加一个 scrollSensor 即可。关于 List、ScrollView + LazyVStack( LazyHStack )这类的组合,需要为每个子视图都添加一个 scrollSensor。

  • 判别的精确度没有前两种方法高

    当可翻滚组件中的内容出现了非翻滚引起的标准或方位的改动( 例如 List 中某个视图的标准发生了动态改动 ),本方法会误判别为发生了翻滚,但在视图的改动结束后,情况会立刻恢复到翻滚结束

    翻滚初步后( 情况已改动为翻滚中 ),坚持手指处于按压情况并间断滑动,此方法会将此刻视为翻滚结束,而前两种方法仍会坚持翻滚中的情况直到手指结束按压

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 的迸发期。

希望本文可以对你有所帮助。一同也欢迎你通过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

我正以聊天室、Twitter、博客留言等评论为灵感,从中选取有代表性的问题和技巧制作成 Tips ,发布在 Twitter 上。每周也会对当周博客上的新文章以及在 Twitter 上发布的 Tips 进行汇总,并通过邮件列表的方式发送给订阅者。

订阅下方的 邮件列表,可以及时获得每周的 Tips 汇总。

原文宣告在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】