文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

判断 ScrollViewList 是否正在滚动详解

2024-04-02 19:55

关注

正文

判断一个可滚动控件( 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 的爆发期。

以上就是判断 ScrollView、List 是否正在滚动的详细内容,更多关于ScrollView List 滚动判断的资料请关注编程网其它相关文章!

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     801人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     348人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     311人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     432人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     220人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯