处理嵌套视图的首选项
在本系列的前一部分中,我们介绍了 SwiftUI 锚点首选项。现在我们终于要走出迷雾森林了,在这最后一部分,我们将把所有的东西组合在一起。我们还将学习 SwiftUI 如何处理嵌套视图的首选项,以及Anchor<T>的其他一些用法。像往常一样,先例为敬:
我们现在的目标是创建一个迷你地图视图,它将反映表单的状态:

关于这个例子,有几点需要注意:
- 迷你地图显示了按比例缩小的表单展现形式。不同的颜色将表示标题视图、文本字段和文本字段容器。
- 随着标题文本视图的增长,迷你地图会做出相应的变化。
- 当我们添加一个新视图(如twitter field),迷你地图也会改变。
- 当表单中的
frame发生变化时,迷你地图也会更新。 - 当文本字段颜色为红色时,表示没有输入,黄色表示少于3个字符,绿色表示大于等于3个字符。
请注意,迷你地图对表单一无所知。它将只对视图层次结构的首选项中的更改作出响应。
让我们开始编码
首先定义一些类型,因为我们的视图树将有多种视图,所以需要对它们进行区分。为此,我们首先定义一个枚举:
enum MyViewType: Equatable {
case formContainer // main container
case fieldContainer // contains a text label + text field
case field(Int) // text field (with an associated value that indicates the character count in the field)
case title // form title
case miniMapArea // view placed behind the minimap elements
}
在定义要在首选项中设置的数据类型时,添加了一些稍后使用的方法。数据类型将有两个属性(vtype和bounds):
struct MyPreferenceData: Identifiable {
let id = UUID() // required when using ForEach later
let vtype: MyViewType
let bounds: Anchor<CGRect>
// Calculate the color to use in the minimap, for each view type
func getColor() -> Color {
switch vtype {
case .field(let length):
return length == 0 ? .red : (length < 3 ? .yellow : .green)
case .title:
return .purple
default:
return .gray
}
}
// Returns true, if this view type must be shown in the minimap.
// Only fields, field containers and the title are shown in the minimap
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())
}
}
接下来就有趣了!我们有许多字段,每个字段前面都有一个文本标签,并由一个容器包围。用一个名为 MyFormField 的视图封装这个重复模式。此外,我们还相应地设置首选项。由于文本字段是包含VStack的一个子字段,而且需要两个嵌套视图的边框,所以不能两次都调用anchorPreference()。在Vstack上调用anchorPreference()会阻止对TextField的调用。所以,我们在VStack上使用transformanchorpreference()。通过这样,我们添加数据,而不是替换它:
// This view draws a rounded box, with a label and a textfield
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将所有视图放在一起。你会看到,这里我们设置了稍后用于迷你地图中的三个首选项。我们正在收集表单标题、表单区域和小地图区域的边界:
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) {
// This view puts a gray rectangle where the minimap elements will be.
// We will reference its size and position later, to make sure the mini map elements
// are overlayed right on top of it.
Color(white: 0.7)
.frame(width: 200)
.anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
return [MyPreferenceData(vtype: .miniMapArea, bounds: $0)]
}
.padding(.horizontal, 30)
// Form Container
VStack(alignment: .leading) {
// Title
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()
}
// Switch + Slider
HStack {
Toggle(isOn: $twitterFieldPreset) { Text("") }
Slider(value: $length, in: 360...540).layoutPriority(1)
}.padding(.bottom, 5)
// First row of text fields
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)
// Second row of text fields
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)
}
}
最后,迷你地图将遍历所有首选项来绘制每个元素:
struct MiniMap: View {
let geometry: GeometryProxy
let preferences: [MyPreferenceData]
var body: some View {
// Get the form container preference
guard let formContainerAnchor = preferences.first(where: { $0.vtype == .formContainer })?.bounds else { return AnyView(EmptyView()) }
// Get the minimap area container
guard let miniMapAreaAnchor = preferences.first(where: { $0.vtype == .miniMapArea })?.bounds else { return AnyView(EmptyView()) }
// Calcualte a multiplier factor to scale the views from the form, into the minimap.
let factor = geometry[formContainerAnchor].size.width / (geometry[miniMapAreaAnchor].size.width - 10.0)
// Determine the position of the form
let containerPosition = CGPoint(x: geometry[formContainerAnchor].minX, y: geometry[formContainerAnchor].minY)
// Determine the position of the mini map area
let miniMapPosition = CGPoint(x: geometry[miniMapAreaAnchor].minX, y: geometry[miniMapAreaAnchor].minY)
// The following view had to be encapsulated in two separate functions (miniMapView & rectangleView),
// because beta 5 has a bug that fails to compile expressions that are "too complex".
return AnyView(miniMapView(factor, containerPosition, miniMapPosition))
}
func miniMapView(_ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
ZStack(alignment: .topLeading) {
// Create a small representation of each of the form's views.
// Preferences are traversed in reverse order, otherwise the branch views
// would be covered by their ancestors
ForEach(preferences.reversed()) { pref in
if pref.show() { // some type of views, we don't want to 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)
}
}
关于视图树顺序的说明
我们先暂停,思考下在嵌套视图中执行首选项闭包的顺序。例如,看看小地图的实现。你可能已经注意到,ForEach以相反的顺序运行循环。否则,代表textfield容器的矩形将最后绘制,覆盖它们相应的迷你地图中的textfield。因此,知道如何设置偏好是很重要的。
请注意,没有关于SwiftUI遍历视图树的顺序的文档。在
PreferenceKey中的reduce方法声明中,确实提到了以视图树顺序提供的值。但是,它没有告诉我们这个顺序是什么。不过,我们可以确定它不是随机的,而且每次刷新都是一致的。接下来我所写的关于闭包运行顺序的所有内容,都是通过实验得出的。基本上,我到处都放了断点!不过,既然它看起来很合理,我对它很有信心。
下图显示了视图层次结构的简化表示。为了使图表更易于阅读,省略了一些不必要的视图。红色箭头表示执行anchorPreference()和transformanchorpreference()闭包的顺序。注意,并不是所有的闭包都被调用,只是调用了SwiftUI 认为必要的闭包。例如,如果一个视图的边界没有改变,那么它的.anchorpreference()闭包可能不会运行。如果不确定,请放置断点或打印语句进行调试。

如图所示,SwiftUI 似乎遵循了两个简单的规则:
- 兄弟节点的遍历顺序与它们在代码中出现的顺序相同。
- 子视图的闭包在父视图之前执行。
Anchor<T>的其他用途
正如我们所看到的,一个Anchor<T>.Source可以通过一些静态变量获得,如.bounds,.toLeading,.bottom等。我们通常将它们传递给anchorPreference()修饰符中的值参数。但是,你也可以在Anchor<T>.Source中使用静态方法创建自己的Anchor<CGRect>.Source和 Anchor<CGRect>.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))
我听到你说的了…, 但是什么时候能用上它呢?如果已有的静态变量都不适合你,那么可以使用它将值传递给首选项。例如它们在处理弹窗时特别方便:
.popover(isPresented: $showPopOver,
attachmentAnchor: .rect(Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))),
arrowEdge: .leading) { ... }
让我们总结一下
祝贺你。你坚持到了最后!我希望你喜欢你的新工具,并使用它们变出一些惊人的新应用。可能无极限!