SwiftUI 是一個新宣告式框架,讓開發者創建使用者界面 (User Interface),這個框架實在太強大了。有了實時預覽,我們可以即時完成很多事情,但有時,要在 SwiftUI 複製一些 UIKit 常見的東西卻很困難,例如 ScrollView 偏移值 (offset)!
在 UIKit 中,每個 UIScrollView
都有一個屬性 (property),讓我們可以容易地讀取視圖本身的偏移值:
var contentOffset: CGPoint { get set }
這會回傳一個帶有 x
和 y
值的結構,非常簡單又方便!
遺憾的是,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,0
的 CGPoint
。
然後,讓我們如此建立 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
的所有內容。如定義中所述,結構 T
是 View
型別的。
現在,讓我們來看看如何實作 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 頻道觀看:
祝大家編程快樂!