文章源地址:https://swiftui-lab.com/geometryreader-to-the-rescue/
作者: Javier
翻譯: Liaoworking
探究View樹 part-1 PreferenceKey
在SwiftUI中我們一般不用關(guān)心子級視圖內(nèi)部發(fā)生了什么微宝。不同的View各自管各自內(nèi)部的事情贾费。但總是會遇到一些特殊的需求。比較慘的是文檔都講的比較粗略实牡。 探究View樹的三篇文章會做個補充斋荞。我們將要去了解 PreferenceKey 的協(xié)議和相關(guān)的修改器(modifier
):如
.preference(),
.transformPreference(),
.anchorPreference(),
.transformAnchorPreference(),
.onPreferenceChange(),
.backgroundPreferenceValue()
.overlayPreferenceValue().
有很多荞雏,那我們開始吧~
SwiftUI有一個讓我們?nèi)ソoView添加很多屬性的機制。這些屬性我們叫做 偏好(Preferences) 平酿。 它們可以輕松的沿視圖依次調(diào)用下去凤优,甚至無論怎么修改偏好,添加的回調(diào)都會不受影響的執(zhí)行蜈彼。
有沒有想過navigationView是如何通過 .navigationBarTitle() 來獲取title筑辨。請注意 .navigationBarTitle() 并沒有直接修改NavigationView。而是在沿著View的層級去調(diào)用幸逆。那么它是怎么做到的呢挖垛? 可能你已經(jīng)猜到了。其實是用了偏好秉颗。在2019WWDC的SwiftUI專欄里有一個很簡短的介紹痢毒。大概只有20秒。感興趣的話可以查看Session 216 (SwiftUI Essentials)直接跳到52:35蚕甥。
我們已經(jīng)找到有一些特殊的偏好 叫"anchored preferences(錨定偏好)"哪替, 可以利用它們來方便的檢索子級View的所有幾何學(xué)數(shù)據(jù)。在下中會詳細介紹錨定偏好(anchored preferences)
獨立的Views
我們將會用很短的時間去了解 PreferenceKey 菇怀,為了更好的了解今天的話題凭舶,我們先用一個沒有使用偏好的例子開始。在例子中爱沟,先創(chuàng)建一個顯示月份名的View帅霜。當(dāng)月份標簽被點擊的時候,會在月份標簽上面慢慢的顯示一個邊框(從之前選中的月份標簽移除)呼伸。
例1
代碼很簡單身冀,先創(chuàng)建我們的ContentView:
import SwiftUI
struct EasyExample : View {
@State private var activeIdx: Int = 0
var body: some View {
VStack {
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "January", idx: 0)
MonthView(activeMonth: $activeIdx, label: "February", idx: 1)
MonthView(activeMonth: $activeIdx, label: "March", idx: 2)
MonthView(activeMonth: $activeIdx, label: "April", idx: 3)
}
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "May", idx: 4)
MonthView(activeMonth: $activeIdx, label: "June", idx: 5)
MonthView(activeMonth: $activeIdx, label: "July", idx: 6)
MonthView(activeMonth: $activeIdx, label: "August", idx: 7)
}
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "September", idx: 8)
MonthView(activeMonth: $activeIdx, label: "October", idx: 9)
MonthView(activeMonth: $activeIdx, label: "November", idx: 10)
MonthView(activeMonth: $activeIdx, label: "December", idx: 11)
}
Spacer()
}
}
}
和自定義views:
struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.onTapGesture { self.activeMonth = self.idx }
.background(MonthBorder(show: activeMonth == idx))
}
}
struct MonthBorder: View {
let show: Bool
var body: some View {
RoundedRectangle(cornerRadius: 15)
.stroke(lineWidth: 3.0).foregroundColor(show ? Color.red : Color.clear)
.animation(.easeInOut(duration: 0.6))
}
}
代碼邏輯也很簡單,當(dāng)月份標簽被點擊括享,改變 @State
為最新點擊的月份標簽的序號搂根。 而且每個月份邊框的顏色都由自己的變量來控制。 如果月份標簽被選中铃辖,邊框會被設(shè)置成紅色剩愧,否則邊框就會變透明。這個例子很簡答娇斩,每個View繪制自己的邊框仁卷。
相互協(xié)作的Views
下面難度再升級一些穴翩,我們想讓邊框從一個月份移動到另外一個。
例子2
你可以先想想如何去實現(xiàn)锦积,不像之前有12個邊框藏否,現(xiàn)在只有一個邊框,你需要動畫改變邊框的位置和大小充包。
例子2中副签,邊框并不是月份的一部分,你需要創(chuàng)建一個單獨的邊框View基矮,并相應(yīng)的改變位置和大小淆储,這意味著必須有一種方式去跟蹤每個月份的大小和位置。
如果你看過我上一批文章(GeometryReader to the Rescue),
你就已經(jīng)有一種方式去解決這個問題了家浇,如果你不知道GeometryReader是怎么工作的本砰,可以先看看這篇文章。
解決這個問題的一種方式就是: 每一個月份標簽都通過GeometryReader去獲得自身的大小和位置钢悲。每個月份標簽依次更新父級視圖中的存放位置的數(shù)組(通過 @Binding )点额。 一旦父級視圖找到了每一個子視圖的位置和大小,邊框就可以很容易的替換了莺琳。這個方案還不錯还棱,但子級視圖修改數(shù)組的時候可能會產(chǎn)生問題。
對于某些布局惭等,如果在構(gòu)建視圖的時候珍手,修改其某個變量,其父級視圖也會受到影響辞做,反過來子級視圖也會受到影響琳要。這使我們正在構(gòu)建的視圖失效,有時可能需要再重新開始構(gòu)建視圖秤茅。 還有時候會變成一個循環(huán)稚补。好的是SwiftUI視乎可以檢測到這種情況,也不會產(chǎn)生崩潰框喳。它會給你一個運行時的警告: Modifying state during view update(當(dāng)視圖更新的時候修改視圖). 快速修復(fù)這個問題的方法是延遲變量的改變课幕,直到視圖的更新完成:
DispatchQueue.main.async {
self.rects[k] = rect
}
不過這好像有點取巧(hack), 雖然這起作用了,但只是一個暫時的解決方案帖努。不確定以后會不會起作用撰豺。 有點對框架底層的原理下賭注的意思了。幸運的是 PreferenceKey 可以解決拼余。
PreferenceKey的介紹
SwiftUI 提供給我們一個修改器讓我們添加一些數(shù)據(jù)到某個具體的視圖。我們可以通過頂級視圖(ancestor view)查詢這些數(shù)據(jù)亩歹。并且有多種方式去讀取PreferenceKey匙监。這取決于你的目的是怎樣的凡橱。無論怎樣,偏好似乎就是我們想要的亭姥,那我們先試試來解決我們的問題稼钩。
我們可以通過下面的例子來知道通過preferences來暴露哪些信息。
1.去標記一些view达罗,這里我們通過Int值0..11去標記坝撑,其實你可以用任何值都可以標記的。
2.獲取文本框的CGRect.
我們先命名一個遵守 Equatable 協(xié)議的MyTextPreferenceData的結(jié)構(gòu)體粮揉。
struct MyTextPreferenceData: Equatable {
let viewIdx: Int
let rect: CGRect
}
然后我們定義一個遵循 PreferenceKey 的結(jié)構(gòu)體MyTextPreferenceKey巡李。
struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]
static var defaultValue: [MyTextPreferenceData] = []
static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
我強烈建議你閱讀一些PreferenceKey的文檔,遵守協(xié)議后你必須要實現(xiàn)如下:
- value 我們想要通過PreferenceKey獲得什么類型的一個別名扶认,例子中我們用的是[MyTextPreferenceData]數(shù)組侨拦。
- defaultValue 沒有顯式設(shè)置首選項時,SwiftUI會用這個默認值辐宾。
- reduce 用來覆蓋在視圖樹中找到的所有鍵值對狱从,是一個靜態(tài)函數(shù)。通常你可以用來累加接收到的所有值叠纹。在我們的例子中季研,當(dāng)SwiftUI遍歷視圖樹時,會把所有preference鍵值對存儲在一個數(shù)組中誉察。下面我們會講训貌。你應(yīng)該清楚 值是按照視圖樹的順序給reduce函數(shù)的 我們會在另外一個例子中討論。
我們現(xiàn)在有了 PreferenceKey 了冒窍,開始對之前的代碼就行修改递沪。
先修改MonthView, 通過GeometryReader來獲取文字的大小和位置综液,這些值需要轉(zhuǎn)換一下坐標系款慨,才能繪制出正確的邊框。視圖可以通過修改器來命名它們的空間坐標系 .coordinateSpace(name: "name")
谬莹。 一旦我們轉(zhuǎn)換了rect檩奠,我們也要相應(yīng)的設(shè)置preference
struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.background(MyPreferenceViewSetter(idx: idx)).onTapGesture { self.activeMonth = self.idx }
}
}
struct MyPreferenceViewSetter: View {
let idx: Int
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: MyTextPreferenceKey.self,
value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
}
}
}
然后,我們創(chuàng)建一個單獨的邊框視圖附帽,該視圖將更改其偏移量和frame以匹配與最后點擊的視圖相對應(yīng)的矩形:
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
.animation(.easeInOut(duration: 1.0))
最后埠戳,我們只要保證當(dāng)preferences改變的時候,我們相應(yīng)的關(guān)系rect數(shù)組蕉扮。 例如當(dāng)設(shè)備旋轉(zhuǎn)整胃,或者window的大小改變, 下面的代碼都會被調(diào)用:
.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
for p in preferences {
self.rects[p.viewIdx] = p.rect
}
}
下面是完整的代碼:
import SwiftUI
struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]
static var defaultValue: [MyTextPreferenceData] = []
static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
struct MyTextPreferenceData: Equatable {
let viewIdx: Int
let rect: CGRect
}
struct ContentView : View {
@State private var activeIdx: Int = 0
@State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 12)
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
.animation(.easeInOut(duration: 1.0))
VStack {
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "January", idx: 0)
MonthView(activeMonth: $activeIdx, label: "February", idx: 1)
MonthView(activeMonth: $activeIdx, label: "March", idx: 2)
MonthView(activeMonth: $activeIdx, label: "April", idx: 3)
}
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "May", idx: 4)
MonthView(activeMonth: $activeIdx, label: "June", idx: 5)
MonthView(activeMonth: $activeIdx, label: "July", idx: 6)
MonthView(activeMonth: $activeIdx, label: "August", idx: 7)
}
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "September", idx: 8)
MonthView(activeMonth: $activeIdx, label: "October", idx: 9)
MonthView(activeMonth: $activeIdx, label: "November", idx: 10)
MonthView(activeMonth: $activeIdx, label: "December", idx: 11)
}
Spacer()
}.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
for p in preferences {
self.rects[p.viewIdx] = p.rect
}
}
}.coordinateSpace(name: "myZstack")
}
}
struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.background(MyPreferenceViewSetter(idx: idx)).onTapGesture { self.activeMonth = self.idx }
}
}
struct MyPreferenceViewSetter: View {
let idx: Int
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: MyTextPreferenceKey.self,
value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
}
}
}
明智地使用Preferences(首選項)
當(dāng)我們使用preferences喳钟,可能會使用子級視圖的幾何信息來布局它們的一個頂層視圖(ancestors)屁使,如果是這樣的話在岂,你應(yīng)該注意。 如果頂層視圖影響了子級視圖的布局蛮寂,反過來子級視圖也會影響頂層視圖蔽午,就會陷入一個遞歸循環(huán)中。
可能有時候程序會卡死酬蹋,或者屏幕會閃動來持續(xù)的重新繪制及老。或者CPU會達到一個峰值范抓,這些都會暗示你錯誤的使用了preferences骄恶。
例如你在VStack中有兩個視圖,上面的視圖高度依據(jù)下面視圖的y值尉咕。 可能就會給你帶來循環(huán)叠蝇。
為了解決這個問題,用一些布局工具使得頂層視圖不要影響子級視圖年缎,一些好的方案就是: ZStack, .overlay(), .background()
或者幾何影響(geometry effects).
我們將在即將發(fā)布的文章中去討論 幾何影響 (GeometryEffect)
下一步是什么
這篇文章中我們通過GeometryReader來“竊取”了月份標簽中的幾何信息悔捶,然而我們可以通過錨定的偏好(Anchor Preferences)來更好的實現(xiàn)它。 在下面的文章中我們將繼續(xù)學(xué)習(xí)它单芜。而且我們將深入究竟SwiftUI是怎樣遍歷樹的蜕该。其實也可以不通過.onPreferenceChange()
來使用preferences。下篇文章中也有講解洲鸠。
當(dāng)你一開始去使用preferences的時候堂淡,可能你的代碼又亂又難閱讀。我覺得你應(yīng)該在View的extension中封裝好preferences扒腕,我之前寫過的一篇文章有講過怎么去做绢淀。你可以查看讓你代碼變的更好的View extension。