译自 Inspecting the View Tree (Anchor Preferences) - Part 2 - The SwiftUI Lab
更多内容,欢迎关注公众号「Swift花园」
在 第一部分 的文章中,我们介绍了 preference 的使用。对于向上传递信息的场景,这个东西非常有用。通过定义 PreferenceKey 的关联类型,我们可以把任何东西放进那里。
在第二部分,是时候介绍 Anchor Preferences 了。写这篇文章的时候,我找不到任何相关文档,博客或者文章介绍如何使用这些难以理解的工具。所以,请你们加入我,一起探索这未知的疆域。
直觉上一开始 Anchor Preference 不好理解,但一旦我们掌握了它们,就很难忘记了。为了让事情简单一点,我们还是以第一部分里的例子来讲解。如果你对于挑战本身已经很熟悉,那当然很好,这样你就能专注于所有这些新特性。不像之前的解决方案,我们不再用到坐标系,并且会把 .onPreferenceChange()
换成别的东西。
那个例子又来了:我们要让边框从一个月份移动到另一个月份,带有动画效果:
现在让我们热烈欢迎: Anchor。这是一个存放类型 T 的不透明类型,T 可以是 CGRect 或者 CGPoint。我们通过用 Anchor 来访问视图的边界,用 Anchor 访问诸如 top,topLeading,topTrailing,center,trailing,bottom,bottomLeading,bottomTrailing 和 leading 等视图属性。
因为它是一个不透明类型,所以我们不能单独使用它。还记得 GeometryReader to the Rescue一文中介绍过 GeometryProxy 的下标 getter 吗?当我们使用 Anchor 的值作为 geometry proxy 的索引时,你可以得到表示 CGRect 或者 CGPoint 值。同时,它们已经被转换成 GeometryReader 视图的坐标空间。
我们先通过修改 PreferenceKey 处理的数据开始吧。在这个例子中,我们要用 Anchor 替换掉 CGRect:
struct MyTextPreferenceData { let viewIdx: Int let bounds: Anchor<CGRect> }复制代码
我们的 PreferenceKey 保持不变:
struct MyTextPreferenceKey: PreferenceKey { typealias Value = [MyTextPreferenceData] static var defaultValue: [MyTextPreferenceData] = [] static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) { value.append(contentsOf: nextValue()) } }复制代码
MonthView 现在变得更简洁了。我们不再使用 .preference()
,而是调用 modifier .anchorPreference()
。不像其他方法,这里我们指定一个值(在例子里是 .bounds),它表示我们的 transform 闭包拿到一个表示被修改视图的边界的 Anchor 。跟常规属性相似的处理方式,我们用 ($0) 来创建我们的 MyTextPreferenceData 值。这样一来,我们就不再需要在 .background() modifier 里使用 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()
的 modifier,但它有一个很大的改进:可以访问整个视图树的偏好数组。通过这种方式,我们得到了所有月份视图的边界,可以使用它们来计算需要在何处绘制边界。
还有一个地方需要使用 GeometryReader,这是为了理解 Anchor 的值。注意,我们不再需要关心坐标空间了,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 GeometryReader { geometry in self.createBorder(geometry, preferences) .frame(maxWidth: .infinity, maxHeight: .infinity, 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()
。它的功能相同,只不过不是在后面绘制,而是在视图前面绘制。
我们已经知道不止有一种 Anchor 值。有 bounds,也有 topLeading,center,bottom,等等。可能有时候我们需要获取多个这样的值。但是,正如我们将学习到的,这并不像对所有这些值调用 .anchorPreference()
那么简单。为了说明这一点,我们再来解决一遍那个问题。
但这一回我们不用 Anchor 来获取月份视图的边界,而是使用两个单独的 Anchor 值。其中一个用于 topLeading,而另一个用于 bottomTrailing。提醒一下,对于这个特定问题,采用 Anchor 是更好的方案。不过我们这里采用第三种方法,只是为了学习如何在同一个视图上获取多个锚点偏好。
我们首先修改 MyTextPreferenceData 来容纳矩形的两个端点。这次我们需要把它们设置为可选型,因为两者不能被同时设置。
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)] }) .transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in value[0].bottomTrailing = anchor }) .onTapGesture { self.activeMonth = self.idx } } }复制代码
最后,我们相应地更新 .createBorder()
,令它基于两个点代替矩形来计算:
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 GeometryReader { geometry in self.createBorder(geometry, preferences) .frame(maxWidth: .infinity, maxHeight: .infinity, 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()
。至于为什么要这么做,我们详细说明。
这个系列的最后一步,我们要使用一个不一样的例子来讲解。我们要创建一个迷你地图。这个迷你地图通过读取一个表单的视图树来构建。我们还将看到修改表单的视图树如何对迷你地图产生直接影响,它只对表单视图树的偏好变化做出响应。
先睹为快:
我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~