译自 swiftui-lab.com/communicati…
更多内容,欢迎关注公众号「Swift花园」
在本系列的前一部分中,我们介绍了 SwiftUI 的锚点偏好。现在我们终于要走出迷雾森林了。在这最后一部分,我们将把所有的东西组合到一起。我们还将学习 SwiftUI 如何处理嵌套视图的偏好,以及 Anchor 的一些其他用法。如常,先例为敬:
我们的目标是创建一个迷你地图的视图,它会反映一个表单的状态:
关于这个例子,有几点需要注意:
注意,迷你地图对表单一无所知。它只对视图层级的偏好的变化做出反应。
首先定义一些类型。既然视图树会包含多种视图,我们就需要某种东西对它们进行区分。出于这个目的,我们先定义一个枚举:
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 }复制代码
然后定义我们要往偏好中设置的数据类型,并且添加一些之后会用到的方法。数据类型将包含两个属性(vtype
和 bounds
):
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 似乎遵循了两个简单的规则:
正如我们看到的,一个 Anchor<T>.Source
可以通过一些静态变量获得,比如 .bounds
,.topLeading
,.bottom
,等等。通常我们会把它们传入 anchorPreference()
modifier 的参数。不过,你也可以用 Anchor<T>.Source
的静态方法创建自己的 Anchor<CGRect>.Source
和 Anchor<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及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~