探究视图树-part2 AnchorPreferences

1,398 阅读5分钟
译自 Inspecting the View Tree (Anchor Preferences) - Part 2 - The SwiftUI Lab
更多内容,欢迎关注公众号「Swift花园」

第一部分 的文章中,我们介绍了 preference 的使用。对于向上传递信息的场景,这个东西非常有用。通过定义 PreferenceKey 的关联类型,我们可以把任何东西放进那里。

在第二部分,是时候介绍 Anchor Preferences 了。写这篇文章的时候,我找不到任何相关文档,博客或者文章介绍如何使用这些难以理解的工具。所以,请你们加入我,一起探索这未知的疆域。

直觉上一开始 Anchor Preference 不好理解,但一旦我们掌握了它们,就很难忘记了。为了让事情简单一点,我们还是以第一部分里的例子来讲解。如果你对于挑战本身已经很熟悉,那当然很好,这样你就能专注于所有这些新特性。不像之前的解决方案,我们不再用到坐标系,并且会把 .onPreferenceChange() 换成别的东西。

那个例子又来了:我们要让边框从一个月份移动到另一个月份,带有动画效果:


锚点偏好(Anchor Preference)

现在让我们热烈欢迎: Anchor。这是一个存放类型 T 的不透明类型,T 可以是 CGRect 或者 CGPoint。我们通过用 Anchor 来访问视图的边界,用 Anchor 访问诸如 top,topLeading,topTrailing,center,trailing,bottom,bottomLeading,bottomTrailing 和 leading 等视图属性。

因为它是一个不透明类型,所以我们不能单独使用它。还记得 GeometryReader to the Rescue一文中介绍过 GeometryProxy 的下标 getter 吗?当我们使用 Anchor 的值作为 geometry proxy 的索引时,你可以得到表示 CGRect 或者 CGPoint 值。同时,它们已经被转换成 GeometryReader 视图的坐标空间。

我们先通过修改 PreferenceKey 处理的数据开始吧。在这个例子中,我们要用 Anchor 替换掉 CGRect:

struct MyTextPreferenceData {
    let viewIdx: Int
    let bounds: Anchor<CGRect>
}

我们的 PreferenceKey 保持不变:

struct MyTextPreferenceKey: PreferenceKey {
    typealias Value = [MyTextPreferenceData]

    static var defaultValue: [MyTextPreferenceData] = []

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

MonthView 现在变得更简洁了。我们不再使用 .preference(),而是调用 modifier .anchorPreference()。不像其他方法,这里我们指定一个值(在例子里是 .bounds),它表示我们的 transform 闭包拿到一个表示被修改视图的边界的 Anchor 。跟常规属性相似的处理方式,我们用 ($0) 来创建我们的 MyTextPreferenceData 值。这样一来,我们就不再需要在 .background() modifier 里使用 GeometryReader 来获取文本视图的边界了。

为了便于你理解,我们还是看看代码:

struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int

    var body: some View {
        Text(label)
            .padding(10)
            .anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceData(viewIdx: self.idx, bounds: $0)] })
            .onTapGesture { self.activeMonth = self.idx }
    }
}

最后,我们来更新 ContentView。这里有一些变化。首先,我们不再使用 .onPreferenceChange(),而是调用 .backgroundPreferenceValue()。这是一个类似 .background() 的 modifier,但它有一个很大的改进:可以访问整个视图树的偏好数组。通过这种方式,我们得到了所有月份视图的边界,可以使用它们来计算需要在何处绘制边界。

还有一个地方需要使用 GeometryReader,这是为了理解 Anchor 的值。注意,我们不再需要关心坐标空间了,GeometryReader 会处理它。

struct ContentView : 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()
        }.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
            GeometryReader { geometry in
                self.createBorder(geometry, preferences)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }
        }
    }

    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {

        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })

        let bounds = p != nil ? geometry[p!.bounds] : .zero

        return RoundedRectangle(cornerRadius: 15)
                .stroke(lineWidth: 3.0)
                .foregroundColor(Color.green)
                .frame(width: bounds.size.width, height: bounds.size.height)
                .fixedSize()
                .offset(x: bounds.minX, y: bounds.minY)
                .animation(.easeInOut(duration: 1.0))
    }
}

.backgroundPreferenceValue() 对应 .overlayPreferenceValue()。它的功能相同,只不过不是在后面绘制,而是在视图前面绘制。


带有一个 PreferenceKey 的多个锚点偏好

我们已经知道不止有一种 Anchor 值。有 bounds,也有 topLeadingcenterbottom,等等。可能有时候我们需要获取多个这样的值。但是,正如我们将学习到的,这并不像对所有这些值调用 .anchorPreference() 那么简单。为了说明这一点,我们再来解决一遍那个问题。

但这一回我们不用 Anchor 来获取月份视图的边界,而是使用两个单独的 Anchor 值。其中一个用于 topLeading,而另一个用于 bottomTrailing。提醒一下,对于这个特定问题,采用 Anchor 是更好的方案。不过我们这里采用第三种方法,只是为了学习如何在同一个视图上获取多个锚点偏好。

我们首先修改 MyTextPreferenceData 来容纳矩形的两个端点。这次我们需要把它们设置为可选型,因为两者不能被同时设置。

struct MyTextPreferenceData {
    let viewIdx: Int
    var topLeading: Anchor<CGPoint>? = nil
    var bottomTrailing: Anchor<CGPoint>? = nil
}

PreferenceKey 保持不变:

struct MyTextPreferenceKey: PreferenceKey {
    typealias Value = [MyTextPreferenceData]

    static var defaultValue: [MyTextPreferenceData] = []

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

我们的月份视图限制需要设置两个锚点偏好。但是,如果我们对同一个视图调用超过一次 .anchorPreference(),那么只有最后一次会生效。因此,我们只能调用一次 .anchorPreference(),然后用 .transformAnchorPreference() 填充缺失的数据:

struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int

    var body: some View {
        Text(label)
            .padding(10)
            .anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] })
            .transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
                value[0].bottomTrailing = anchor
            })

            .onTapGesture { self.activeMonth = self.idx }
    }
}

最后,我们相应地更新 .createBorder(),令它基于两个点代替矩形来计算:

struct ContentView : 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()
        }.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
            GeometryReader { geometry in
                self.createBorder(geometry, preferences)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }
        }
    }

    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {
        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })

        let aTopLeading = p?.topLeading
        let aBottomTrailing = p?.bottomTrailing

        let topLeading = aTopLeading != nil ? geometry[aTopLeading!] : .zero
        let bottomTrailing = aBottomTrailing != nil ? geometry[aBottomTrailing!] : .zero


        return RoundedRectangle(cornerRadius: 15)
            .stroke(lineWidth: 3.0)
            .foregroundColor(Color.green)
            .frame(width: bottomTrailing.x - topLeading.x, height: bottomTrailing.y - topLeading.y)
            .fixedSize()
            .offset(x: topLeading.x, y: topLeading.y)
            .animation(.easeInOut(duration: 1.0))
    }
}


嵌套的视图

目前为止,我们一直在处理兄弟视图(或者表亲视图)。但是,当我们要给嵌套的视图设置偏好时,事情就变得更有挑战性了。这个时候 .transformAnchorPreference() 变得更加重要。例如,你有两个视图,分别是父级和子级,在两者上都设置 .anchorPreference() 将不起作用。子视图的闭包将不会执行。要解决这个问题,需要在子视图使用 anchorPreference() 在父视图上使用 transformAnchorPreference()。至于为什么要这么做,我们详细说明。


下一步

这个系列的最后一步,我们要使用一个不一样的例子来讲解。我们要创建一个迷你地图。这个迷你地图通过读取一个表单的视图树来构建。我们还将看到修改表单的视图树如何对迷你地图产生直接影响,它只对表单视图树的偏好变化做出响应。

先睹为快:



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