审视视图树——第2部分:AnchorPreferences

793 阅读5分钟

原文:swiftui-lab.com/communicati…

在本文的第一部分中,我们介绍了首选项的使用。这些对于向上传递信息(孩子 —> 祖先)非常有用。通过自定义PreferenceKey的关联类型,我们现在知道可以将任何想要的内容放入其中。

在第二部分中,我们要聊聊锚点首选项。在写这篇文章的时候,我找不到一篇可以解释如何使用这些难以捉摸的工具的文档、博客或文章。所以请和我一起探索这个未知的领域。

锚点首选项并不那么直观,但是一旦我们深入了解它们,就再也离不开了。为了简单起见,我们将处理第一部分中需要解决的问题。庆幸的是,你已经熟悉了这个挑战,所以可以将精力集中在所有这些令人兴奋的新特性上。与之前的解决方案不同,我们将不再使用空间坐标,我们将用其他东西替换. onpreferencechange()

接下来,让我们再次:创建一个边界,使用动画从一个月视图移动到另一个:

Example

锚点首选项(Anchor Preferences)

双手合十,让我们热烈欢迎:Anchor<T>。它一个不透明类型,值类型为T,T可以是CGRect,也可以是CGPoint。我们通常使用Anchor<CGRect>来访问视图的边界,使用Anchor<CGPoint>来访问其他视图属性,如toptopLeadingtopTrailingcentertrailingbottombottomLeadingbottomTrailingleading

因为它是一个不透明的值,故不能单独使用。还记得(GeometryReader to the Rescue)中的GeometryProxy的下标(subscript)吗?现在你知道它是干什么的了吧。当使用Anchor<T>值作为几何代理的索引时,将得到所表示的 CGRectCGPoint 值。同时,你也把它转换成 GeometryReader视图的坐标空间。

我们首先修改由 PreferenceKey 处理的数据。在这种情况下,我们用Anchor<CGRect>替换CGRect

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

MyTextPreferenceKey保持不变:

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

现在月视图就更简单了。使用修饰词.anchorPreference()代替.preference()。不同于其他方法,这里我们可以指定一个值(在本例中是.bounds)。这意味着我们的转换闭包获得一个Anchor<CGRect>,代表修改后视图的边界。与我们对普通首选项所做的类似,我们使用它($0)来创建MyTextPreferenceData值。这样,我们就不再需要在.background()修饰符中使用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()的修饰符,但是有一个很大的改进:可以访问整个视图树的首选项数组。通过这种方式,我们得到了所有月视图的边界,可以使用它们来计算需要在何处绘制边界。

还有一个地方需要使用 GeometryReader。这是为了理解Anchor<CGRect>的值。请注意,我们不再需要担心坐标空间,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
            return GeometryReader { geometry in
                ZStack(alignment: .topLeading) {
                    self.createBorder(geometry, preferences)
                    
                    HStack { Spacer() } // makes the ZStack to expand horizontally
                    VStack { Spacer() } // makes the ZStack to expand vertically
                }.frame(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<T>值。有 bounds,也有 topLeadingcenterbottom等等。在某些情况下,可能需要获取多个这样的值。但是,正如我们将学习到的,这并不像对它们全部调用 .anchorPreference() 那么简单。让我们举例来阐述一下这个问题。

但这一次,我们不使用Anchor<CGRect>来获取月视图的边框,而是使用两个单独的 Anchor<CGPoint>值。一个用于topLeading,另一个用于bottomTrailing。注意,对于这个特定的问题,获取Anchor<CGRect>是一个更好的方法。然而,我们之所以使用第三种方法,只是为了学习如何在同一个视图中获得多个锚点首选项。

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

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)] }) // get .topLeading
            .transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
                value[0].bottomTrailing = anchor
            }) //// get .bottomTrailing
            
            .onTapGesture { self.activeMonth = self.idx }
    }
}

最后,我们更新 .createBorder(),它用这两个点代替rect来进行计算:

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
            return GeometryReader { geometry in
                ZStack(alignment: .topLeading) {
                    self.createBorder(geometry, preferences)
                    
                    HStack { Spacer() } // makes the ZStack to expand horizontally
                    VStack { Spacer() } // makes the ZStack to expand vertically
                }.frame(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。至于为什么这么做,后面有详细介绍。

下一步

在本系列的最后一部分中,我们将使用一个不同的示例来说明这一点。我们要创建一个迷你地图视图。迷你地图将通过读取表单的树视图来构建。我们还将看到修改表单的树视图如何对迷你地图产生直接影响,它只对表单树视图的首选项的更改作出响应。

可以先睹为快:

Mini Map