判斷?ScrollView List?是否正在滾動(dòng)詳解
正文
判斷一個(gè)可滾動(dòng)控件( ScrollView、List )是否處于滾動(dòng)狀態(tài)在某些場(chǎng)景下具有重要的作用。比如在 SwipeCell 中,需要在可滾動(dòng)組件開(kāi)始滾動(dòng)時(shí),自動(dòng)關(guān)閉已經(jīng)打開(kāi)的側(cè)滑菜單。遺憾的是,SwiftUI 并沒(méi)有提供這方面的 API 。本文將介紹幾種在 SwiftUI 中獲取當(dāng)前滾動(dòng)狀態(tài)的方法,每種方法都有各自的優(yōu)勢(shì)和局限性。
方法一:Introspect
可在 此處 獲取本節(jié)的代碼
在 UIKit( AppKit )中,開(kāi)發(fā)者可以通過(guò) Delegate 的方式獲知當(dāng)前的滾動(dòng)狀態(tài),主要依靠以下三個(gè)方法:
scrollViewDidScroll(_ scrollView: UIScrollView)
開(kāi)始滾動(dòng)時(shí)調(diào)用此方法
scrollViewDidEndDecelerating(_ scrollView: UIScrollView)
手指滑動(dòng)可滾動(dòng)區(qū)域后( 此時(shí)手指已經(jīng)離開(kāi) ),滾動(dòng)逐漸減速,在滾動(dòng)停止時(shí)會(huì)調(diào)用此方法
scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool)
手指拖動(dòng)結(jié)束后( 手指離開(kāi)時(shí) ),調(diào)用此方法
在 SwiftUI 中,很多的視圖控件是對(duì) UIKit( AppKit )控件的二次包裝。因此,我們可以通過(guò)訪問(wèn)其背后的 UIKit 控件的方式( 使用 Introspect )來(lái)實(shí)現(xiàn)本文的需求。
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 } } // 手指緩慢拖動(dòng)可滾動(dòng)控件,手指離開(kāi)后,decelerate 為 false,因此并不會(huì)調(diào)用 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 } // 同時(shí)支持 ScrollView 和 List .introspectScrollView { scrollView in scrollView.delegate = delegate } .introspectTableView { tableView in tableView.delegate = delegate } } }
調(diào)用方法:
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) } } }
方案一優(yōu)點(diǎn)
- 準(zhǔn)確
- 及時(shí)
- 系統(tǒng)負(fù)擔(dān)小
方案一缺點(diǎn)
- 向后兼容性差
- SwiftUI 隨時(shí)可能會(huì)改變控件的內(nèi)部實(shí)現(xiàn)方式,這種情況已經(jīng)多次出現(xiàn)。目前 SwiftUI 在內(nèi)部的實(shí)現(xiàn)上去 UIKit( AppKit )化很明顯,比如,本節(jié)介紹的方法在 SwiftUI 4.0 中已經(jīng)失效
方法二:Runloop
我第一次接觸 Runloop 是在學(xué)習(xí) Combine 的時(shí)候,直到我碰到 Timer 的閉包并沒(méi)有按照預(yù)期被調(diào)用時(shí)才對(duì)其進(jìn)行了一定的了解
Runloop 是一個(gè)事件處理循環(huán)。當(dāng)沒(méi)有事件時(shí),Runloop 會(huì)進(jìn)入休眠狀態(tài),而有事件時(shí),Runloop 會(huì)調(diào)用對(duì)應(yīng)的 Handler。
Runloop 與線程是綁定的。在應(yīng)用程序啟動(dòng)的時(shí)候,主線程的 Runloop 會(huì)被自動(dòng)創(chuàng)建并啟動(dòng)。
Runloop 擁有多種模式( Mode ),它只會(huì)運(yùn)行在一個(gè)模式之下。如果想切換 Mode,必須先退出 loop 然后再重新指定一個(gè) Mode 進(jìn)入。
在絕大多數(shù)的時(shí)間里,Runloop 都處于 kCFRunLoopDefaultMode( default )模式中,當(dāng)可滾動(dòng)控件處于滾動(dòng)狀態(tài)時(shí),為了保證滾動(dòng)的效率,系統(tǒng)會(huì)將 Runloop 切換至 UITrackingRunLoopMode( tracking )模式下。
本節(jié)采用的方法便是利用了上述特性,通過(guò)創(chuàng)建綁定于不同 Runloop 模式下的 TimerPublisher ,實(shí)現(xiàn)對(duì)滾動(dòng)狀態(tài)的判斷。
final class ExclusionStore: ObservableObject { @Published var isScrolling = false // 當(dāng) Runloop 處于 default( kCFRunLoopDefaultMode )模式時(shí),每隔 0.1 秒會(huì)發(fā)送一個(gè)時(shí)間信號(hào) private let idlePublisher = Timer.publish(every: 0.1, on: .main, in: .default).autoconnect() // 當(dāng) Runloop 處于 tracking( UITrackingRunLoopMode )模式時(shí),每隔 0.1 秒會(huì)發(fā)送一個(gè)時(shí)間信號(hào) private let scrollingPublisher = Timer.publish(every: 0.1, on: .main, in: .tracking).autoconnect() private var publisher: some Publisher { scrollingPublisher .map { _ in 1 } // 滾動(dòng)時(shí),發(fā)送 1 .merge(with: idlePublisher .map { _ in 0 } // 不滾動(dòng)時(shí),發(fā)送 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 // 防止內(nèi)存泄露 } } }
方案二優(yōu)點(diǎn)
- 具備與 Delegate 方式幾乎一致的準(zhǔn)確性和及時(shí)性
- 實(shí)現(xiàn)的邏輯非常簡(jiǎn)單
方案二缺點(diǎn)
- 只能運(yùn)行于 iOS 系統(tǒng)
- 在 macOS 下的 eventTracking 模式中,該方案的表現(xiàn)并不理想
- 屏幕中只能有一個(gè)可滾動(dòng)控件
- 由于任意可滾動(dòng)控件滾動(dòng)時(shí),都會(huì)導(dǎo)致主線程的 Runloop 切換至 tracing 模式,因此無(wú)法有效地區(qū)分滾動(dòng)是由那個(gè)控件造成的
方法三:PreferenceKey
在 SwiftUI 中,子視圖可以通過(guò) preference 視圖修飾器向其祖先視圖傳遞信息( PreferenceKey )。preference 與 onChange 的調(diào)用時(shí)機(jī)非常類似,只有在值發(fā)生改變后才會(huì)傳遞數(shù)據(jù)。
在 ScrollView、List 發(fā)生滾動(dòng)時(shí),它們內(nèi)部的子視圖的位置也將發(fā)生改變。我們將以是否可以持續(xù)接收到它們的位置信息為依據(jù)判斷當(dāng)前是否處于滾動(dòng)狀態(tài)。
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) // 改善進(jìn)入視圖時(shí)可能出現(xiàn)的狀態(tài)抖動(dòng) .handleEvents( receiveOutput: { _ in self.timestamp = Date() // 如果 0.15 秒后沒(méi)有繼續(xù)收到位置變化的信號(hào),則發(fā)送滾動(dòng)狀態(tài)停止的信號(hào) 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 } // 接收來(lái)自子視圖的位置信息 .onPreferenceChange(MinValueKey.self) { _ in store.preferencePublisher.send(1) // 我們不關(guān)心具體的位置信息,只需將其標(biāo)注為滾動(dòng)中 } .onDisappear { store.cancellable = nil } } } // 添加與 ScrollView、List 的子視圖之上,用于在位置發(fā)生變化時(shí)發(fā)送信息 func scrollSensor() -> some View { overlay( GeometryReader { proxy in Color.clear .preference( key: MinValueKey.self, value: proxy.frame(in: .global) ) } ) }
方案三優(yōu)點(diǎn)
- 支持多平臺(tái)( iOS、macOS、macCatalyst )
- 擁有較好的前后兼容性
方案三缺點(diǎn)
- 需要為可滾動(dòng)容器的子視圖添加修飾器
- 對(duì)于 ScrollView + VStack( HStack )這類的組合,只需為可滾動(dòng)視圖添加一個(gè) scrollSensor 即可。對(duì)于 List、ScrollView + LazyVStack( LazyHStack )這類的組合,需要為每個(gè)子視圖都添加一個(gè) scrollSensor。
- 判斷的準(zhǔn)確度沒(méi)有前兩種方式高
- 當(dāng)可滾動(dòng)組件中的內(nèi)容出現(xiàn)了非滾動(dòng)引起的尺寸或位置的變化( 例如 List 中某個(gè)視圖的尺寸發(fā)生了動(dòng)態(tài)變化 ),本方式會(huì)誤判斷為發(fā)生了滾動(dòng),但在視圖的變化結(jié)束后,狀態(tài)會(huì)馬上恢復(fù)到滾動(dòng)結(jié)束
- 滾動(dòng)開(kāi)始后( 狀態(tài)已變化為滾動(dòng)中 ),保持手指處于按壓狀態(tài)并停止滑動(dòng),此方式會(huì)將此時(shí)視為滾動(dòng)結(jié)束,而前兩種方式仍會(huì)保持滾動(dòng)中的狀態(tài)直到手指結(jié)束按壓
IsScrolling
我將后兩種解決方案打包做成了一個(gè)庫(kù) —— IsScrolling 以方便大家使用。其中 exclusion 對(duì)應(yīng)著 Runloop 原理、common 對(duì)應(yīng)著 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) } } }
總結(jié)
SwiftUI 仍在高速進(jìn)化中,很多積極的變化并不會(huì)立即體現(xiàn)出來(lái)。待 SwiftUI 更多的底層實(shí)現(xiàn)不再依賴 UIKit( AppKit )之時(shí),才會(huì)是它 API 的爆發(fā)期。
以上就是判斷 ScrollView、List 是否正在滾動(dòng)的詳細(xì)內(nèi)容,更多關(guān)于ScrollView List 滾動(dòng)判斷的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- Flutter Widgets粘合劑CustomScrollView NestedScrollView滾動(dòng)控件
- scrollview tableView嵌套解決方案示例
- UIScrollView實(shí)現(xiàn)六棱柱圖片瀏覽效果
- Android使用NestedScrollView?內(nèi)嵌RecycleView滑動(dòng)沖突問(wèn)題解決
- Android超詳細(xì)講解組件ScrollView的使用
- Flutter滾動(dòng)組件之SingleChildScrollView使用詳解
- 一行代碼教你解決Scrollview和TextInput焦點(diǎn)獲取問(wèn)題
相關(guān)文章
用SwiftUI實(shí)現(xiàn)3D Scroll滾動(dòng)效果的實(shí)現(xiàn)代碼
這篇文章主要介紹了用SwiftUI實(shí)現(xiàn)3D Scroll效果的實(shí)現(xiàn)代碼,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)2020-04-04Swift實(shí)現(xiàn)簡(jiǎn)易計(jì)算器功能
這篇文章主要為大家詳細(xì)介紹了Swift實(shí)現(xiàn)簡(jiǎn)易計(jì)算器功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-01-01Swift map和filter函數(shù)原型基礎(chǔ)示例
這篇文章主要為大家介紹了Swift map和filter函數(shù)原型基礎(chǔ)示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-07-07RxSwift發(fā)送及訂閱 Subjects、Variables代碼示例
這篇文章主要介紹了RxSwift發(fā)送及訂閱 Subjects、Variables代碼示例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-12-12swift中自定義正則表達(dá)式運(yùn)算符=~詳解
這篇文章主要給大家介紹了關(guān)于swift中自定義正則表達(dá)式運(yùn)算符=~的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧。2017-12-12Swift?Error重構(gòu)的基礎(chǔ)示例詳解
這篇文章主要為大家介紹了Swift?Error基礎(chǔ)錯(cuò)誤處理的方法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-11-11