SwiftUI 框架

SwiftUI 小技巧:在 ScrollView 實時計算 Scroll 偏移值

在 UIKit 中,每個 UIScrollView 都有一個屬性,讓我們可以容易地讀取視圖本身的偏移值 (offset)。遺憾的是,SwiftUI 到目前為止還是缺少了這個簡單的屬性。在這篇文章中,Alessandro 會帶大家實作一個非常簡單的 UI,來顯示實時顯示 ScrollView 偏移值。
SwiftUI 小技巧:在 ScrollView 實時計算 Scroll 偏移值
SwiftUI 小技巧:在 ScrollView 實時計算 Scroll 偏移值
In: SwiftUI 框架
本篇原文(標題:SwiftUI: Calculate Scroll Offset in ScrollViews)刊登於作者 Medium,由 Alessandro Manilii 所著,並授權翻譯及轉載。

SwiftUI 是一個新宣告式框架,讓開發者創建使用者界面 (User Interface),這個框架實在太強大了。有了實時預覽,我們可以即時完成很多事情,但有時,要在 SwiftUI 複製一些 UIKit 常見的東西卻很困難,例如 ScrollView 偏移值 (offset)!

在 UIKit 中,每個 UIScrollView 都有一個屬性 (property),讓我們可以容易地讀取視圖本身的偏移值:

var contentOffset: CGPoint { get set }

這會回傳一個帶有 xy 值的結構,非常簡單又方便!

遺憾的是,SwiftUI 到目前為止還是缺少了這個簡單的屬性。所以,我們就需要自己想辦法來獲得這個數值。

跟著這篇文章實作,在文章完結的時候,我們會創建出以下的成品:

SwiftUI 框架經常允許(或逼迫)我們跳出框架去思考解決方法,這正是一個這樣做的好機會。

讓我們先構建一個非常簡單的 UI,當中有一個長列表、和一個現在還無法顯示實際偏移值的 Text 標籤,你會發現 verticalOffset 變數一直都不會改變(我們稍後會添加這個功能):

struct ContentView: View {
    
    @State private var verticalOffset: CGFloat = 0.0
    
    var body: some View {
        
        VStack {
            Text("Offset: \(String(format: "%.2f", verticalOffset))")
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.yellow)
            ScrollView {
                LazyVStack {
                    ForEach(0..<200) { index in
                        Text("Row number \(index)")
                            .padding()
                    }
                }
            }
        }
    }
}

要實作上圖的結果,我們需要編寫一個 View,它的行為與 SwiftUI ScrollView 完全相同,但會以某種方式實時顯示偏移值。

首先,讓我們建立一個遵從 PreferenceKey 協定的結構。

根據 Apple 官方文件,這個協定的定義如下:

視圖創建的數值,而視圖有多個子視圖,會自動將設定值合併為父類視圖可見的條件。

這個說法比較複雜;簡單來說,就是我們允許視圖與其父類視圖對話及傳遞數值。

要遵從這個協定,我們必須實作一個靜態屬性和一個靜態函數:

static var defaultValue: Self.Value { get }
static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)

預設值就是我們的偏移值,是一個起始值為 0,0CGPoint

然後,讓我們如此建立 OffsetPreferenceKey 結構:

private struct OffsetPreferenceKey: PreferenceKey {
    
    static var defaultValue: CGPoint = .zero
    
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}

現在可以創建我們的 scrollView 了。讓我們創建一個結構,它除了與 SwiftUI 的 ScrollView 有同一個屬性之外,還有一個 onOffsetChanged 閉包,在 scrollview 修改其位置時就會被觸發:

struct OffsettableScrollView<T: View>: View {

    let axes: Axis.Set
    let showsIndicator: Bool
    let onOffsetChanged: (CGPoint) -> Void
    let content: T
    
    init(axes: Axis.Set = .vertical,
         showsIndicator: Bool = true,
         onOffsetChanged: @escaping (CGPoint) -> Void = { _ in },
         @ViewBuilder content: () -> T
    ) {
        self.axes = axes
        self.showsIndicator = showsIndicator
        self.onOffsetChanged = onOffsetChanged
        self.content = content()
    }

  // var body to come...
}

從上面的程式碼可見,我使用了一個泛型變數 (generic var),來傳遞 ScrollView 的所有內容。如定義中所述,結構 TView 型別的。

現在,讓我們來看看如何實作 body 屬性:

var body: some View {
        ScrollView(axes, showsIndicators: showsIndicator) {
            GeometryReader { proxy in
                Color.clear.preference(
                    key: OffsetPreferenceKey.self,
                    value: proxy.frame(
                        in: .named("ScrollViewOrigin")
                    ).origin
                )
            }
            .frame(width: 0, height: 0)
            content
        }
        .coordinateSpace(name: "ScrollViewOrigin")
        .onPreferenceChange(OffsetPreferenceKey.self,
                            perform: onOffsetChanged)
}
  • 在第 2 行,我創建了一個 ScrollView
  • 在第 3 行,我創建了一個 GeometryReader,當中包含了一個沒有維度的空白視圖 (empty view) Color.Clear。因為我們需要追蹤視圖的位置,所以使用沒有維度的視圖就更適合了。在視圖裡面,我設置了 OffsetPreferenceKey 來傳遞 frame 本身的原點 (origin)。我使用了 coordinateSpace(name:),來讓另一個函數在 Color 視圖上進行尋找或操作,及在視圖相應的維度上操作。
  • 在第 15 行,當偏移值改變時,原點位置就會被傳遞出去,並觸發閉包。
  • 在第 12 行,我使用了初始化器 (initializer) 上傳遞的 content

以下是結構的完整程式碼:

struct OffsettableScrollView<T: View>: View {
    let axes: Axis.Set
    let showsIndicator: Bool
    let onOffsetChanged: (CGPoint) -> Void
    let content: T
    
    init(axes: Axis.Set = .vertical,
         showsIndicator: Bool = true,
         onOffsetChanged: @escaping (CGPoint) -> Void = { _ in },
         @ViewBuilder content: () -> T
    ) {
        self.axes = axes
        self.showsIndicator = showsIndicator
        self.onOffsetChanged = onOffsetChanged
        self.content = content()
    }
    
    var body: some View {
        ScrollView(axes, showsIndicators: showsIndicator) {
            GeometryReader { proxy in
                Color.clear.preference(
                    key: OffsetPreferenceKey.self,
                    value: proxy.frame(
                        in: .named("ScrollViewOrigin")
                    ).origin
                )
            }
            .frame(width: 0, height: 0)
            content
        }
        .coordinateSpace(name: "ScrollViewOrigin")
        .onPreferenceChange(OffsetPreferenceKey.self,
                            perform: onOffsetChanged)
    }
}

完成了!我們這個全新的 OffsettableScrollView 就可以隨時傳遞偏移值了!

現在讓我們加入連接好的 Text,來修改原本的內容視圖吧:

struct ContentView: View {
    
    @State private var verticalOffset: CGFloat = 0.0
    
    var body: some View {
        
        VStack {
            Text("Offset: \(String(format: "%.2f", verticalOffset))")
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.yellow)
            
            OffsettableScrollView { point in
                verticalOffset = point.y
            } content: {
                LazyVStack {
                    ForEach(0..<200) { index in
                        Text("Row number \(index)")
                            .padding()
                    }
                }
            }
        }
    }
}

大功告成了!你可以看到程式碼非常簡單清晰!

希望你喜歡這篇文章。如果你想看看這篇文章的教學影片,可以到我的 YouTube 頻道觀看:

祝大家編程快樂!

本篇原文(標題:SwiftUI: Calculate Scroll Offset in ScrollViews)刊登於作者 Medium,由 Alessandro Manilii 所著,並授權翻譯及轉載。
作者簡介:Alessandro Manilii,一位意大利的專業 iOS 開發者,Wakala 的 iOS 技術主管。如果你有興趣閱讀我的其他文章,可以在 Medium 上訂閱。
譯者簡介:Kelly Chan-AppCoda 編輯小姐。

作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。