在本文的第一部分中,我们介绍了首选项的使用。这些对于向上传递信息(孩子 —> 祖先)非常有用。通过自定义PreferenceKey的关联类型,我们现在知道可以将任何想要的内容放入其中。
在第二部分中,我们要聊聊锚点首选项。在写这篇文章的时候,我找不到一篇可以解释如何使用这些难以捉摸的工具的文档、博客或文章。所以请和我一起探索这个未知的领域。
锚点首选项并不那么直观,但是一旦我们深入了解它们,就再也离不开了。为了简单起见,我们将处理第一部分中需要解决的问题。庆幸的是,你已经熟悉了这个挑战,所以可以将精力集中在所有这些令人兴奋的新特性上。与之前的解决方案不同,我们将不再使用空间坐标,我们将用其他东西替换. onpreferencechange()。
接下来,让我们再次:创建一个边界,使用动画从一个月视图移动到另一个:

锚点首选项(Anchor Preferences)
双手合十,让我们热烈欢迎:Anchor<T>。它一个不透明类型,值类型为T,T可以是CGRect,也可以是CGPoint。我们通常使用Anchor<CGRect>来访问视图的边界,使用Anchor<CGPoint>来访问其他视图属性,如top、topLeading、 topTrailing、center、 trailing、 bottom、 bottomLeading、 bottomTrailing和 leading。
因为它是一个不透明的值,故不能单独使用。还记得(GeometryReader to the Rescue)中的GeometryProxy的下标(subscript)吗?现在你知道它是干什么的了吧。当使用Anchor<T>值作为几何代理的索引时,将得到所表示的 CGRect或 CGPoint 值。同时,你也把它转换成 GeometryReader视图的坐标空间。
我们首先修改由 PreferenceKey 处理的数据。在这种情况下,我们用Anchor<CGRect>替换CGRect:
struct MyTextPreferenceData {
let viewIdx: Int
let bounds: Anchor<CGRect>
}
MyTextPreferenceKey保持不变:
struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]
static var defaultValue: [MyTextPreferenceData] = []
static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
现在月视图就更简单了。使用修饰词.anchorPreference()代替.preference()。不同于其他方法,这里我们可以指定一个值(在本例中是.bounds)。这意味着我们的转换闭包获得一个Anchor<CGRect>,代表修改后视图的边界。与我们对普通首选项所做的类似,我们使用它($0)来创建MyTextPreferenceData值。这样,我们就不再需要在.background()修饰符中使用GeometryReader来获取文本视图的边界。
为了更好地理解,让我们看看代码:
struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceData(viewIdx: self.idx, bounds: $0)] })
.onTapGesture { self.activeMonth = self.idx }
}
}
最后,我们来更新 ContentView。这里有一些变化:首先,我们不再使用.onPreferenceChange(),而是调用.backgroundPreferenceValue()。它是一个类似于.background()的修饰符,但是有一个很大的改进:可以访问整个视图树的首选项数组。通过这种方式,我们得到了所有月视图的边界,可以使用它们来计算需要在何处绘制边界。
还有一个地方需要使用 GeometryReader。这是为了理解Anchor<CGRect>的值。请注意,我们不再需要担心坐标空间,GeometryReader会处理它。
struct ContentView : 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()
}.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
return GeometryReader { geometry in
ZStack(alignment: .topLeading) {
self.createBorder(geometry, preferences)
HStack { Spacer() } // makes the ZStack to expand horizontally
VStack { Spacer() } // makes the ZStack to expand vertically
}.frame(alignment: .topLeading)
}
}
}
func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {
let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
let bounds = p != nil ? geometry[p!.bounds] : .zero
return RoundedRectangle(cornerRadius: 15)
.stroke(lineWidth: 3.0)
.foregroundColor(Color.green)
.frame(width: bounds.size.width, height: bounds.size.height)
.fixedSize()
.offset(x: bounds.minX, y: bounds.minY)
.animation(.easeInOut(duration: 1.0))
}
}
.backgroundPreferenceValue()有着对应的.overlayPreferenceValue()。它执行相同的操作,不过是在修改后的视图的前面绘制。
带有一个PreferenceKey的多个锚点首选项
我们现在知道有不止一种Anchor<T>值。有 bounds,也有 topLeading、 center、 bottom等等。在某些情况下,可能需要获取多个这样的值。但是,正如我们将学习到的,这并不像对它们全部调用 .anchorPreference() 那么简单。让我们举例来阐述一下这个问题。
但这一次,我们不使用Anchor<CGRect>来获取月视图的边框,而是使用两个单独的 Anchor<CGPoint>值。一个用于topLeading,另一个用于bottomTrailing。注意,对于这个特定的问题,获取Anchor<CGRect>是一个更好的方法。然而,我们之所以使用第三种方法,只是为了学习如何在同一个视图中获得多个锚点首选项。
我们首先修改 MyTextPreferenceData来容纳rect的两个端点,这次我们需要将它们设为可选,因为它们不能同时设置。
struct MyTextPreferenceData {
let viewIdx: Int
var topLeading: Anchor<CGPoint>? = nil
var bottomTrailing: Anchor<CGPoint>? = nil
}
PreferenceKey保持不变:
struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]
static var defaultValue: [MyTextPreferenceData] = []
static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
我们的月视图现在需要设置两个锚点首选项。但是,如果我们在同一个视图中添加对.anchorPreference()的多个调用,那么只有最后一次有用。这里,我们需要调用一次.anchorPreference(),然后使用.transformAnchorPreference()来填充缺失的数据:
struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] }) // get .topLeading
.transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
value[0].bottomTrailing = anchor
}) //// get .bottomTrailing
.onTapGesture { self.activeMonth = self.idx }
}
}
最后,我们更新 .createBorder(),它用这两个点代替rect来进行计算:
struct ContentView : 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()
}.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
return GeometryReader { geometry in
ZStack(alignment: .topLeading) {
self.createBorder(geometry, preferences)
HStack { Spacer() } // makes the ZStack to expand horizontally
VStack { Spacer() } // makes the ZStack to expand vertically
}.frame(alignment: .topLeading)
}
}
}
func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {
let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
let aTopLeading = p?.topLeading
let aBottomTrailing = p?.bottomTrailing
let topLeading = aTopLeading != nil ? geometry[aTopLeading!] : .zero
let bottomTrailing = aBottomTrailing != nil ? geometry[aBottomTrailing!] : .zero
return RoundedRectangle(cornerRadius: 15)
.stroke(lineWidth: 3.0)
.foregroundColor(Color.green)
.frame(width: bottomTrailing.x - topLeading.x, height: bottomTrailing.y - topLeading.y)
.fixedSize()
.offset(x: topLeading.x, y: topLeading.y)
.animation(.easeInOut(duration: 1.0))
}
}
嵌套视图
到目前为止,我们一直在处理兄弟视图(或表兄妹视图)中的首选项。然而,当我们需要设置嵌套视图的首选项时,事情就更有挑战性了。然后.transformAnchorPreference()变得更加重要。例如,如果你有两个视图,分别是父视图和子视图,那么在两个视图上都设置. anchorpreference()将不起作用。子视图的闭包将不会执行。要解决这个问题,需要在子节点上指定anchorPreference,在父节点上指定transformAnchorPreference。至于为什么这么做,后面有详细介绍。
下一步
在本系列的最后一部分中,我们将使用一个不同的示例来说明这一点。我们要创建一个迷你地图视图。迷你地图将通过读取表单的树视图来构建。我们还将看到修改表单的树视图如何对迷你地图产生直接影响,它只对表单树视图的首选项的更改作出响应。
可以先睹为快:
