阅读 138

[深入 SwiftUI] 探究视图树-part3 嵌套视图

译自 swiftui-lab.com/communicati…
更多内容,欢迎关注公众号「Swift花园」

处理嵌套视图的偏好

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

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

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

  • 迷你地图显示了表单的微缩形式。不同的颜色代表了标题视图,文本框和文本框容器。
  • 随着文本视图的增长,迷你地图会做出反应。
  • 当我们添加一个新视图(比如,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)

        // -------------------------------------------------------------------------------------------------
        // iOS 13 Beta 5 Release Notes. Known Issues:
        // Using a ForEach view with a complex expression in its closure can may result in compiler errors.
        // Workaround: Extract those expressions into their own View types. (53325810)
        // -------------------------------------------------------------------------------------------------
        // 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 以相反的顺序运行循环。否则,代表文本框容器的矩形应该会最后绘制,覆盖对应的迷你地图中的文本框。因为,搞懂偏好如何被设置是很重要的。

请注意,SwiftUI 如何遍历视图树并没有文档说明。PreferenceKey 方法中的 reduce 方法声明中,确实提到了以视图树顺序提供的值。但是,它没有告诉我们这个顺序是什么。不过,我们可以确定它不是随机的,而且每次刷新都是一致的。
我接下来写的关于闭包运行顺序的每样东西,都是通过实验得出的。基本上,我到处都设置了断点!不过,既然它看起来很合理,我对它也相当有信息。

下图显示了一个视图层级的简化表达。为了使图标更易读,不必要的视图都被省略了。红色箭头表示 anchorPreference()transformAnchorPreference() 闭包执行的顺序。注意,不一定所有的闭包都会被调用,只有那些 SwiftUI 认为必要的闭包才会被调用。举个例子,如果一个视图的边界没有改变,那么它的 .anchorPreference() 闭包可能不会运行。如果不确定,你可以放置断点或者打印语句进行调试:



从图中可以观察到,SwiftUI 似乎遵循了两个简单的规则:

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

Anchor 的其他用途

正如我们看到的,一个 Anchor<T>.Source 可以通过一些静态变量获得,比如 .bounds.topLeading.bottom,等等。通常我们会把它们传入 anchorPreference()modifier 的参数。不过,你也可以用 Anchor<T>.Source 的静态方法创建自己的 Anchor<CGRect>.SourceAnchor<CGPoint>.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 时它们就特别方便:

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


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


关注下面的标签,发现更多相似文章
评论