SwiftUI 布局協(xié)議 - Part2
前言
在 Part 1 我們探索了布局協(xié)議的基礎(chǔ)知識,為理解布局是如何工作的打下了堅(jiān)實(shí)的基礎(chǔ)?,F(xiàn)在,是時候深入研究那些更少提及的功能了,以及如何使用它們來為我們帶來便利。
Part 1 - 基礎(chǔ):
- 什么是布局協(xié)議
- 視圖層次結(jié)構(gòu)的族動態(tài)
- 我們的第一個布局實(shí)現(xiàn)
- 容器對齊
- 自定義值:LayoutValueKey
- 默認(rèn)間距
- 布局屬性和Spacer()
- 布局緩存
- 高明的偽裝者
- 使用AnyLayout 切換布局
- 結(jié)語
Part 2 - 高級布局:
- 前言
- 自定義動畫
- 雙向自定義值
- 避免布局循環(huán)和崩潰
- 遞歸布局
- 布局組合
- 插入兩個布局
- 使用綁定參數(shù)
- 一個有用的調(diào)試工具
- 最后的思考
自定義動畫
讓我們從寫一個圓形布局的視圖容器開始吧。我們將它叫做 WheelLayout:
當(dāng)布局發(fā)生改變時,SwfitUI 提供了內(nèi)置動畫支持。所以如果我們將輪子的旋轉(zhuǎn)值更改為90度,我們將會看見它是如何逐漸的移動到新的位置上:
這非常好我本可以在此結(jié)束動畫部分。但是,你已經(jīng)知道我們的博客不滿足于膚淺的表面,所以讓我們深層次的看看到底發(fā)生了什么。
當(dāng)我們改變角度時,SwiftUI 會計(jì)算好每個視圖最初和最終的位置,然后在動畫期間內(nèi)修改它們的位置,從A點(diǎn)到B點(diǎn)成一條直線。起初它似乎沒有這樣做,但是檢查下面這個動畫,集中注意觀察單個視圖,看看它們是如何都跟隨直虛線移動的?
你有想過如果動畫的角度是從0到360會發(fā)生什么嗎?給你一分鐘... 對!...什么都不會發(fā)生。開始的位置和結(jié)束的位置是一樣的,因此就 SwiftUI 而言,沒有動畫。
如果這就是你要找的東西,那就太好了,但由于我們將視圖圍繞一個圓圈放置,如果視圖沿著那個假想的圓圈移動不是更有意義嗎?好吧,事實(shí)證明,這樣做非常容易!
我們問題的答案是很幸運(yùn)的,這個布局協(xié)議采用 Animatable 協(xié)議!如果你不知道或者忘記這是什么,我建議你查看 SwiftUI 布局協(xié)議 - Part 1 的 Animating Shape Paths 部分 。
簡單的說,通過添加 animatableData 屬性到我們的布局,我們要求 SwiftUI 動畫的每一幀重新計(jì)算布局。但是,在每個布局傳遞中,角度都會收到一個內(nèi)插值?,F(xiàn)在 SwiftUI 不會為我們插入位置。相反,它會插入角度值。我們的布局代碼將會完成剩下的工作。
添加一個 animatableData 屬性足夠讓我們的視圖正確的跟隨圓圈。但是,既然我們已經(jīng)到了這步。。。為什么我們不讓半徑也可以動畫呢?
雙向自定義值
在文章的第一部分我們了解到如何使用 LayoutValues 將信息附加到視圖,以便它們的代理可以在 placeSubviews 和 sizeThatFits 方法中暴露這些信息。我們的想法是信息從視圖流向布局,一會兒將看見這一點(diǎn)是如何被逆轉(zhuǎn)。
本節(jié)所解釋的想法應(yīng)謹(jǐn)慎使用,以避免布局循環(huán)和 CPU 峰值。在下一部分我將會解釋原因和如何避免它。但是不用擔(dān)心,這并不復(fù)雜,你只需要遵循一些準(zhǔn)則。
讓我們回到輪子的這個例子,假設(shè)我們想要視圖旋轉(zhuǎn)起來,讓它們指向中心。
布局協(xié)議只能決定視圖位置和它們的建議尺寸,但是不能應(yīng)用樣式、旋轉(zhuǎn)或者其他的效果。如果我們想要這些效果,那么布局應(yīng)該有一種傳達(dá)回視圖的方式。這時候布局值就變得重要起來,到目前為止,我們已經(jīng)使用它們傳遞信息給布局,但只要加上一點(diǎn)創(chuàng)意,我們就可以反向使用它們。
我之前提到過的 LayoutValues 并不局限于傳遞 ??CGFloats?
? ,你可以將它用于任何事情,包括??Binding?
?,在這個例子中,我們將使用 ??Binding<Angle>?
?:
注意:我稱它為雙向自定義值,因?yàn)樾畔⑹强梢噪p向流動的,但是,這不是 SwiftUI 的官方術(shù)語,只是為了更清晰的解釋這個想法的術(shù)語。
在布局的 placeSubview 方法中,我們設(shè)置每個子視圖的角度:
回到我們的視圖,我們可以讀取值,并用它來旋轉(zhuǎn)視圖:
這段代碼將確保所有的視圖都指向圓心,但是我們可以更優(yōu)雅一點(diǎn)。我提供的解決方案需要設(shè)置一個旋轉(zhuǎn)數(shù)組,將它們作為布局值然后使用這些值旋轉(zhuǎn)視圖。如果我們可以向布局用戶隱藏這種復(fù)雜性那不是很好嗎?這里就是重寫之后的。
首先我們創(chuàng)建一個封裝視圖 WheelComponent:
然后我們擺脫旋轉(zhuǎn)數(shù)組(我們不再需要了!)并將每個視圖包裝在 WheelComponent視圖中。
就是這樣。用戶使用容器只需要記住將視圖封裝在 WheelComponent里面。他們不需要擔(dān)心布局值,綁定,角度等等。當(dāng)然,不在封裝里的視圖不會受到任何影響,視圖不會旋轉(zhuǎn)指向中心。
我們還可以添加一個改進(jìn),那就是視圖旋轉(zhuǎn)的動畫。仔細(xì)觀察并比較下面三個輪子:一個不旋轉(zhuǎn)。另外兩個旋轉(zhuǎn)指向中心,但是一個不使用動畫而另一個使用。
避免布局循環(huán)和崩潰
眾所周知我們在布局期間不能更新視圖狀態(tài)。這會導(dǎo)致不可預(yù)測的結(jié)果,很可能會使 CPU 達(dá)到峰值。在此之前我們看到過這種情況,即閉包在布局期間運(yùn)行時,也許當(dāng)時不是太明顯。但是現(xiàn)在,這是毫無疑問的。sizeThatFits 和 placeSubviews 是布局過程中的一部分。因此當(dāng)我們使用上一部分中描述的"欺騙"的技巧,我們必須使用 DispatchQueue 用隊(duì)列更新。就像上面的例子一樣:
使用雙向自定義值還有另一個潛在的問題,那就是你的視圖必須在不影響別的布局的前提下使用該值,否則的話你將會陷入布局循環(huán)。
例如,如果用 placeSubviews 設(shè)置去更改視圖顏色,那就是安全的。在案例中,可能看起來旋轉(zhuǎn)會影響布局,但其實(shí)不是這樣的,當(dāng)你旋轉(zhuǎn)視圖,它的周圍從來沒產(chǎn)生影響,邊界仍然保持不變。如果你設(shè)置了偏移,或者其他的變換矩陣,也會發(fā)生同樣的事情。但無論如何,我建議你監(jiān)測 CPU 來發(fā)現(xiàn)布局中其他潛在的問題。如果 CPU 開始飆升,或許可以在 placeSubviews 中添加一條打印語句查看它是否無休止的調(diào)用。注意動畫也會使 CPU 增長。如果你想測試你的容器是否循環(huán),不要在動畫時查看 CPU 。
注意這不是新問題。過去我們在使用 GeometryReader? 獲取視圖尺寸并傳遞值到父視圖的時候遇到過這個問題,然后父視圖使用該信息去改變視圖,即使用 GeometryReader? 去再一次改變,然后我們就陷入了布局循環(huán)。這是個老問題,我在 SwiftUI 剛發(fā)布的時候就寫過此類問題,在 Safely Updating The View State [1] 一文中可以查看更多信息。
我還想再提一下潛在的崩潰。這與雙向自定義值無關(guān)。這是你在寫任何布局都必須要考慮的。我們提到 SwiftUI? 可能會多次調(diào)用 sizeThatFits 去測試視圖的靈活性。在這些調(diào)用中,你返回的值應(yīng)該是合理的。例如,下面的代碼會崩潰:
這里 sizeThatFits 返回了 .infinity 作為最小尺寸, .zero 作為最大尺寸。這是不合理的,最小尺寸不可能大于最大尺寸!
遞歸布局
在下面這個例子中我們將探索遞歸布局。我們將會把之前的 WheelLayout 視圖轉(zhuǎn)變?yōu)?nbsp;RecursiveWheel.我們的新布局將會在圓圈里放置12個視圖。里面的12個視圖將會按比例縮小到內(nèi)圈中,直到它們不會再有別的視圖。視圖的縮放和旋轉(zhuǎn)要再一次使用雙向自定義值實(shí)現(xiàn)。
在這個例子中在容器中一共有44個視圖,所以我們的新容器將會分別以12,12,12和8為一圈。
注意本案例中如何使用緩存與子視圖通信。這是可以實(shí)現(xiàn)的,因?yàn)榫彺媸且粋€ inout 參數(shù),我們可以在 placeSubviews 中更新。
placeSubviews 方法遍歷并放置12個子視圖:
然后遞歸調(diào)用placeSubviews 但僅限于剩下的視圖,如此直到?jīng)]有別的視圖。
布局組合
在上一個例子中我們使用了相同的布局遞歸。但是,我們也可以組合一些不同布局到容器中。在下一個例子中我們將會把前三個視圖水平的放置在視圖頂部,后三個水平的放置在底部。剩下的視圖將會在中間,垂直排列。
我們不需要編寫垂直或者水平的間距邏輯代碼,因?yàn)?nbsp;SwiftUI 已經(jīng)有這樣的布局了:HStackLayout 和 VStackLayout。
只是有點(diǎn)小問題,很輕易就可以修復(fù)。由于某些原因,系統(tǒng)布局私下實(shí)現(xiàn)了 sizeThatFits 和 placeSubviews 。這意味著我們無法調(diào)用它們。但是,類型消除布局確實(shí)暴露它的所有方法,所以不要這樣做:
我們可以:
此外,在與其他視圖布局工作的時候,我們就相當(dāng)于 SwiftUI 的角色。子布局的任何緩存創(chuàng)建和更新都屬于我們的責(zé)任,幸運(yùn)的是,這都很容易處理。我們只需要添加子布局緩存到我們自己的緩存里。
插入兩個布局
另一個組合案例:插入兩個布局
下一個例子將會創(chuàng)建一個以輪子,或者波浪形式顯示視圖的布局。當(dāng)然它還提供了一個從 0.0 到1.0 的 pct 參數(shù),當(dāng) pct == 0.0,視圖將會展示輪子,當(dāng)pct == 1.0,視圖將會展示 sin波動。中間的數(shù)值將會穿插在兩者的位置之中。
在我們創(chuàng)建組合布局之前,讓我先來介紹一下 WaveLayout,這個布局有好幾個參數(shù)來讓你改變正弦波動的幅度,頻率和角度。
InterpolatedLayout 將會計(jì)算兩個布局(波動和輪子)的尺寸和位置然后它將插入這些值以進(jìn)行最終定位。注意。在 placeSubviews 方法中,如果子視圖被多次定位,最后一次調(diào)用place()
使用以下公式計(jì)算插值:
我們需要一種方法讓 WaveLayout 和 WheelLayout 將每一個視圖位置和旋轉(zhuǎn)返回給 InterpolatedLayout 。我們通過緩存做到了這一點(diǎn)。我們再一次看到了緩存不是性能提升的唯一用途。
我們也需要 WaveLayout 和 WheelLayout 去檢測它們是否被 InterpolatedLayout 使用,以便它們可以相應(yīng)的更新緩存。這些視圖可以輕易的檢測到這種情況,這要?dú)w功于獨(dú)立的緩存值,如果緩存是由 InterpolatedLayout 創(chuàng)建的,則該值僅為 false。
使用綁定參數(shù)
今年 SwfitUI Lounges 出現(xiàn)了一個有趣的問題,詢問是否可能使用新的布局協(xié)議去創(chuàng)建一個層次樹,用線連接。挑戰(zhàn)的不是視圖樹結(jié)構(gòu),而是我們?nèi)绾萎嬤B接線。
還有其它方法可以實(shí)現(xiàn)它,例如,使用 Canvas[2] ,但是我們這里都是關(guān)于布局協(xié)議的,讓我們來看看可以如何解決連接線的問題。
我們現(xiàn)在都知道,這根線不可能被布局繪制出來。那我們需要的是一種讓布局告訴視圖如何繪制線條的方法。初步想法可以(在這個問題上蘋果的工程師是這么建議的[3]) 使用布局值。這正是我們在上一個例子中做的事情,雙向自定義值。但是,仔細(xì)思考之后,還有一種更簡單的方式。
相比于使用布局值去分別通知樹的每個節(jié)點(diǎn)的最終位置,使用布局代碼創(chuàng)建整個路徑來的更簡單一點(diǎn)。然后,我們只需要將路徑返回給負(fù)責(zé)展示的視圖。通過添加綁定布局參數(shù)很容易完成。
在完成放置視圖以后,我們知道了位置并使用它們的坐標(biāo)去創(chuàng)建路徑。再次注意,我們必須非常小心的避免布局循環(huán)。我發(fā)現(xiàn)更新路徑會產(chǎn)生一個循環(huán),即使該路徑被繪制為不影響布局的背景視圖也是如此,所以為了避免這種循環(huán),我們要確保路徑發(fā)生改變,然后才更新綁定,這樣就可以成功的打破循環(huán)。
這個挑戰(zhàn)的另一個有趣的部分是告訴布局這些視圖如何分層連接。在本例中,我創(chuàng)建了兩個 UUID 布局值,一個標(biāo)識視圖,另一個作為父視圖的 ID。
當(dāng)我們使用這些代碼的時候需要注意一些事項(xiàng)。這里應(yīng)該只有一個父節(jié)點(diǎn)是 nil 的節(jié)點(diǎn)(根結(jié)點(diǎn)),你應(yīng)該小心的避免循環(huán)引用(例如:兩個節(jié)點(diǎn)互為父節(jié)點(diǎn))。
同時也要注意,這里有一個好的選擇,即放置到具有垂直和水平的滾動 ScrollView 中。
注意這是基本實(shí)現(xiàn),僅用于說明如何實(shí)現(xiàn)。還有許多潛在的優(yōu)化,但制作樹布局所需的關(guān)鍵元素都在這里。
一個有用的調(diào)試工具
回到當(dāng) SwiftUI 剛發(fā)布的時候,我盡力搞清楚布局是如何工作的,我希望我有一個像我今天要介紹的這種工具 。直到現(xiàn)在,它都是最好的工具,用來添加圍繞視圖的邊框觀察視圖邊緣。那是我們最好的盟友。
使用邊框依然是很好的調(diào)試工具,但我們可以添加一個新的工具。感謝新的布局協(xié)議,我創(chuàng)建了一個修飾器,它在嘗試?yán)斫鉃槭裁匆晥D不像您認(rèn)為的那樣工作的時候非常有用,修飾器在這兒:
你可以在任何視圖使用它,一個覆蓋層將會浮動在視圖的頭部角落,顯示一組給定的建議尺寸。如果你未制定建議,最小,理想和最大尺寸都將被覆蓋。
一些使用示例:
更多的例子:
突然間一切都變得有意義了。例如:檢查一下使用和不使用 resizable()的圖像尺寸。終于能看到數(shù)字是不是有一種奇怪的滿足感?
總結(jié)
即使你不打算寫你自己的布局容器,明白它是如何工作也會幫助你理解布局在 SwiftUI 的一般工作方式。
就個人而言,深入布局協(xié)議讓我對 HStack 或 VStack 等容器編寫代碼的團(tuán)隊(duì)有了新的認(rèn)識。我經(jīng)常認(rèn)為這些視圖是理所當(dāng)然的,并將它們視為簡單而不復(fù)雜的容器,好吧,嘗試編寫自己的版本,在各種情況下復(fù)制一個 HStack ,多種類型的視圖和布局優(yōu)先級競爭同一個空間。。。這是一個不錯的挑戰(zhàn)!
參考資料
[1]Safely Updating The View State : https://swiftui-lab.com/state-changes/?。
[2]Canvas: https://swiftui-lab.com/swiftui-animations-part5/?。
[3]建議: http://swiftui-lab.com/digital-lounges-2022#layout-9?。