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 中文版
如何在 SwiftUI App 中開發 Live Activities
SwiftUI 框架

如何在 SwiftUI App 中開發 Live Activities

Live Activities 首次於 iOS 16 推出,是 Apple 最令人興奮的更新之一,能讓 App 與使用者在即時互動上更有連結。它不再需要使用者不斷打開 App,Live Activities 可以讓資訊直接顯示在鎖定畫面和 Dynamic Island 上。
使用 Tool Calling 強化 Foundation Models 功能
AI

使用 Tool Calling 強化 Foundation Models 功能

在前幾篇教學中,我們介紹了 Foundation Models 在 iOS 26 中的運作方式,以及如何使用這個全新框架打造具備 AI 功能的應用。我們也介紹了 @Generable 巨集,它能輕鬆地將模型回應轉換為結構化的 Swift 類型。 現在,在這個 Foundation
活用 Foundation Models 的 @Generable 與 @Guide 製作測驗 App
AI

活用 Foundation Models 的 @Generable 與 @Guide 製作測驗 App

在前一篇教學中,我們介紹了 Foundation Models 框架,並示範了如何用它來進行基本的內容生成。那個過程相當簡單——你提供一個提示詞(prompt),等幾秒鐘,就能獲得自然語言的回應。在我們的範例中,我們建立了一個簡單的問答 App,讓使用者可以提問,App 則直接顯示生成的文字。 但如果回應變得更複雜——你需要把非結構化文字轉換為結構化的物件呢? 舉例來說,
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。