網站首頁 編程語言 正文
正文
判斷一個可滾動控件( 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) } } }
方案一優點
- 準確
- 及時
- 系統負擔小
方案一缺點
- 向后兼容性差
- 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 的爆發期。
原文鏈接:https://www.fatbobman.com/posts/how_to_judge_ScrollView_is_scrolling/
相關推薦
- 2022-10-01 django中資源文件夾的引入及配置方法_python
- 2022-08-04 如何利用python實現列表嵌套字典取值_python
- 2022-06-18 深入解析Go?變量字符串與字符編碼問題_Golang
- 2023-01-08 ubuntu1804搭建FTP服務器的詳細教程_FTP服務器
- 2022-02-11 小程序如何把參數設置為全局變量
- 2022-07-02 Python零錢兌換的實現代碼_python
- 2022-08-30 cvc-complex-type.2.4.a: 發現了以元素 ‘base-extension‘ 開頭
- 2022-05-20 ElasticSearch 7.X系列之:查詢分析索引磁盤使用空間_disk_usage
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支