文章源地址:[https://swiftui-lab.com/communicating-with-the-view-tree-part-3/)
作者: Javier
翻譯: Liaoworking
處理嵌套視圖的偏好(Preferences)
在之前的部分我們介紹了SwiftUI的錨定偏好(anchor preferences) , 現(xiàn)在到了最后一部分啦~ 把所有知識(shí)點(diǎn)放到一起呆奕,繼續(xù)學(xué)習(xí)SwiftUI處理嵌套視圖的偏好(preferences)。順便添加一些Anchor<T>的其他用法。就從下面的例子開(kāi)始吧课锌。
我們的目標(biāo)是創(chuàng)建一個(gè)小的示意圖來(lái)展示一些狀態(tài)的表格浩村。
在例子中需要注意:
- 左邊小的示意圖是縮小版的右邊表格犀忱。不同的顏色代表不同的title view, 文本字段和文本字段的容器得院。
- 隨著文字的變多左电,小示意圖也會(huì)表現(xiàn)出來(lái)牌废。
- 當(dāng)我們添加新的視圖的時(shí)候咽白,小示意圖也會(huì)改變。
- 當(dāng)表格的frame改變的時(shí)候鸟缕, 小示意圖也會(huì)改變晶框。
- 文本框的顏色紅色代表沒(méi)有輸入,黃色小于3個(gè)懂从,綠色大于等于3個(gè)授段。
注意小示意圖并不知道任何關(guān)于表單的信息,它只是因?yàn)橐晥D層級(jí)的偏好(preferences)的改變而改變番甩。
那就開(kāi)始吧~
因?yàn)檫@里的視圖有多種類型侵贵,需要來(lái)將它們區(qū)分,這里我們用一個(gè)枚舉缘薛。
enum MyViewType: Equatable {
case formContainer // 主容器
case fieldContainer // 包括標(biāo)簽和文本框的容器
case field(Int) //文本框窍育,包括內(nèi)容長(zhǎng)度
case title // 表單標(biāo)題
case miniMapArea // 小示意圖后面的視圖
}
然后我們定義一個(gè)MyPreferenceData類卡睦,用來(lái)處理偏好中設(shè)置的數(shù)據(jù)。它有兩個(gè)屬性(vtype 和 bounds)漱抓,還有一些會(huì)使用到的方法:
struct MyPreferenceData: Identifiable {
let id = UUID() // forEach必須要用的屬性
let vtype: MyViewType
let bounds: Anchor<CGRect>
// 配置小示意圖的顏色
func getColor() -> Color {
switch vtype {
case .field(let length):
return length == 0 ? .red : (length < 3 ? .yellow : .green)
case .title:
return .purple
default:
return .gray
}
}
// 如果當(dāng)前view必須要顯示在小示意圖中 返回true
// 只有文本框 文本框容器 標(biāo)題才顯示在小示意圖中
func show() -> Bool {
switch vtype {
case .field:
return true
case .title:
return true
case .fieldContainer:
return true
default:
return false
}
}
}
定義PreferenceKey
struct MyPreferenceKey: PreferenceKey {
typealias Value = [MyPreferenceData]
static var defaultValue: [MyPreferenceData] = []
static func reduce(value: inout [MyPreferenceData], nextValue: () -> [MyPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
有趣的地方開(kāi)始了表锻,我們有很多的區(qū)域,每一個(gè)區(qū)域前面都有一個(gè)textLabel乞娄,并被一個(gè)容器包裹瞬逊。我們把這些重復(fù)的視圖封裝成MyFormField類。與此同時(shí)也相應(yīng)的設(shè)置好偏好补胚。文本框是VStack的一部分码耐。我們需要兩個(gè)嵌套視圖的bounds. 不可以使用.anchorPreference()
兩次,在VStack中調(diào)用anchorPreference()
會(huì)阻止TextField中的調(diào)用溶其∩龋可以在VStack中掉用.transformAnchorPreference()
來(lái)添加數(shù)據(jù),而不是替換數(shù)據(jù)瓶逃。
// 包含標(biāo)題束铭、文本框的圓角視圖
struct MyFormField: View {
@Binding var fieldValue: String
let label: String
var body: some View {
VStack(alignment: .leading) {
Text(label)
TextField("", text: $fieldValue)
.textFieldStyle(RoundedBorderTextFieldStyle())
.anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
return [MyPreferenceData(vtype: .field(self.fieldValue.count), bounds: $0)]
}
}
.padding(15)
.background(RoundedRectangle(cornerRadius: 15).fill(Color(white: 0.9)))
.transformAnchorPreference(key: MyPreferenceKey.self, value: .bounds) {
$0.append(MyPreferenceData(vtype: .fieldContainer, bounds: $1))
}
}
}
ContentView把所有的View都放到了一起,這里我們?cè)O(shè)置了三個(gè)偏好厢绝,稍后會(huì)再小示意圖中用到契沫。這三個(gè)偏好分別存儲(chǔ)的是圖表標(biāo)題的bounds,圖表區(qū)域和示意圖區(qū)域昔汉。
struct ContentView : View {
@State private var fieldValues = Array<String>(repeating: "", count: 5)
@State private var length: Float = 360
@State private var twitterFieldPreset = false
var body: some View {
VStack {
Spacer()
HStack(alignment: .center) {
// 存放小示意圖的View
// 我們將獲取它的大小懈万、位置來(lái)確定小示意圖的元素正確顯示
Color(white: 0.7)
.frame(width: 200)
.anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
return [MyPreferenceData(vtype: .miniMapArea, bounds: $0)]
}
.padding(.horizontal, 30)
// 表單容器
VStack(alignment: .leading) {
// 標(biāo)題
VStack {
Text("Hello \(fieldValues[0]) \(fieldValues[1]) \(fieldValues[2])")
.font(.title).fontWeight(.bold)
.anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
return [MyPreferenceData.init(vtype: .title, bounds: $0)]
}
Divider()
}
// 開(kāi)關(guān)和滑條
HStack {
Toggle(isOn: $twitterFieldPreset) { Text("") }
Slider(value: $length, in: 360...540).layoutPriority(1)
}.padding(.bottom, 5)
// 文本框的第一行
HStack {
MyFormField(fieldValue: $fieldValues[0], label: "First Name")
MyFormField(fieldValue: $fieldValues[1], label: "Middle Name")
MyFormField(fieldValue: $fieldValues[2], label: "Last Name")
}.frame(width: 540)
// 文本框的第二行
HStack {
MyFormField(fieldValue: $fieldValues[3], label: "Email")
if twitterFieldPreset {
MyFormField(fieldValue: $fieldValues[4], label: "Twitter")
}
}.frame(width: CGFloat(length))
}.transformAnchorPreference(key: MyPreferenceKey.self, value: .bounds) {
$0.append(MyPreferenceData(vtype: .formContainer, bounds: $1))
}
Spacer()
}
.overlayPreferenceValue(MyPreferenceKey.self) { preferences in
GeometryReader { geometry in
MiniMap(geometry: geometry, preferences: preferences)
}
}
Spacer()
}.background(Color(white: 0.8)).edgesIgnoringSafeArea(.all)
}
}
最后,我們的小示意圖會(huì)遍歷所有的偏好靶病,并繪制小示意圖中的每一個(gè)元素会通。
struct MiniMap: View {
let geometry: GeometryProxy
let preferences: [MyPreferenceData]
var body: some View {
// 獲得表單容器的偏好
guard let formContainerAnchor = preferences.first(where: { $0.vtype == .formContainer })?.bounds else { return AnyView(EmptyView()) }
// 獲得小示意圖的偏好
guard let miniMapAreaAnchor = preferences.first(where: { $0.vtype == .miniMapArea })?.bounds else { return AnyView(EmptyView()) }
// 計(jì)算表單的數(shù)據(jù) 用來(lái)顯示在小示意圖中
let factor = geometry[formContainerAnchor].size.width / (geometry[miniMapAreaAnchor].size.width - 10.0)
// 確定表單的位置
let containerPosition = CGPoint(x: geometry[formContainerAnchor].minX, y: geometry[formContainerAnchor].minY)
// 確定小示意圖的位置
let miniMapPosition = CGPoint(x: geometry[miniMapAreaAnchor].minX, y: geometry[miniMapAreaAnchor].minY)
// -------------------------------------------------------------------------------------------------
// iOS 13 Beta 5 正式版發(fā)布日志 已知問(wèn)題:
// 復(fù)雜的ForEach view可能會(huì)有編譯報(bào)錯(cuò)
// 解決方案: 抽一個(gè)新的View出來(lái)
// -------------------------------------------------------------------------------------------------
// 由于 beta 5編譯報(bào)錯(cuò)的bug,封裝成AnyView.
return AnyView(miniMapView(factor, containerPosition, miniMapPosition))
}
func miniMapView(_ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
ZStack(alignment: .topLeading) {
// 創(chuàng)建小的示意圖視圖
// 首選項(xiàng)以相反的順序遍歷
// 將被父視圖覆蓋
ForEach(preferences.reversed()) { pref in
if pref.show() { // 一些不想要顯示的視圖
self.rectangleView(pref, factor, containerPosition, miniMapPosition)
}
}
}.padding(5)
}
func rectangleView(_ pref: MyPreferenceData, _ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
Rectangle()
.fill(pref.getColor())
.frame(width: self.geometry[pref.bounds].size.width / factor,
height: self.geometry[pref.bounds].size.height / factor)
.offset(x: (self.geometry[pref.bounds].minX - containerPosition.x) / factor + miniMapPosition.x,
y: (self.geometry[pref.bounds].minY - containerPosition.y) / factor + miniMapPosition.y)
}
}
關(guān)于視圖樹(shù)的一句話
現(xiàn)在讓我們停下來(lái)思考一下:偏好(preference)閉包在嵌套視圖中的執(zhí)行順序娄周。例如先看看小示意圖的實(shí)現(xiàn)涕侈,你可能會(huì)注意到ForEach以相反的順序運(yùn)行循環(huán),否則文本框容器會(huì)最后才繪制煤辨,來(lái)覆蓋對(duì)應(yīng)的小示意圖的文本框裳涛。 所以了解偏好的設(shè)置就變的很重要了。
首先請(qǐng)注意:并沒(méi)有關(guān)于SwiftUI遍歷視圖樹(shù)順序的文檔众辨,PreferenceKey類中的reduce方法聲明中提到值是以視圖樹(shù)的順序排列端三。但是沒(méi)有告訴我們這個(gè)順序是什么,我們能確認(rèn)的是這并不是隨機(jī)順序而且每次刷新的時(shí)候都保持一致泻轰。
下面我講的所有和關(guān)于閉包中的運(yùn)行順序相關(guān)的技肩,都是通過(guò)專門的實(shí)驗(yàn)弄清楚的。我?guī)缀趺總€(gè)地方都打斷點(diǎn)了浮声,都說(shuō)的通虚婿,我也對(duì)此很有信心
下面的圖表簡(jiǎn)單的說(shuō)明了視圖層級(jí),為了讓視圖更容易理解泳挥,簡(jiǎn)單的視圖就忽略了然痊,紅色的箭頭表示anchorPreference()
和transformAnchorPreference()
閉包的執(zhí)行順序。注意屉符,只有SwiftUI認(rèn)為必須的才會(huì)調(diào)用剧浸,并不是所有的閉包都會(huì)被調(diào)用。例如視圖的bounds并沒(méi)有改變.anchorPreference()
可能就不會(huì)調(diào)用矗钟。當(dāng)不確定的時(shí)候唆香,打個(gè)斷點(diǎn)或者打印一下?tīng)顟B(tài)來(lái)調(diào)試一下。
如圖吨艇,SwiftUI遵循下面兩個(gè)原則:
- 1.同級(jí)的視圖的遍歷順序和它們?cè)诖a中的出現(xiàn)順序相同躬它。
- 2.子級(jí)視圖的閉包執(zhí)行時(shí)機(jī)要比父級(jí)視圖早。
Anchor<T>的其他使用东涡。
正如我們所看到的冯吓,Anchor<T>.Source可以被一下靜態(tài)變量獲得到,如.bounds, .topLeading, .bottom等疮跑。我們通常將它傳遞給anchorPreference() 修改器组贺。然而你可以通過(guò)靜態(tài)變量Anchor<T>.Source創(chuàng)建你自己的Anchor<CGRect>.Source和Anchor<CGPoint>.Source,如下:
let a1 = Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))
let a2 = Anchor<CGPoint>.Source.point(CGPoint(x: 10, y: 30))
let a3 = Anchor<CGPoint>.Source.unitPoint(UnitPoint(x: 10, y: 30))
你可能會(huì)問(wèn)我們什么時(shí)候用這些祖娘,你可以把值傳遞給偏好失尖,如果已存在的靜態(tài)變量對(duì)你都沒(méi)啥用處,但是當(dāng)處理彈框的時(shí)候用它就會(huì)特別的方便渐苏。
.popover(isPresented: $showPopOver,
attachmentAnchor: .rect(Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))),
arrowEdge: .leading) { ... }
總結(jié):
恭喜掀潮,這一系列總算到最后了,希望你能享受享受這些工具而且用來(lái)創(chuàng)作炫酷的app整以。有著無(wú)限的可能性胧辽。歡迎評(píng)論,或者給我email和Twitter上關(guān)注我公黑。
歡迎關(guān)注來(lái)獲取更多文章邑商。