[SwiftUI-Lab] 探究View樹 part-1 PreferenceKey

文章源地址: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)月份標簽被點擊的時候,會在月份標簽上面慢慢的顯示一個邊框(從之前選中的月份標簽移除)呼伸。

image

例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

下面難度再升級一些穴翩,我們想讓邊框從一個月份移動到另外一個。


image

例子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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末瘾腰,一起剝皮案震驚了整個濱河市皆的,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蹋盆,老刑警劉巖费薄,帶你破解...
    沈念sama閱讀 221,406評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異栖雾,居然都是意外死亡楞抡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,395評論 3 398
  • 文/潘曉璐 我一進店門析藕,熙熙樓的掌柜王于貴愁眉苦臉地迎上來召廷,“玉大人,你說我怎么就攤上這事≈簦” “怎么了数初?”我有些...
    開封第一講書人閱讀 167,815評論 0 360
  • 文/不壞的土叔 我叫張陵找爱,是天一觀的道長梗顺。 經(jīng)常有香客問我,道長车摄,這世上最難降的妖魔是什么寺谤? 我笑而不...
    開封第一講書人閱讀 59,537評論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮吮播,結(jié)果婚禮上变屁,老公的妹妹穿的比我還像新娘。我一直安慰自己意狠,他們只是感情好粟关,可當(dāng)我...
    茶點故事閱讀 68,536評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著环戈,像睡著了一般闷板。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上院塞,一...
    開封第一講書人閱讀 52,184評論 1 308
  • 那天遮晚,我揣著相機與錄音,去河邊找鬼拦止。 笑死县遣,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的汹族。 我是一名探鬼主播萧求,決...
    沈念sama閱讀 40,776評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼顶瞒!你這毒婦竟也來了夸政?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,668評論 0 276
  • 序言:老撾萬榮一對情侶失蹤搁拙,失蹤者是張志新(化名)和其女友劉穎秒梳,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體箕速,經(jīng)...
    沈念sama閱讀 46,212評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡酪碘,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,299評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了盐茎。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片兴垦。...
    茶點故事閱讀 40,438評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出探越,到底是詐尸還是另有隱情狡赐,我是刑警寧澤,帶...
    沈念sama閱讀 36,128評論 5 349
  • 正文 年R本政府宣布钦幔,位于F島的核電站枕屉,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏鲤氢。R本人自食惡果不足惜搀擂,卻給世界環(huán)境...
    茶點故事閱讀 41,807評論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望卷玉。 院中可真熱鬧哨颂,春花似錦、人聲如沸相种。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,279評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽寝并。三九已至箫措,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間食茎,已是汗流浹背蒂破。 一陣腳步聲響...
    開封第一講書人閱讀 33,395評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留别渔,地道東北人附迷。 一個月前我還...
    沈念sama閱讀 48,827評論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像哎媚,于是被迫代替她去往敵國和親喇伯。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,446評論 2 359

推薦閱讀更多精彩內(nèi)容