审视视图树——第3部分:嵌套视图

236 阅读4分钟

原文:swiftui-lab.com/communicati…

处理嵌套视图的首选项

在本系列的前一部分中,我们介绍了 SwiftUI 锚点首选项。现在我们终于要走出迷雾森林了,在这最后一部分,我们将把所有的东西组合在一起。我们还将学习 SwiftUI 如何处理嵌套视图的首选项,以及Anchor<T>的其他一些用法。像往常一样,先例为敬:

我们现在的目标是创建一个迷你地图视图,它将反映表单的状态:

img

关于这个例子,有几点需要注意:

  • 迷你地图显示了按比例缩小的表单展现形式。不同的颜色将表示标题视图、文本字段和文本字段容器。
  • 随着标题文本视图的增长,迷你地图会做出相应的变化。
  • 当我们添加一个新视图(如twitter field),迷你地图也会改变。
  • 当表单中的 frame 发生变化时,迷你地图也会更新。
  • 当文本字段颜色为红色时,表示没有输入,黄色表示少于3个字符,绿色表示大于等于3个字符。

请注意,迷你地图对表单一无所知。它将只对视图层次结构的首选项中的更改作出响应。

让我们开始编码

首先定义一些类型,因为我们的视图树将有多种视图,所以需要对它们进行区分。为此,我们首先定义一个枚举:

enum MyViewType: Equatable {
    case formContainer // main container
    case fieldContainer // contains a text label + text field
    case field(Int) // text field (with an associated value that indicates the character count in the field)
    case title // form title
    case miniMapArea // view placed behind the minimap elements
}

在定义要在首选项中设置的数据类型时,添加了一些稍后使用的方法。数据类型将有两个属性(vtypebounds):

struct MyPreferenceData: Identifiable {
    let id = UUID() // required when using ForEach later
    let vtype: MyViewType
    let bounds: Anchor<CGRect>
    
    // Calculate the color to use in the minimap, for each view type
    func getColor() -> Color {
        switch vtype {
        case .field(let length):
            return length == 0 ? .red : (length < 3 ? .yellow : .green)
        case .title:
            return .purple
        default:
            return .gray
        }
    }
    
    // Returns true, if this view type must be shown in the minimap.
    // Only fields, field containers and the title are shown in the minimap
    func show() -> Bool {
        switch vtype {
        case .field:
            return true
        case .title:
            return true
        case .fieldContainer:
            return true
        default:
            return false
        }
    }
}

像往常一样定义一个PreferenceKey

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

接下来就有趣了!我们有许多字段,每个字段前面都有一个文本标签,并由一个容器包围。用一个名为 MyFormField 的视图封装这个重复模式。此外,我们还相应地设置首选项。由于文本字段是包含VStack的一个子字段,而且需要两个嵌套视图的边框,所以不能两次都调用anchorPreference()。在Vstack上调用anchorPreference()会阻止对TextField的调用。所以,我们在VStack上使用transformanchorpreference()。通过这样,我们添加数据,而不是替换它:

// This view draws a rounded box, with a label and a textfield
struct MyFormField: View {
    @Binding var fieldValue: String
    let label: String
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(label)
            TextField("", text: $fieldValue)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                    return [MyPreferenceData(vtype: .field(self.fieldValue.count), bounds: $0)]
                }
        }
        .padding(15)
        .background(RoundedRectangle(cornerRadius: 15).fill(Color(white: 0.9)))
        .transformAnchorPreference(key: MyPreferenceKey.self, value: .bounds) {
            $0.append(MyPreferenceData(vtype: .fieldContainer, bounds: $1))
        }
    }
}

我们的ContentView将所有视图放在一起。你会看到,这里我们设置了稍后用于迷你地图中的三个首选项。我们正在收集表单标题、表单区域和小地图区域的边界:

struct ContentView : View {
    @State private var fieldValues = Array<String>(repeating: "", count: 5)
    @State private var length: Float = 360
    @State private var twitterFieldPreset = false
    
    var body: some View {
        
        VStack {
            Spacer()
            
            HStack(alignment: .center) {
                
                // This view puts a gray rectangle where the minimap elements will be.
                // We will reference its size and position later, to make sure the mini map elements
                // are overlayed right on top of it.
                Color(white: 0.7)
                    .frame(width: 200)
                    .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                        return [MyPreferenceData(vtype: .miniMapArea, bounds: $0)]
                    }
                    .padding(.horizontal, 30)
                
                // Form Container
                VStack(alignment: .leading) {
                    // Title
                    VStack {
                        Text("Hello \(fieldValues[0]) \(fieldValues[1]) \(fieldValues[2])")
                            .font(.title).fontWeight(.bold)
                            .anchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                                return [MyPreferenceData.init(vtype: .title, bounds: $0)]
                        }
                        Divider()
                    }
                    
                    // Switch + Slider
                    HStack {
                        Toggle(isOn: $twitterFieldPreset) { Text("") }
                        
                        Slider(value: $length, in: 360...540).layoutPriority(1)
                    }.padding(.bottom, 5)

                    // First row of text fields
                    HStack {
                        MyFormField(fieldValue: $fieldValues[0], label: "First Name")
                        MyFormField(fieldValue: $fieldValues[1], label: "Middle Name")
                        MyFormField(fieldValue: $fieldValues[2], label: "Last Name")
                    }.frame(width: 540)
                    
                    // Second row of text fields
                    HStack {
                        MyFormField(fieldValue: $fieldValues[3], label: "Email")
                        
                        if twitterFieldPreset {
                            MyFormField(fieldValue: $fieldValues[4], label: "Twitter")
                        }
                        
                        
                    }.frame(width: CGFloat(length))

                }.transformAnchorPreference(key: MyPreferenceKey.self, value: .bounds) {
                    $0.append(MyPreferenceData(vtype: .formContainer, bounds: $1))
                }

                Spacer()
                
            }
            .overlayPreferenceValue(MyPreferenceKey.self) { preferences in
                GeometryReader { geometry in
                    MiniMap(geometry: geometry, preferences: preferences)
                }
            }
            
            Spacer()
        }.background(Color(white: 0.8)).edgesIgnoringSafeArea(.all)
    }
}

最后,迷你地图将遍历所有首选项来绘制每个元素:

struct MiniMap: View {
    let geometry: GeometryProxy
    let preferences: [MyPreferenceData]
    
    var body: some View {
        // Get the form container preference
        guard let formContainerAnchor = preferences.first(where: { $0.vtype == .formContainer })?.bounds else { return AnyView(EmptyView()) }
        
        // Get the minimap area container
        guard let miniMapAreaAnchor = preferences.first(where: { $0.vtype == .miniMapArea })?.bounds else { return AnyView(EmptyView()) }
        
        // Calcualte a multiplier factor to scale the views from the form, into the minimap.
        let factor = geometry[formContainerAnchor].size.width / (geometry[miniMapAreaAnchor].size.width - 10.0)
        
        // Determine the position of the form
        let containerPosition = CGPoint(x: geometry[formContainerAnchor].minX, y: geometry[formContainerAnchor].minY)
        
        // Determine the position of the mini map area
        let miniMapPosition = CGPoint(x: geometry[miniMapAreaAnchor].minX, y: geometry[miniMapAreaAnchor].minY) 
      
        // The following view had to be encapsulated in two separate functions (miniMapView & rectangleView),
        // because beta 5 has a bug that fails to compile expressions that are "too complex".
        return AnyView(miniMapView(factor, containerPosition, miniMapPosition))
    }

    func miniMapView(_ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
        ZStack(alignment: .topLeading) {
            // Create a small representation of each of the form's views.
            // Preferences are traversed in reverse order, otherwise the branch views
            // would be covered by their ancestors
            ForEach(preferences.reversed()) { pref in
                if pref.show() { // some type of views, we don't want to show
                    self.rectangleView(pref, factor, containerPosition, miniMapPosition)
                }
            }
        }.padding(5)
    }
    
    func rectangleView(_ pref: MyPreferenceData, _ factor: CGFloat, _ containerPosition: CGPoint, _ miniMapPosition: CGPoint) -> some View {
        Rectangle()
        .fill(pref.getColor())
        .frame(width: self.geometry[pref.bounds].size.width / factor,
               height: self.geometry[pref.bounds].size.height / factor)
        .offset(x: (self.geometry[pref.bounds].minX - containerPosition.x) / factor + miniMapPosition.x,
                y: (self.geometry[pref.bounds].minY - containerPosition.y) / factor + miniMapPosition.y)
    }

}

关于视图树顺序的说明

我们先暂停,思考下在嵌套视图中执行首选项闭包的顺序。例如,看看小地图的实现。你可能已经注意到,ForEach以相反的顺序运行循环。否则,代表textfield容器的矩形将最后绘制,覆盖它们相应的迷你地图中的textfield。因此,知道如何设置偏好是很重要的。

请注意,没有关于SwiftUI遍历视图树的顺序的文档。在PreferenceKey中的reduce方法声明中,确实提到了以视图树顺序提供的值。但是,它没有告诉我们这个顺序是什么。不过,我们可以确定它不是随机的,而且每次刷新都是一致的。

接下来我所写的关于闭包运行顺序的所有内容,都是通过实验得出的。基本上,我到处都放了断点!不过,既然它看起来很合理,我对它很有信心。

下图显示了视图层次结构的简化表示。为了使图表更易于阅读,省略了一些不必要的视图。红色箭头表示执行anchorPreference()transformanchorpreference()闭包的顺序。注意,并不是所有的闭包都被调用,只是调用了SwiftUI 认为必要的闭包。例如,如果一个视图的边界没有改变,那么它的.anchorpreference()闭包可能不会运行。如果不确定,请放置断点或打印语句进行调试。

View Tree Hierarchy

如图所示,SwiftUI 似乎遵循了两个简单的规则:

  1. 兄弟节点的遍历顺序与它们在代码中出现的顺序相同。
  2. 子视图的闭包在父视图之前执行。

Anchor<T>的其他用途

正如我们所看到的,一个Anchor<T>.Source可以通过一些静态变量获得,如.bounds.toLeading.bottom等。我们通常将它们传递给anchorPreference()修饰符中的值参数。但是,你也可以在Anchor<T>.Source中使用静态方法创建自己的Anchor<CGRect>.SourceAnchor<CGRect>.Source。例如这样:

let a1 = Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))
let a2 = Anchor<CGPoint>.Source.point(CGPoint(x: 10, y: 30))
let a3 = Anchor<CGPoint>.Source.unitPoint(UnitPoint(x: 10, y: 30))

我听到你说的了…, 但是什么时候能用上它呢?如果已有的静态变量都不适合你,那么可以使用它将值传递给首选项。例如它们在处理弹窗时特别方便:

.popover(isPresented: $showPopOver,
         attachmentAnchor: .rect(Anchor<CGRect>.Source.rect(CGRect(x: 0, y: 0, width: 100, height: 50))),
         arrowEdge: .leading) { ... }

让我们总结一下

祝贺你。你坚持到了最后!我希望你喜欢你的新工具,并使用它们变出一些惊人的新应用。可能无极限!