审视视图树——第1部分:PreferenceKey

858 阅读10分钟

原文:swiftui-lab.com/communicati…

对于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,为了更好地理解这个主题,让我们从一个不需要首选项的示例开始。在本例中,每个视图都知道自己要做什么。我们将创建一个显示月份名称的视图。当一个月的标签被点击时,它的周围会出现一个边框(之前选中的则移除)。

img

代码非常简单,没有什么特别之处。首先我们有我们的 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变量--该变量跟踪最后选中的标签。我们还使每个月视图的边框颜色依赖于这个变量。如果视图被选中,则边框颜色设置为红色。否则,边框透明。这种情况很简单,每个视图都绘制自己的边框。

合作的视图

让我们把事情复杂化一点。现在,我们希望边界能够逐月移动,而不是逐渐消失。

img

我希望你暂停一下,思考一下如何解决这个问题。不像前面的例子,你有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")))])
        }
    }
}

接着,我们为边框创建一个独立的视图。这个视图将改变它的offsetframe来匹配最后点击的视图对应的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()。这个我们也将会讲到。下一部分已经在这里提供了。

在你离开之前,我还想再多说一句,当你开始广泛使用首选项时,代码可能会变得混乱并变得难以阅读。如果到了这个地步,我建议你将首选项封装到视图扩展中。我最近发表了一篇文章,解释了如何做。有关更多信息,查阅扩展视图以获得更好的代码可读性