[SwiftUI-Lab] 探究View树 part-1 PreferenceKey

3,224 阅读9分钟

文章源地址:swiftui-lab.com/geometryrea…

作者: Javier

翻译: Liaoworking

探究View树 part-1 PreferenceKey

译者学习心得:什么是PreferenceKey?

单纯从字面意思去理解,很难了解到什么是PreferenceKey。偏好钥匙?

这里其实可以把Key理解成key-value的key

通过一个key去找到对应的偏好value,那么又什么是偏好?

偏好我们可以理解成View的一些属性。

那么就很容易知道PreferenceKey是什么意思了。

-------下面是翻译正文------

在SwiftUI中我们一般不用关心子级视图内部发生了什么。不同的View各自管各自内部的事情。但总是会遇到一些特殊的需求。比较惨的是文档都讲的比较粗略。 探究View树的三篇文章会做个补充。我们将要去了解 PreferenceKey 的协议和相关的修改器(modifier):如

.preference(), .transformPreference(), .anchorPreference(), .transformAnchorPreference(), .onPreferenceChange(), .backgroundPreferenceValue() .overlayPreferenceValue(). 有很多,那我们开始吧~

SwiftUI有一个让我们去给View添加很多属性的机制。这些属性我们叫做 偏好(Preferences) 。 它们可以轻松的沿视图依次调用下去,甚至无论怎么修改偏好,添加的回调都会不受影响的执行。

有没有想过navigationView是如何通过 .navigationBarTitle() 来获取title。请注意 .navigationBarTitle() 并没有直接修改NavigationView。而是在沿着View的层级去调用。那么它是怎么做到的呢? 可能你已经猜到了。其实是用了偏好。在2019WWDC的SwiftUI专栏里有一个很简短的介绍。大概只有20秒。感兴趣的话可以查看Session 216 (SwiftUI Essentials)直接跳到52:35。

我们已经找到有一些特殊的偏好 叫"anchored preferences(锚定偏好)", 可以利用它们来方便的检索子级View的所有几何学数据。在下中会详细介绍锚定偏好(anchored preferences)

独立的Views

我们将会用很短的时间去了解 PreferenceKey ,为了更好的了解今天的话题,我们先用一个没有使用偏好的例子开始。在例子中,先创建一个显示月份名的View。当月份标签被点击的时候,会在月份标签上面慢慢的显示一个边框(从之前选中的月份标签移除)。

image
例1

代码很简单,先创建我们的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()
        }
    }
}

和自定义views:

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 为最新点击的月份标签的序号。 而且每个月份边框的颜色都由自己的变量来控制。 如果月份标签被选中,边框会被设置成红色,否则边框就会变透明。这个例子很简答,每个View绘制自己的边框。


相互协作的Views

下面难度再升级一些,我们想让边框从一个月份移动到另外一个。

image
例子2

你可以先想想如何去实现,不像之前有12个边框,现在只有一个边框,你需要动画改变边框的位置和大小。

例子2中,边框并不是月份的一部分,你需要创建一个单独的边框View,并相应的改变位置和大小,这意味着必须有一种方式去跟踪每个月份的大小和位置。

如果你看过我上一批文章(GeometryReader to the Rescue), 你就已经有一种方式去解决这个问题了,如果你不知道GeometryReader是怎么工作的,可以先看看这篇文章。

解决这个问题的一种方式就是: 每一个月份标签都通过GeometryReader去获得自身的大小和位置。每个月份标签依次更新父级视图中的存放位置的数组(通过 @Binding )。 一旦父级视图找到了每一个子视图的位置和大小,边框就可以很容易的替换了。这个方案还不错,但子级视图修改数组的时候可能会产生问题。

对于某些布局,如果在构建视图的时候,修改其某个变量,其父级视图也会受到影响,反过来子级视图也会受到影响。这使我们正在构建的视图失效,有时可能需要再重新开始构建视图。 还有时候会变成一个循环。好的是SwiftUI视乎可以检测到这种情况,也不会产生崩溃。它会给你一个运行时的警告: Modifying state during view update(当视图更新的时候修改视图). 快速修复这个问题的方法是延迟变量的改变,直到视图的更新完成:

DispatchQueue.main.async {
  self.rects[k] = rect
}

不过这好像有点取巧(hack), 虽然这起作用了,但只是一个暂时的解决方案。不确定以后会不会起作用。 有点对框架底层的原理下赌注的意思了。幸运的是 PreferenceKey 可以解决。

PreferenceKey的介绍

SwiftUI 提供给我们一个修改器让我们添加一些数据到某个具体的视图。我们可以通过顶级视图(ancestor view)查询这些数据。并且有多种方式去读取PreferenceKey。这取决于你的目的是怎样的。无论怎样,偏好似乎就是我们想要的,那我们先试试来解决我们的问题。

我们可以通过下面的例子来知道通过preferences来暴露哪些信息。

1.去标记一些view,这里我们通过Int值0..11去标记,其实你可以用任何值都可以标记的。

2.获取文本框的CGRect.

我们先命名一个遵守 Equatable 协议的MyTextPreferenceData的结构体。

struct MyTextPreferenceData: Equatable {
    let viewIdx: Int
    let rect: CGRect
}

然后我们定义一个遵循 PreferenceKey 的结构体MyTextPreferenceKey。

struct MyTextPreferenceKey: PreferenceKey {
    typealias Value = [MyTextPreferenceData]

    static var defaultValue: [MyTextPreferenceData] = []
    
    static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

我强烈建议你阅读一些PreferenceKey的文档,遵守协议后你必须要实现如下:

  • value 我们想要通过PreferenceKey获得什么类型的一个别名,例子中我们用的是[MyTextPreferenceData]数组。
  • defaultValue 没有显式设置首选项时,SwiftUI会用这个默认值。
  • reduce 用来覆盖在视图树中找到的所有键值对,是一个静态函数。通常你可以用来累加接收到的所有值。在我们的例子中,当SwiftUI遍历视图树时,会把所有preference键值对存储在一个数组中。下面我们会讲。你应该清楚 值是按照视图树的顺序给reduce函数的 我们会在另外一个例子中讨论。

我们现在有了 PreferenceKey 了,开始对之前的代码就行修改。

先修改MonthView, 通过GeometryReader来获取文字的大小和位置,这些值需要转换一下坐标系,才能绘制出正确的边框。视图可以通过修改器来命名它们的空间坐标系 .coordinateSpace(name: "name")。 一旦我们转换了rect,我们也要相应的设置preference

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))

最后,我们只要保证当preferences改变的时候,我们相应的关系rect数组。 例如当设备旋转,或者window的大小改变, 下面的代码都会被调用:

.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")))])
        }
    }
}

明智地使用Preferences(首选项)

当我们使用preferences,可能会使用子级视图的几何信息来布局它们的一个顶层视图(ancestors),如果是这样的话,你应该注意。 如果顶层视图影响了子级视图的布局,反过来子级视图也会影响顶层视图,就会陷入一个递归循环中。

可能有时候程序会卡死,或者屏幕会闪动来持续的重新绘制。或者CPU会达到一个峰值,这些都会暗示你错误的使用了preferences。

例如你在VStack中有两个视图,上面的视图高度依据下面视图的y值。 可能就会给你带来循环。

为了解决这个问题,用一些布局工具使得顶层视图不要影响子级视图,一些好的方案就是: ZStack, .overlay(), .background() 或者几何影响(geometry effects). 我们将在即将发布的文章中去讨论 几何影响 (GeometryEffect)

下一步是什么

这篇文章中我们通过GeometryReader来“窃取”了月份标签中的几何信息,然而我们可以通过锚定的偏好(Anchor Preferences)来更好的实现它。 在下面的文章中我们将继续学习它。而且我们将深入究竟SwiftUI是怎样遍历树的。其实也可以不通过.onPreferenceChange() 来使用preferences。下篇文章中也有讲解。

当你一开始去使用preferences的时候,可能你的代码又乱又难阅读。我觉得你应该在View的extension中封装好preferences,我之前写过的一篇文章有讲过怎么去做。你可以查看让你代码变的更好的View extension