日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學無先后,達者為師

網站首頁 編程語言 正文

判斷?ScrollView?List?是否正在滾動詳解_Swift

作者:東坡肘子 ? 更新時間: 2022-11-05 編程語言

正文

判斷一個可滾動控件( 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/

欄目分類
最近更新