在線搜索“SwiftUI ScrollView offset”會(huì)得到很多關(guān)于如何控制ScrollView滾動(dòng)位置的討論:隨著iOS 14的發(fā)布,SwiftUI新增了ScrollViewReader.
這是否意味著我們不再需要ScrollView偏移量offset?
在本文中,我們將探討如何獲得offset偏移量以及它的一些用途.
ScrollView offset
類似UIScrollView,ScrollView由兩個(gè)layer組成:
-
frame layer在視圖結(jié)構(gòu)中定位ScrollView -
content layer所有子組件存放的容器
如果我們查看一個(gè)垂直滾動(dòng)視圖(本文將使用這個(gè)視圖),則偏移量表示frame layer層的y坐標(biāo)的最小值與content layer內(nèi)容層的y坐標(biāo)的最小值之間的差值。
獲取 offset
SwiftUI的ScrollView初始化方法:
public struct ScrollView<Content: View>: View {
...
public init(
_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
@ViewBuilder content: () -> Content
)
}
除了content視圖構(gòu)建器之外,我們沒有什么可以使用的.
讓我們創(chuàng)建一個(gè)簡(jiǎn)單的ScrollView的例子,用一些Text文本填充:
ScrollView {
Text("A")
Text("B")
Text("C")
}
偏移量將與內(nèi)容中第一個(gè)元素Text("A")的偏移量相同,我們?nèi)绾蔚玫竭@個(gè)元素的偏移量?
再一次,我們需要用到SwiftUI的GeometryReader,以及一個(gè)新的PreferenceKey。
首先,讓我們定義preference key:
private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
其次,我們?yōu)橐晥D的.background修飾器添加GeometryReader:
ScrollView {
Text("A")
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .local).minY
)
}
)
Text("B")
Text("C")
}
geometry reader就像我們?cè)?a href="http://www.itdecent.cn/p/bb7005502299" target="_blank">SwiftUI:GeometryReader中看到的一樣,是用來分享視圖層次結(jié)構(gòu)中元素的信息:我們使用它來提取視圖的y坐標(biāo)的最小值,計(jì)算出偏移量。
然而它并不能正常執(zhí)行:
我們正在為局部坐標(biāo)空間中的框架查詢GeometryProxy,該空間是我們的.background背景視圖中建議的空間。
簡(jiǎn)而言之,就是Color.clear的minY在.local局部坐標(biāo)一直是0.
修改為.global全局坐標(biāo),從設(shè)備屏幕的坐標(biāo)系來看是有問題的,Scrollview可以放在視圖層次結(jié)構(gòu)的任何地方,.global全局坐標(biāo)系并沒有什么幫助。
如果我們把GeometryReader放在Text("A")上面會(huì)發(fā)生什么?
ScrollView {
GeometryReader { proxy in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .local).minY
)
}
Text("A")
Text("B")
Text("C")
}
這可能看起來更有希望,但它仍然不會(huì)工作:
在這種情況下,.local的坐標(biāo)系是ScrollView的content layer,但是我們需要把它顯示在ScrollView的frame layer。
根據(jù)我們的ScrollView的frame layer獲得到GeometryProxy,我們需要在ScrollView上定義一個(gè)新的坐標(biāo)空間,并在GeometryReader中引用它:
ScrollView {
Text("A")
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).minY
)
}
)
Text("B")
Text("C")
}
.coordinateSpace(name: "frameLayer") // the new coordinate space!
這是可行的,因?yàn)?code>ScrollView把frame layer暴露在的外層?,F(xiàn)在正確的ScrollView的offset偏移量在視圖層次結(jié)構(gòu)中可用。
func offset(_ proxy:GeometryProxy) -> some View {
let minY = proxy.frame(in: .named("frameLayer")).minY
print("minY:\(minY)")
return Color.clear
}
var body: some View {
ScrollView {
Text("A")
.background(
GeometryReader { proxy in
self.offset(proxy)
}
)
Text("B")
Text("C")
Spacer().frame(maxWidth: .infinity)
}
.background(Color.orange)
.coordinateSpace(name: "frameLayer")
}
簡(jiǎn)單修改下,在控制臺(tái)看下結(jié)果??!
創(chuàng)建ScrollViewOffset View
我們?cè)陂_發(fā)中需要抽取封裝,可以在需要時(shí)輕松地獲得偏移量。
ScrollView接受content內(nèi)容視圖構(gòu)建器,這使得我們無法獲得該內(nèi)容的第一個(gè)元素(如果你知道方法,請(qǐng)聯(lián)系我).
我們可以申請(qǐng).background修飾器作用于整個(gè)content上,但是這并沒有考慮到content內(nèi)容本身可能是一個(gè)Group組的可能性,在這種情況下,修飾符將應(yīng)用于組的每個(gè)元素,這不是我們想要的。
一種解決方案是將geometry reader移動(dòng)到ScrollView內(nèi)容的上方,然后在實(shí)際內(nèi)容上用負(fù)的padding來隱藏它:
struct ScrollViewOffset<Content: View>: View {
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
ScrollView {
offsetReader
content()
.padding(.top, -8)
// ???? this places the real content as if our `offsetReader` was
// not there.
}
.coordinateSpace(name: "frameLayer")
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).minY
)
}
.frame(height: 0)
// this makes sure that the reader doesn't affect the content height
}
}
類似于readSize修飾器,我們也可以讓ScrollViewOffset在每次偏移量改變時(shí)觸發(fā)回調(diào)方法:
struct ScrollViewOffset<Content: View>: View {
let onOffsetChange: (CGFloat) -> Void
let content: () -> Content
init(
onOffsetChange: @escaping (CGFloat) -> Void,
@ViewBuilder content: @escaping () -> Content
) {
self.onOffsetChange = onOffsetChange
self.content = content
}
var body: some View {
ScrollView {
offsetReader
content()
.padding(.top, -8)
}
.coordinateSpace(name: "frameLayer")
.onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).minY
)
}
.frame(height: 0)
}
}
然后我們就可以這樣使用:
ScrollViewOffset { offset in
print("New ScrollView offset: \(offset)")
} content: {
Text("A")
Text("B")
Text("C")
}
用法
現(xiàn)在我們有了這個(gè)強(qiáng)大的組件,就可以做我們要做的了。
最常見的用法可能是在滾動(dòng)時(shí)改變頂部安全區(qū)域的顏色:

struct ContentView: View {
@State private var scrollOffset: CGFloat = .zero
var body: some View {
ZStack {
scrollView
statusBarView
}
}
var scrollView: some View {
ScrollViewOffset {
scrollOffset = $0
} content: {
LazyVStack {
ForEach(0..<100) { index in
Text("\(index)")
}
}
}
}
var statusBarView: some View {
GeometryReader { geometry in
Color.red
.opacity(opacity)
.frame(height: geometry.safeAreaInsets.top, alignment: .top)
.edgesIgnoringSafeArea(.top)
}
}
var opacity: Double {
switch scrollOffset {
case -100...0:
return Double(-scrollOffset) / 100.0
case ...(-100):
return 1
default:
return 0
}
}
}
這是一個(gè)基于滾動(dòng)位置改變背景顏色的視圖:

struct ContentView: View {
@State var scrollOffset: CGFloat = .zero
var body: some View {
ZStack {
backgroundColor
scrollView
}
}
var backgroundColor: some View {
Color(
// This number determines how fast the color changes ????
hue: Double(abs(scrollOffset.truncatingRemainder(dividingBy: 3500))) / 3500,
saturation: 1,
brightness: 1
)
.ignoresSafeArea()
}
var scrollView: some View {
ScrollViewOffset {
scrollOffset = $0
} content: {
LazyVStack(spacing: 8) {
ForEach(0..<100) { index in
Text("\(index)")
.font(.title)
}
}
}
}
}
truncatingRemainder(dividingBy:)
浮點(diǎn)數(shù)取余:商取整數(shù),余數(shù)還是浮點(diǎn)數(shù)
類似整型的%,
let value1 = 5.5
let value2 = 2.2
let div = value1.truncatingRemainder(dividingBy: value2)
//div=1.1
//即商是2,余數(shù)為1.1。
iOS13 vs iOS14
我們?cè)趇os14上看到的一切都很好,但是在ios13上,最初的偏移量是不同的。
在iOS13中,偏移量考慮了頂部安全區(qū)域:例如,嵌入大標(biāo)題的NavigationView中的ScrollViewOffset的初始偏移量為140,iOS14中的相同視圖的初始(正確)偏移量值為0。
這點(diǎn)是需要特別注意的?。?!
結(jié)論
有了ScrollViewReader,在大多數(shù)用例中,我們不再需要訪問ScrollView偏移量:對(duì)于其余的用例,GeometryReader都是可以做到的.