判斷?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ī)非常類(lèi)似,只有在值發(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 )這類(lèi)的組合,只需為可滾動(dòng)視圖添加一個(gè) scrollSensor 即可。對(duì)于 List、ScrollView + LazyVStack( LazyHStack )這類(lèi)的組合,需要為每個(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)不再依賴(lài) 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)文章
Swift實(shí)現(xiàn)JSON轉(zhuǎn)Model的方法及HandyJSON使用講解
這篇文章給大家介紹了Swift實(shí)現(xiàn)JSON轉(zhuǎn)Model的方法及HandyJSON使用講解,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友參考下吧2017-07-07
Combine中錯(cuò)誤處理和Scheduler使用詳解
這篇文章主要為大家介紹了Combine中錯(cuò)誤處理和Scheduler使用示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12
Swift 使用 Observe 監(jiān)測(cè)頁(yè)面滾動(dòng)的實(shí)現(xiàn)方法
這篇文章主要介紹了Swift 使用 Observe 監(jiān)測(cè)頁(yè)面滾動(dòng)的實(shí)現(xiàn)方法,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-05-05
Ubuntu 16.04上安裝 Swift 3.0及問(wèn)題解答
本文給大家分享的是在Ubuntu系統(tǒng)中安裝 Swift 3.0的方法和步驟,以及安裝過(guò)程中有可能遇到的問(wèn)題的解答,這里推薦給小伙伴們,希望大家能夠喜歡2016-07-07
本地推送通知UserNotifications在Swift中的實(shí)現(xiàn)方式
這篇文章主要介紹了本地推送通知UserNotifications在Swift中的實(shí)現(xiàn)方式,想了解消息推送的同學(xué),一定要看一下2021-04-04
LeetCode?刷題?Swift?兩個(gè)數(shù)組的交集
這篇文章主要為大家介紹了LeetCode?刷題?Swift?兩個(gè)數(shù)組的交集示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09
Swift UILable 設(shè)置內(nèi)邊距實(shí)例代碼
本文主要介紹Swift UILable 設(shè)置內(nèi)邊距,這里提供示例代碼供大家參考,有需要的小伙伴可以看下2016-07-07

