探究视图树 – Part 1: PreferenceKey

1,552 阅读10分钟

译自 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 可以依赖。


介绍 PreferenceKey

SwiftUI 提供了一个 modifier,让我们可以添加一些数据到我们自己选择的任意特定视图上。这些数据之后能被顶级视图查询到。读取这些属性的方式有很多种,取决于你的目的。不管怎么说,看起来 preference 正是我们要找的东西,让我们先试着解决我们的问题:

首先我们要确定我希望通过属性暴露哪些信息。在我们的例子中,我们需要:

  • 某种可以标识视图的东西。这里我们采用一个 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:一个指代我们希望通过属性暴露的信息类型的别名,在这个例子里是 MyTextPreferenceData 数组。我稍后再来介绍它。
  • defaultValue: 当一个 preference key 没有被显式设值时,SwiftUI 会使用这个 defaultValue。
  • reduce: 这是一个静态函数,SwiftUI 用它来合并在视图树中找到的所有键值对。通常你是累加所有接收到的值,但你也可以按照任意方式处理。在我们的例子中,当 SwiftUI 遍历视图树时,它会收集所有的 preference 键值对,把它们存放在一个数组中,之后可以给我们访问。你应该了解的是,Values 是以视图树的顺序提供给 reduce 函数的对此我们会在另一个例子中讨论。

现在,我们已经建立了 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

在使用视图 preference 时,你可能会使用子视图里的几何信息,以便布局它的某个先祖视图。如果是这样的话,你应该小心处理。如果先祖视图会对子视图的布局做出反应,而子视图也会对先祖视图的变化做出反应,那你将陷入一个无限循环。

你可能会遭遇不同的后果,有的时候是程序卡死,有的时候是屏幕持续重绘从而闪动,或者 CPU 很有可能到达峰值。所有这些现象可能暗示你错误地使用了 preference。

举个例子,假设你在一个 VStack 里有两个视图,上面的视图基于下面视图的 y 值设置高度,那你就是在给自己找来循环。

为了避免这类问题,你可以借助布局工具让先祖视图不要影响子视图。一些很好用的方案包括:ZStack.overlay().background()或者几何效果等。我们将在另一篇GeometryEffect 的文章中讨论。


下一步

这篇文章中我们通过 GeometryReader “窃取”了月份标签的几何信息。不过,通过使用Anchor Preferences,我们还可以优化实现方案。在接下来的文章中,我们将学习它,同时也会深入探究 SwiftUI 是如何遍历视图树的。其实不诉诸.onPreferenceChange(),我们也有别的方式可以使用 preference。下篇文章也会讨论。

当你推进之前,我希望你留意到,当你开始广泛使用 Preference 时,你的代码可能会变得难以阅读。我建议你在视图扩展中封装这些 preferences。最近我还写过一篇文章,专门介绍怎么做。获取更多详细的信息,你可以去查看View Extensions for Better Code Readability


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~