译自 swiftui-lab.com/communicati…
建议横屏阅读代码
在 SwiftUI 中,我们一般不用关心子视图内部发生了什么。每个视图各自管好自己的事情。但是,我们总会遇到一些特殊情况,这时就需要我们用到 SwiftUI 给我们的好工具。不幸的是,文档极其粗略。接下来的三篇文档尝试对文档做出补充。我们将会了解PreferenceKey协议以及相关的几个 modifier:.preference(),.transformPreference(),.anchorPreference(),.transformAnchorPreference(),.onPreferenceChange(),.backgroundPreferenceValue()和.overlayPreferenceValue()。涉及的内容很多,让我们开始吧!
SwiftUI 有一个机制,可以让我们“附着”某些属性到视图上。这些属性被称为Preferences,并且它们很容易通过视图层级向上传递。我们甚至可以安装一些回调,在这些属性变化时执行。
你有没有想过NavigationView
是如何通过.navigationBarTitle()获得标题的。注意,.navigationBarTitle()
并没有直接修改NavigationView
,而是沿着视图层级被调用的!那么它是怎么做到的呢?可能你已经猜到了,它用了 Preference。实际上,WWDC session SwiftUI Essential 曾简短地介绍了这个东西。如果你感兴趣,可以查看Session 216 (SwiftUI Essentials),跳到 52:35 处。
我们也会学习一些特殊的 preference,它们被称为 “anchored preferences”。这些 preference 对于挖掘子视图的几何信息十分有用。我们会在下一篇文章中涉及这些内容。
介绍 PreferenceKey 只需要花费我们一分钟,但为了更好地理解这个主题,让我们以一些没有使用 preference 的例子开始。在案例中,每个视图都清楚自己该做什么。我们要创建一个显示月份名称的视图,当一个月份的标签被点击时,会有一个边框慢慢显现(从之前选中月份的边框移动过去)
代码很简单,没有需要特别解释的。首先,创建我们的 ContentView:
import SwiftUI struct EasyExample : 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() } } }复制代码
和辅助视图:
struct MonthView: View { @Binding var activeMonth: Int let label: String let idx: Int var body: some View { Text(label) .padding(10) .onTapGesture { self.activeMonth = self.idx } .background(MonthBorder(show: activeMonth == idx)) } } struct MonthBorder: View { let show: Bool var body: some View { RoundedRectangle(cornerRadius: 15) .stroke(lineWidth: 3.0).foregroundColor(show ? Color.red : Color.clear) .animation(.easeInOut(duration: 0.6)) } }复制代码
代码也相当直白。每当月份标签被点击,我们改变@State
变量,跟踪最后点击的月份,并让每个月份边框的颜色依赖于这个变量。当视图被选中时,边框颜色被设置为红色,否则被设置为透明。这个例子很简单,因为每个视图都绘制自己的边框。
让我们升级难度。现在,我们不做 fading,我们让边框从一个月份移动到另一个月份。
我会让你暂停一会,思考你要如何实现这个问题。不像之前的例子,你有 12 个边框(每个视图一个),我们现在只有一个边框,并且需要借助动画来改变尺寸和位置。
在新的例子中,边框不再是月份视图的一部分。现在,我们需要创建一个单独的边框视图,并且需要能相应地移动和改变大小。这就要求我们有一种方式可以跟踪每个月份视图的大小和位置。
如果你读过我之前的文章 (GeometryReader to the Rescue),那你已经有一种工具可以解决这个问题。如果你还不知道 GeometryReader 怎么工作,可以先看看这篇文章。
一种解决这个问题的方法是,每个月份视图都使用 GeometryReader 来获取自身的大小和位置,并反过来更新一个共享给它们的父级视图里矩形数组(通过 @Binding
)。这样一来,由于父级视图知道每个子视图的大小和位置,边框就很容易放置了。这个方法很棒,不过让子视图修改这个数组会产生问题。
对于某些布局,如果我们在构建视图的 body
时修改某个会影响父级位置的变量,那么这个视图也会受到影响。这会导致我们正在构建的视图刷新,它可能需要重头再来,从而陷入永无止境的循环。好在 SwiftUI 看起来会检测到这种情况,不会崩溃。但是,它会给你一个运行时错误的警告:Modifying state during view update。快速解决这个问题的方法是延迟变量的更改,直到视图更新完成:
DispatchQueue.main.async { self.rects[k] = rect }复制代码
不过,这样做有点取巧。尽管起作用,这只是一种临时的解决方案,我不确定它未来是否还能工作。这么做相当于对当时底层框架工作状态押注,但你知道,那是一个巨大的未知数...因为我们没有文档。好在,我们有 PreferenceKey 可以依赖。
SwiftUI 提供了一个 modifier,让我们可以添加一些数据到我们自己选择的任意特定视图上。这些数据之后能被顶级视图查询到。读取这些属性的方式有很多种,取决于你的目的。不管怎么说,看起来 preference 正是我们要找的东西,让我们先试着解决我们的问题:
首先我们要确定我希望通过属性暴露哪些信息。在我们的例子中,我们需要:
Int
值,从 0 到 11,其实你用任何值都可以。把它们放进一个结构体,取名 MyTextPreferenceData。注意,它必须遵循 Equatable 协议:
struct MyTextPreferenceData: Equatable { let viewIdx: Int let rect: CGRect }复制代码
然后,我们需要定义一个实现 PreferenceKey 协议的结构体:
struct MyTextPreferenceKey: PreferenceKey { typealias Value = [MyTextPreferenceData] static var defaultValue: [MyTextPreferenceData] = [] static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) { value.append(contentsOf: nextValue()) } }复制代码
PreferenceKey 唯一可得的文档在它的定义文件里,我强烈建议你去阅读。不过基本上,你需要实现下面这些东西:
现在,我们已经建立了 PreferenceKey 结构体,我们需要对之前的实现做出修改:
首先,我们修改 MonthView。我们要用 GeometryReader 来获取文本的大小和位置。这些值需要被转换到边框要绘制时所在的坐标系。视图可以通过应用 modifier .coordinateSpace(name: "name")
来命名自己的坐标空间。因此,一旦我们转换了矩形,要相应地设置属性:
struct MonthView: View { @Binding var activeMonth: Int let label: String let idx: Int var body: some View { Text(label) .padding(10) .background(MyPreferenceViewSetter(idx: idx)).onTapGesture { self.activeMonth = self.idx } } } struct MyPreferenceViewSetter: View { let idx: Int var body: some View { GeometryReader { geometry in Rectangle() .fill(Color.clear) .preference(key: MyTextPreferenceKey.self, value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))]) } } }复制代码
然后,我们为边框创建一个单独的视图,这个视图会改变偏移量和 frame,以匹配最后一个被点击的视图的矩形:
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green) .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height) .offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY) .animation(.easeInOut(duration: 1.0))复制代码
最后,我们需要确保当属性变化时,恰当地更新矩形数组。例如,当设备旋转时,或者窗口大小变化时,下面的代码会被调用:
.onPreferenceChange(MyTextPreferenceKey.self) { preferences in for p in preferences { self.rects[p.viewIdx] = p.rect } }复制代码
下面是完整的代码:
import SwiftUI struct MyTextPreferenceKey: PreferenceKey { typealias Value = [MyTextPreferenceData] static var defaultValue: [MyTextPreferenceData] = [] static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) { value.append(contentsOf: nextValue()) } } struct MyTextPreferenceData: Equatable { let viewIdx: Int let rect: CGRect } struct ContentView : View { @State private var activeIdx: Int = 0 @State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 12) var body: some View { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green) .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height) .offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY) .animation(.easeInOut(duration: 1.0)) 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() }.onPreferenceChange(MyTextPreferenceKey.self) { preferences in for p in preferences { self.rects[p.viewIdx] = p.rect } } }.coordinateSpace(name: "myZstack") } } struct MonthView: View { @Binding var activeMonth: Int let label: String let idx: Int var body: some View { Text(label) .padding(10) .background(MyPreferenceViewSetter(idx: idx)).onTapGesture { self.activeMonth = self.idx } } } struct MyPreferenceViewSetter: View { let idx: Int var body: some View { GeometryReader { geometry in Rectangle() .fill(Color.clear) .preference(key: MyTextPreferenceKey.self, value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))]) } } }复制代码
在使用视图 preference 时,你可能会使用子视图里的几何信息,以便布局它的某个先祖视图。如果是这样的话,你应该小心处理。如果先祖视图会对子视图的布局做出反应,而子视图也会对先祖视图的变化做出反应,那你将陷入一个无限循环。
你可能会遭遇不同的后果,有的时候是程序卡死,有的时候是屏幕持续重绘从而闪动,或者 CPU 很有可能到达峰值。所有这些现象可能暗示你错误地使用了 preference。
举个例子,假设你在一个 VStack 里有两个视图,上面的视图基于下面视图的 y 值设置高度,那你就是在给自己找来循环。
为了避免这类问题,你可以借助布局工具让先祖视图不要影响子视图。一些很好用的方案包括:ZStack,.overlay(),.background()或者几何效果等。我们将在另一篇GeometryEffect 的文章中讨论。
这篇文章中我们通过 GeometryReader “窃取”了月份标签的几何信息。不过,通过使用Anchor Preferences,我们还可以优化实现方案。在接下来的文章中,我们将学习它,同时也会深入探究 SwiftUI 是如何遍历视图树的。其实不诉诸.onPreferenceChange()
,我们也有别的方式可以使用 preference。下篇文章也会讨论。
当你推进之前,我希望你留意到,当你开始广泛使用 Preference 时,你的代码可能会变得难以阅读。我建议你在视图扩展中封装这些 preferences。最近我还写过一篇文章,专门介绍怎么做。获取更多详细的信息,你可以去查看View Extensions for Better Code Readability。
我的公众号这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~