对于SwiftUI,我们通常不需要担心子视图的内部情况。每个子视图都愉快的做着自己的事。然而,生活并不总是一往如常。面对特殊情况,SwiftUI 为我们提供了一些很好的工具。不幸的是,它们目前的文档都非常简短,而本文试图分为三个部分对其进行补救。我们将学习 PreferenceKey 协议及其相关的修饰词: .preference()、. transformpreference()、. anchorpreference()、. transchorpreference()、. onpreferencechange()、. backgroundpreferencevalue()和. overlaypreferencevalue()。有很多内容需要介绍,让我们开始吧!
SwiftUI 有一个机制,允许将一些属性附加到视图中。这些属性称为首选项(Preferences),可以很容易地在视图层次结构中传递这些属性,也可以在每当这些首选项更改时,来触发执行一个回调。
你是否曾经想知道 NavigationView是如何从. navigationbartitle()获取标题的。注意,.navigationBarTitle()并不直接修改NavigationView,相反,它是在视图层次结构下调用的!那么它是如何做到的呢?你可能已经猜到了。它使用了首选项。事实上,在WWDC的 SwiftUI Essentials 会议上它曾被简单提及。只有简短的20s,很容易错过。如果感兴趣,可以查看Session 216 (SwiftUI Essentials)并跳转到52:35。
我们还会了解到有一些特殊的首选项,比如锚定首选项(anchored preferences)。这对于从子视图中检索各种几何信息非常有用,将在本文的下一部分介绍它。
独立的视图
我们稍后会了解到 PreferenceKey,为了更好地理解这个主题,让我们从一个不需要首选项的示例开始。在本例中,每个视图都知道自己要做什么。我们将创建一个显示月份名称的视图。当一个月的标签被点击时,它的周围会出现一个边框(之前选中的则移除)。
代码非常简单,没有什么特别之处。首先我们有我们的 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变量--该变量跟踪最后选中的标签。我们还使每个月视图的边框颜色依赖于这个变量。如果视图被选中,则边框颜色设置为红色。否则,边框透明。这种情况很简单,每个视图都绘制自己的边框。
合作的视图
让我们把事情复杂化一点。现在,我们希望边界能够逐月移动,而不是逐渐消失。
我希望你暂停一下,思考一下如何解决这个问题。不像前面的例子,你有12个边框(每个视图一个),我们现在只有一个独立的边框,需要使用动画改变大小和位置。
在这个新示例中,边框不再是月视图的一部分。现在,我们需要创建一个单独的边界,但它需要相应地移动和调整大小。这意味着必须有一种方法来跟踪每个月视图的大小和位置。
如果你读过我以前的文章(GeometryReader to the Rescue),那么你已经拥有了解决此问题所需的工具之一。如果你不知道GeometryReader是如何工作的,那先暂停一下,看看这篇文章。
解决这个问题的一种方法是,让每个月视图使用GeometryReader来获得自己的大小和位置。每个视图将依次更新与父视图共享的矩形数组(通过@Binding)。现在,由于父节点知道每个子节点的大小和位置,所以可以轻松地放置边界。这种方法很好,但不幸的是,让子视图修改这个数组会产生问题。
对于某些布局,如果在构建视图主体时修改了一个影响父视图位置的变量,那么子视图也会受到影响。这将使我们正在构建的视图无效,可能需要重新启动。也许是一个永无止境的循环。幸运的是,SwiftUI 似乎能够检测到这种情况,还不会崩溃。但是,它会给出一个运行时错误警告:在视图更新期间修改状态。这个问题的快速解决方法是延迟变量的更改,直到视图更新完成:
DispatchQueue.main.async {
self.rects[k] = rect
}
尽管如此,它看起来还是有点怪。虽然在实践中它是有效的,但这是一种依靠经验的解决方案,我们不确定将来是否还会继续有效,因为它在当前框架如何运作上押下了太多赌注。就你所知,这里有个很大的问题--没有文档😭。幸运的是,PreferenceKey要登场了!😁
介绍 PreferenceKey
SwiftUI 提供了一个修改器,==使我们可以在任意视图上添加自定的数据。这些数据稍后可以被一个祖先视图查询==。可以通过多种方式读取首选项,而你选用的方法取决于你想要达到的目标。不管怎样,看起来首选项正是我们所需要的,所以让我们试着解决这些问题:
我们首先需要确定希望通过首选项公开哪些信息。在上面的例子中,我们想要:
- 用个什么来识别视图。这里我们将使用一个
Int值,从 0~11 。也可是你想要的任何其他东西。 - 文本视图的位置和大小。
CGRect值就可以做到。
我们把需要的数据放在一个结构体中,命名为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惟一可用的文档就是定义它的地方,我强烈建议读下它。基本来说,必须实现以下几点:
Value:是一个typealias,它指示希望通过首选项公开哪种类型的信息。在这个例子中,我们使用了MyTextPreferenceData数组。这我稍后就会讲到。defaultValue:当没有显式地设置一个首选项键值时,SwiftUI 将使用这个defaultValue。reduce:这是一个静态函数,SwiftUI 将使用它来合并视图树中的所有键值。一般情况下,使用它来累积它接收到的所有值,但是你可以做任何你想做的事情。在我们的例子中,当 SwiftUI 遍历树时,它将收集首选项键值并将它们存储在一个单独的数组中,稍后我们将能够访问这个数组。此刻你需要知道,值是按视图树顺序提供给reduce函数的。至于为什么?我们将在另一个例子中阐述,此时与顺序无关。
现在,我们的 PreferenceKey结构准备完毕,到对之前的实现进行更改的时候了:
首先,修改我们的 MonthView。我们将使用 GeometryReader来获取文本大小和位置。这些值需要转换到要绘制边界的视图的坐标空间。视图可以通过应用修饰符. coordinatespace(name:"name")来命名它们的坐标空间。一旦我们有转换的rect,遍设置相应的首选项:
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")))])
}
}
}
接着,我们为边框创建一个独立的视图。这个视图将改变它的offset和frame来匹配最后点击的视图对应的rect:
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))
最后,我们需要确保当首选项更改时,我们会适当地更新rects数组。例如,当设备旋转,或窗口改变大小,这段代码将被调用:
.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")))])
}
}
}
合理使用首选项
在使用视图首选项时,可以使用来自子节点的几何信息,以布局其祖先节点之一。如果是这种情况,你应该谨慎行事。如果祖先对子元素布局的变化做出相应,而子元素又对祖先元素的变化做出响应,那么就有陷入递归循环的风险。
你可能会经历各种异常情况,如:应用程序挂起、屏幕闪烁,试图连续重绘、很可能CPU占用过高。所有这些都可能表明你误用了首选项。
例如,在VStack中有两个视图,并且顶部的视图(a)根据第二个视图(B) y 位置设置它的高度,这你就为自己埋下一个循环炸弹💣啦。
避免这个问题有一些方法,重点是:使用合适的布局工具使祖先元素不影响子元素。一些好的技术有:ZStack、.overlay()、.background()或几何效果(geometry effects)等等。我们将在以后的文章中讨论几何效应。
接下来
在本文中,我们使用 GeometryReader 来窃取月份标签的几何信息。还有一种更好的方法,那就是使用锚点首选项(Anchor Preferences)。在接下来的部分中,我们将学习使用它,并且还将深入了解 SwiftUI 是如何遍历树的。还有一种使用首选项的另一种方法,而不是. onpreferencechange()。这个我们也将会讲到。下一部分已经在这里提供了。
在你离开之前,我还想再多说一句,当你开始广泛使用首选项时,代码可能会变得混乱并变得难以阅读。如果到了这个地步,我建议你将首选项封装到视图扩展中。我最近发表了一篇文章,解释了如何做。有关更多信息,查阅扩展视图以获得更好的代码可读性。