Created
December 28, 2019 14:39
-
-
Save onevcat/a7ef0d562d2693b084fc8358bc01f890 to your computer and use it in GitHub Desktop.
Revisions
-
onevcat created this gist
Dec 28, 2019 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,848 @@ # 用户体验和布局进阶 PokeMaster app 现在已经是一个具有完整功能的 SwiftUI app 了。麻雀虽小,五脏俱全,在这个示例 app 里,我们涉及到了一个一般性的 iOS app 所需要的大部分内容: - 如何构建内容展示的列表 - 如何构建用户交互的表单 - 如何进行网络请求并把内容展示出来 - 如何响应用户的手势 - 如何在不同页面之间进行导航 - 以及,如何通过一定的架构将所有上面的内容整合起来 这一章里,我们会涉及到一些稍微进阶的内容,包括自定义绘制的方法,和一些 SwiftUI 布局方面的话题。在学习和实际练习里,可能你已经遇到过这样的情况:有时候觉得某个布局难以实现,或者需要不断通过试错,来确定 `View` 的某种布局写法确实有效,甚至或者觉得某些“奇怪”的行为只是由于 SwiftUI 还在初期,所以是一个暂时性的 bug。根据笔者自身的经验,有时候确实是因为 SwiftUI 还不完善,但在更多情况下,这意味着你还没有真正理解 SwiftUI 的布局和工作方式。 本章将会试图带领你去触及 SwiftUI 一些表层之下的更深入的话题,包括自定义的 `Path` 绘制和动画,SwiftUI 布局的原理,`View` 对齐方式和基础等。不过由于 SwiftUI 的文档并不丰富,本书写作时,很多内容的注解在 Apple 开发者网站上也还是一片空白。所以这些内容更多的是基于尝试后的猜测和经验总结。如果你想获取更精确和深入的理解,还是需要自己动手加以实践。 ## 自定义绘制和动画 SwiftUI 提供了很多常见的标准 UI 控件,比如 `Button`、`Text`、`Image`、`Toggle` 等等。如果我们想要更复杂和更自由的 UI,可能就需要进行一些自定义绘制。 ### Path 和 Shape `Shape` 协议是自定义绘制中最基本的部分,它只要求一个方法,即给定一个 `CGRect` 的绘制范围,返回某个 `Path`。 ```swift-example public protocol Shape : Animatable, View { func path(in rect: CGRect) -> Path } ``` SwiftUI 提供的部分形状,比如 [`Circle`](https://developer.apple.com/documentation/swiftui/circle) 或者 [`Rectangle`](https://developer.apple.com/documentation/swiftui/rectangle),都是 `Shape` 协议的具体实现。如果你对传统 iOS 开发中的 [Core Graphics](https://developer.apple.com/documentation/coregraphics) 有所了解的话,可能对“给定 `CGRect`,请开始你的绘制”这种模式感到熟悉。`Shape` 的 `path(in:)` 和 `UIView` 的 [`drawRect:`](https://developer.apple.com/documentation/uikit/uiview/1622529-draw) 方法如出一辙。而具体的绘制也很类似,Core Graphics 为我们提供的最基本的在上下文中绘制线段和圆弧的 API,在 SwiftUI 的 `Path` 中也能找到等价的方法。比如下面的代码就定义绘制了一个底部为圆弧的三角形箭头:  ```swift-example struct TriangleArrow: Shape { func path(in rect: CGRect) -> Path { Path { path in // 1 path.move(to: .zero) // 2 path.addArc( center: CGPoint(x: -rect.width / 5, y: rect.height / 2), radius: rect.width / 2, startAngle: .degrees(-45), endAngle: .degrees(45), clockwise: false ) // 3 path.addLine(to: CGPoint(x: 0, y: rect.height)) path.addLine(to: CGPoint(x: rect.width, y: rect.height / 2)) // 4 path.closeSubpath() } } } // 5 TriangleArrow() .fill(Color.green) .frame(width: 80, height: 80) ``` > 上面对于 `TriangleArrow` 的绘制只是为了说明基本的 `Path` API 中添加线段和圆弧的方式,请不要纠结于具体的数字或者圆弧角度。 1. 为了完成满足 `Shape` 所需的 `path(in:)` 方法,直接创建一个 `Path` 结构体是最灵活和普遍的做法。第一步我们将绘制起点设定在 `rect` 的零点 (左上)。 2. 添加一段圆弧。 3. 为 `path` 添加线段。 4. 最后一段线段不需要手动添加,可以直接使用 `closeSubpath` 让绘制回到原点,从而得到闭合曲线。 5. 由于 `Shape` 是一个遵守 `View` 的协议,所以我们可以直接按照其他 `View` 同样的方式来使用它。使用 `.fill` 进行单色 (比如例子中的 `Color.green`) 或者渐变 (比如在之前章节介绍过的 `LinearGradient`) 填充。最后,`.frame` 会给 `Shape` 一个参考的尺寸,我们会在下一节中涉及到更多关于 `frame` 的话题。 ### Geometry Reader 有时候,我们会想要在 `View` 里进行一些更精细的尺寸及布局计算,这需要获取一些布局的数值信息:比如当前 `View` 可以使用的 `height` 或者 `width` 是多少,需不需要考虑 iPhone X 系列的安全区域 (safe area) 等。SwiftUI 中,我们可以通过 `GeometryReader` 来读取 parent `View` 提供的这些信息。和 SwiftUI 里大部分类型一样,`GeometryReader` 本身也是一个 `View`,它的初始化方法需要传入一个闭包,这个闭包也是一个 `ViewBuilder`,并被用来构建被包装的 `View`。和其他常见 `ViewBuilder` 不同,这个闭包将提供一个 `GeometryProxy` 结构体: ```swift-example struct GeometryReader<Content> : View where Content : View { init(@ViewBuilder content: @escaping (GeometryProxy) -> Content) //... } ``` `GeometryProxy` 中包括了 SwiftUI 中父 `View` 层级向当前 `View` **提议**的布局信息,它会为 Content `View` 提供一个上下文 (关于 SwiftUI 布局的过程和更多详细信息,我们会在下一小节进行更多介绍)。`GeometryProxy` 的定义如下: ```swift-example public struct GeometryProxy { public var size: CGSize { get } public subscript<T>(anchor: Anchor<T>) -> T { get } public var safeAreaInsets: EdgeInsets { get } public func frame( in coordinateSpace: CoordinateSpace ) -> CGRect } ``` 在本章中,我们只会涉及 `size`,它表示 SwiftUI 布局系统所能提供的尺寸。这让我们可以按照尺寸自适应缩放构建 UI。比如我们想要按照下面的尺寸百分比进行布局:  可以使用下面的代码: ```swift-example struct FlowRectangle: View { var body: some View { GeometryReader { proxy in VStack(spacing: 0) { Rectangle() .fill(Color.red) .frame(height: 0.3 * proxy.size.height) HStack(spacing: 0) { Rectangle() .fill(Color.green) .frame(width: 0.4 * proxy.size.width) VStack(spacing: 0) { Rectangle() .fill(Color.blue) .frame(height: 0.4 * proxy.size.height) Rectangle() .fill(Color.yellow) .frame(height: 0.3 * proxy.size.height) } .frame(width: 0.6 * proxy.size.width) } } } } } ``` `FlowRectangle` 自身并不知道自己会被放置在多大的“画布”上,使用 `GeometryReader` 包装后,让内层 `View` (本例中为最外的 `VStack` 和它的所有子 `View`) 可以根据外层尺寸自适应调整大小。在使用 `FlowRectangle` 时,我们一般会将它限制在某个 `frame` 里,让内部 `View` 读取 `GeometryProxy` 的内容来确定最终尺寸。 ### 六边形绘制实例 在了解了 `Shape` 和 `GeometryReader` 后,我们可以看一个实际的例子。在 PokeMaster app 的详细信息面板,其实在最初的设计稿中还有一个表示宝可梦能力的雷达图,即下图红框部分:  SwiftUI 里并没有这种六边形的内建形状,所以我们需要自己对它进行绘制。 为了简明,下面的代码只展示了布局和绘制 API 的使用,不会具体解释绘制逻辑和线段位置的计算 (它们涉及到一些基本的三角函数)。如果你对完整的代码感兴趣的话,可以查阅随书 "source/PokeMaster-Finished" 里 "RadarView.swift" 的源码。 ```swift-example struct RadarView: View { // ... var body: some View { // 1 GeometryReader { proxy in ZStack { // 2 Hexagon( values: Array(repeating: self.max, count: 6), max: self.max ) .stroke( style: StrokeStyle( lineWidth: 2, dash: [6,3])) .foregroundColor(self.color.opacity(0.5)) // 3 Hexagon( values: self.values, max: self.max, progress: self.progress ) .fill(self.color) } // 4 .frame( width: min(proxy.size.width, proxy.size.height), height: min(proxy.size.width, proxy.size.height) ) } } } ``` 1. 当我们希望对 Content 进行限制时,使用 `GeometryReader` 来读取可用的尺寸。`proxy` 的内容会在 "// 4" 中使用。 2. 为了实现需要的效果,我们使用 `ZStack` 将两个六边形 (`Hexagon`) 堆叠起来,首先是外层固定的正六边形虚线,它的六个定点值和所接受的最大值相同。对于一个 `Shape`,我们使用 `.stroke` 来获取它的边缘路径。 3. 内层六边形通过 `.fill` 进行颜色填充,表示具体的数值。 4. 我们始终希望外层是一个正六边形,因此需要一个正方形的 `.frame`。通过比较 `proxy.size` 的 `width` 和 `height`,对 `ZStack` 的尺寸进行限制。 `Hexagon` 满足 `Shape` 协议,其中核心的 `path(in:)` 代码如下: ```swift-example struct Hexagon: Shape { let values: [Int] let max: Int func path(in rect: CGRect) -> Path { Path { path in let points = self.points(in: rect) path.move(to: points.first!) for p in points.dropFirst() { path.addLine(to: p) } path.closeSubpath() } } // 三角函数计算,将 values 转换为 rect 中的座标点 func points(in rect: CGRect) -> [CGPoint] { // ... } } ``` 在完成这些绘制后,我们就可以在 Preview 或者是实际的 `PokemonInfoPanel` 中使用 `RadarView` 了。比如: ```swift-example struct PokemonInfoPanel: View { // ... var body: some View { // ... HStack(spacing: 20) { AbilityList( model: model, abilityModels: abilities ) RadarView( values: model.pokemon.stats.map { $0.baseStat }, color: model.color, max: 120 ) .frame(width: 100, height: 100) } } } ``` 注意,在使用时,我们可以通过 `frame` 为 `RadarView` 指定尺寸。否则,`AbilityList` 和 `RadarView` 将会等分屏幕宽度。关于这种行为的原因,我们会在下一节里详细谈及。 ### Animatable Data SwiftUI 中 `Shape` 非常强大,对 `Shape` 按照路径制作动画也很简单。如果你有注意,会发现在 `Shape` 协议定义已经规定了 `Shape` 是满足 `Animatable` 的: ```swift-example protocol Shape : Animatable, View { // ... } ``` `Animatable` 本身的定义很简单: ```swift-example protocol Animatable { associatedtype AnimatableData : VectorArithmetic var animatableData: Self.AnimatableData { get set } } ``` 基本在事实上,它只要求你定义一个可以读取和设置的 `animatableData`,而这个值需要是一个可以支持加减的矢量值 `VectorArithmetic`。在 SwiftUI 中,像是 `CGFloat`,`Double` 等都是满足 `VectorArithmetic` 要求的。除此之外,如果提供一对满足 `VectorArithmetic` 的类型,将它们组合起来生成的 `AnimatablePair` 也满足 `VectorArithmetic`,这让我们可以同时为多个值定义动画。 SwiftUI 为包括 `CGPoint`、`CGSize` 和 `Angle` 在类的很多基本类型实现了 `Animatable`。这也是为什么我们能通过改变某个 `View` 上或者它的 modifier 上的对应值来进行动画的原因。不过对于一般的 `Shape` 来说,它所默认提供的 `animatableData` 没有做什么特殊操作。这其实很合理,像是 `CGPoint`、`CGSize` 和 `Angle` 这些类型,我们给定初始值和最终值后,总是可以通过插值的方式在这两个值之间进行过渡,也就是动画。但是为一般性的 `Shape` 定义这类过渡是不可能的。所以想要实现 `Shape` 的动画,我们需要自己定义合适的 `animatableData`。  作为示例,想要实现的是上图这样的动画:当雷达图出现时,我们希望它从顶端的原点开始,内外两个六边形都按照顺时针方向通过动画扇形展开并显示出来。 首先,在 `Hexagon`,我们需要一个变量来控制当前的绘制进度。一个 `CGFloat` 的 `progress` 值就能很好地完成任务: ```swift-example struct Hexagon: Shape { var progress: CGFloat // ... } ``` `CGFloat` 已经满足 `VectorArithmetic` 了,在我们实现 `animatableData` 的 getter 和 setter 时,直接将它和 `progress` 关联起来就可以了: ```swift-example struct Hexagon: Shape { // ... var animatableData: CGFloat { set { progress = newValue } get { progress } } } ``` 当我们通过 `.animate` 或者 `withAnimation` 操作 `Hexagon` (或者它的 Container View) 时,SwiftUI 将自动按照动画要求的曲线为 `animatableData` 从 0 到 1 进行插值。这会调用它的 setter,并通过 setter 更新 `progress` 的值,然后触发 `Shape` 的 `path(in:)` 进行绘制。所以,我们最后只需要把 `progress` 应用到 `path(in:)` 里,让每次插值重绘时的形状满足要求就可以了。`Path` 提供一个 `trimmedPath(from:to:)` 方法,它可以按照输入值截取一段路径: ```swift-example struct Hexagon: Shape { // ... func path(in rect: CGRect) -> Path { Path { path in // ... } .trimmedPath(from: 0, to: progress) } } ``` 比如 `progress` 值为 0.4 时,将返回上面图中左起第二张所显示的路径图形。 最后,只需要我们设置合适的 `progress` 值,就可以让 `Hexagon` 以动画方式显示出来了。相关代码可能类似这样: ```swift-example @State var progress: CGFloat = 0 VStack { Hexagon( values: [165,129,148,176,152,140], max: 200, progress: progress ) .animation(.linear(duration: 3)) .frame(width: 100, height: 100) Button(action: { self.progress = 1.0 }) { Text("动画") } } ``` ## 布局和对齐 看到本节的标题,可能你会赶到有一些奇怪。毕竟我们在本书一开始就已经谈及过 SwiftUI 布局方面的事情,并且贯穿本书我们也实现了不少常见布局的 UI。不过,在练习的时候,你可能会发现有时候 SwiftUI 并没有按照你的想象放置 `View`。通过不断尝试和修改,有时候你能够“碰巧”获得一个可行的写法;也有时候不论如何努力,都无法达到需求。不过如果我们能够对 SwiftUI 的布局方式有更深入了解的话,在遇到这种情况时,就可以少一些胡乱猜测,多一些努力方向。 ### 布局规则 #### SwiftUI 布局流程 SwiftUI 遵循的布局规则,可以总结为“协商解决,层层上报”:父层级的 `View` 根据某种规则,向子 `View` “提议”一个可行的尺寸;子 `View` 以这个尺寸为参考,按照自己的需求进行布局:或占满所有可能的尺寸 (比如 `Rectangle` 和 `Circle`),或按照自己要显示的内容确定新的尺寸 (比如 `Text`),或把这个任务再委托给自己的子 `View` 继续进行布局 (比如各类 Stack View)。在子 `View` 确定自己的尺寸后,它将这个需要的尺寸汇报回父 `View`,父 `View` 最后把这个确定好尺寸的子 `View` 放置在座标系合适的位置上。 让我们回归本源,来看一个最简单的情况,结合这个实例说明布局流程: ```swift-example struct ContentView: View { var body: some View { HStack { Image(systemName: "person.circle") Text("User:") Text("onevcat | Wei Wang") } .lineLimit(1) } } // SceneDelegate.swift window.rootViewController = UIHostingController(rootView: ContentView()) ``` 结果如下,蓝框范围是 `HStack` 的尺寸:  在这里,在 `HStack` 的父 `View` 是 `ContentView`,而 `ContentView` 直接就是 `UIHostingController` 的 `rootView`。SwiftUI 系统经历了以下步骤来进行布局: 1. `rootView` 使用整个屏幕尺寸作为“提议”,向 `HStack` 请求尺寸。 2. `HStack` 在接收到这个尺寸后,会向它的子 `View` 们进行“提议”。 3. 第一步,扣除掉默认的 `HStack` spacing 后,把剩余宽度三等分 (因为 `HStack` 中存在三个子 `View`),并以其中一份向子 `View` 的 `Image` 进行提议。 4. `Image` 会按照它要显示的内容决定自身宽度,并把这个宽度汇报给 `HStack`。 5. `HStack` 从 3 中向子 `View` 提案的总宽度中,扣除掉 4 里 `Image` 汇报的宽度,然后将剩余的宽度平分为两部分,把其中一份作为提案宽度提供给 `Text("User:")`。 6. `Text` 也根据决定自身的宽度。不过和 `Image` 不太一样,`Text` 并不“盲目”遵守自身内容的尺寸,而是会更多地尊重提案的尺寸,通过换行 (在没有设定 `.lineLimit(1)` 的情况下) 或是把部分内容省略为 "..." 来修改内容,去尽量满足提案。注意,父 `View` 的提案对于子 `View` 来说只是一种建议。比如这个 `Text` 如果无论如何,需要使用的宽度都比提案要多,那么它也会将这个实际需要的尺寸返回。 7. 对于最后一个 `Text`,采取的步骤和方法与 6 类似。在三个子 `View` 都决定好各自尺寸后,`HStack` 会按照设定的对齐和排列方式把子 `View` 们水平顺序放置。 8. 不要忘记,`HStack` 是 `rootView` 的子 `View`,因此,它也有义务将它的尺寸向上汇报。由于 `HStack` 知道了三个子 `View` 和使用的 `spacing` 的数值 (在例子中我们使用了默认的 `spacing` 值),`HStack` 的尺寸也得以确定。最后它把这个尺寸 (也就是图中的蓝框部分) 上报。 9. 最后,`rootView` 把 `HStack` 以默认方式放到自己的座标系中,也即在水平和竖直方向上都居中。 #### 布局优先级 在上例中,布局系统在处理 `HStack` 的三个子 `View` 时,会按照顺序处理 `Image`,"User:" `Text` 和最后的用户名 `Text`。如果我们对它的 `frame` 进行一些限制,就可以看到 `Text` 在处理布局上的一些细节。对上面的代码做一些修改,将宽度限制到 `200`,为了清楚,我们还可以为它们加上一些背景颜色: ```swift-example HStack { Image(systemName: "person.circle") .background(Color.yellow) Text("User:") .background(Color.red) Text("onevcat | Wei Wang") .background(Color.green) } .lineLimit(1) .frame(width: 200) ```  绿色部分的 `Text` 无法从父 `View` 中获得足够的宽度提案,因此它只能在用 “...” 截断字符串的前提下,尽可能使用所有的提案宽度。你可能已经注意到,在黄色 `Image` 左侧和绿色 `Text` 右侧都有一小段空隙,但这些空隙并不足以再支撑绿色 `Text` 再多显示一个字符。两侧都有空隙,而并非只有最右侧有空隙的原因,是 `.frame` 默认采取的是 `.center` 对齐。注意。这里的对齐方式和 `HStack` 无关,而是 `.frame` 所导致的效果。如果你为最后的 `.frame(width: 200)` 添加上对齐方式 (例如 `.frame(width: 200, alignment: .leading)`),就能看到实际效果。我们会在本章后面的部分深入探讨 `frame` 和各种对齐方式的本质。 有些情况下,在有限空间中布局多个 `View` 时,我们会希望某些更重要的部分优先显示。比如上例中,相对于 "User:" 这个描述,可能实际的用户名字更加重要。通过 `.layoutPriority`,我们可以控制计算布局的优先级,让父 `View` 优先对某个子 `View` 进行考虑和提案。默认情况下,布局优先级都是 0,我们可以传递一个更大的值 (比如 1),来让绿色的 `Text` 显示全部内容: ```swift-example HStack { Image(systemName: "person.circle") .background(Color.yellow) Text("User:") .background(Color.red) Text("onevcat | Wei Wang") .layoutPriority(1) .background(Color.green) } .lineLimit(1) .frame(width: 200) ```  #### 强制固定尺寸 除去那些刻意而为的自定义绘制,SwiftUI 中默认情况下 `View` 所显示的内容的尺寸一般不会超出 `View` 自身的边界:比如 `Text` 会通过换行和截取省略来尽可能让内容满足边界。有些罕见情况下我们可能希望无论如何,不管 `View` 的可用边界如何设定,都要完整显示内容。这时候,可以使用 `fixedSize`。这个 modifier 将提示布局系统忽略掉外界条件,让被修饰的 `View` 使用它在无约束下原本应有的**理想尺寸**。 继续用上面的 `View` 为例,我们如果在 `frame` 之前添加 `fixedSize`,那么原本被缩略的 "User:" 也将被显示出来。 ```swift-example HStack { Image(systemName: "person.circle") .background(Color.yellow) Text("User:") .background(Color.red) Text("onevcat | Wei Wang") .layoutPriority(1) .background(Color.green) } .lineLimit(1) .fixedSize() .frame(width: 200) ```  不过,需要特别注意的是,代表 `HStack` 尺寸的蓝框,现在比它的实际内容要窄。这很可能不是我们真正所需要的,它会让这个 `HStack` 在参与和其他 `View` 相关的布局时变得很奇怪:SwiftUI 仍然会按照蓝框部分的尺寸来决定 `HStack` 的位置,有时这导致显示内容的重叠。 ### Frame 继续上面的例子,如果我们把 `fixedSize` 从 `frame` 之前拿到 `frame` 之后的话,会发现布局和不加 `fixedSize` 的时候完全一致。这至少给我们提供了一个很重要的暗示:这些 modifier 的调用顺序不同,可能会产生不同的结果。 究其原因,这是由于大部分 `View` modifier 所做的,并不是“改变 `View` 上的某个属性”,而是“用一个带有相关属性的新 `View` 来包装原有的 `View`”。`frame` 也不例外:它并不是将所作用的 `View` 的尺寸进行更改,而是新创建一个 `View`,并强制地用指定的尺寸,对其内容 (其实也就是它的子 `View`) 进行提案。这也是为什么将 `fixedSize` 写在 `frame` 之后会变得没有效果的原因:因为 `frame` 这个 `View` 的理想尺寸就是宽度 200,它已经是按照原本的理想尺寸进行布局了,再用 `fixedSize` 包装也不会带来任何改变。 除了直接指定宽高的 `frame(width:height:alignment:)` 以外,我们也在之前章节多次看到过另一方法: ```swift-example func frame( minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center ) -> some View ``` 和固定宽高的版本不同,这个方法为尺寸定义了一套约束:如果从父 `View` 中获得的提案尺寸小于 `minXXX` 或者大于 `maxXXX`,这个 `frame` 将会把这个提案尺寸截取到相应的最小值或者最大值,然后进行提案。`frame` 方法的两种版本里,所有的参数都有默认值 `nil`,如果你使用这个默认值,那么 `frame` 将不在这个方向上改变原有的尺寸提案,而是将它直接传递给子 `View`。 `frame` 方法的最后一个参数表示所使用的对齐方式。不过,很多时候单纯地改变这个对齐方式不会有任何效果: ```swift-example HStack { //... } .frame(alignment: .leading) .background(Color.purple) ``` 因为这个 `alignment` 指定的是 `frame` View 中的内容在其内部的对齐方式,如果不指定宽度或者高度,那么 `frame` 的尺寸将完全由它的内容决定。换言之,内容都已经占据了 `frame` 的全部空间,不论采用哪种方式,内容在 `frame` 里都是“贴边的”。对齐也就没有任何意义了。想要体现和实验 `frame` 里的对齐方式,可以为 `frame` 添加一个多余内容所需空间的尺寸参数: ```swift-example HStack { //... } .frame(width: 300, alignment: .leading) .background(Color.purple) ``` 结果为:  ### Alignment Guide SwiftUI 中有不少 API 涉及到对齐,这也是最让人困惑的地方之一。你时不时总会遇到疑问:为什么写在这里的对齐不起作用?为什么这里的对齐方式表现非常奇怪?在文档中某个有关对齐的 API 并没有任何解释,它是不是并非准备给一般开发者使用的? 由于 SwiftUI 还处于早期阶段,所以对于上面这些问题,很多时候都可能被误认为是 bug 或者框架还不完善的结果,但大部分时候这并不是事实。上一节里,我们看到了 `frame` 中的对齐设定。在这一小节,我们会专注来看看各类 Stack View 的对齐,以及它所对应的 Alignment Guide 的相关概念。 #### Stack View 的对齐 `HStack`,`VStack` 和 `ZStack` 的初始化方法都可以接受名为 `alignment` 的参数,不过它们的类型却略有不同: - `HStack` 接受 `VerticalAlignment`,典型值为 `.top`、`.center`、`.bottom`、`lastTextBaseline` 等。 - `VStack` 接受 `HorizontalAlignment`,典型值为 `.leading`、`.center` 和 `.trailing`。 - `ZStack` 在两个方向上都有对齐的需求,它接受 `Alignment`。`Alignment` 其实就是对 `VerticalAlignment` 和 `HorizontalAlignment` 组合的封装。 > 三种具体的对齐类型中都定义了 `.center`,它也是默认情况下各类 `Stack` 所定义的对齐方式。不过由于重名,在一些既可以接受 `VerticalAlignment` 又可以接受 `HorizontalAlignment` 的地方,简单地使用 `.center` 会导致歧义,这种情况下,我们可能会需要在前面加上合适的类型名称。 让我们仔细看看 `VerticalAlignment` 和 `HorizontalAlignment` 的这些值到底都是什么。以 `VerticalAlignment.top` 为例,它其实是定义在 `VerticalAlignment` extension 里的一个静态变量: ```swift-example extension VerticalAlignment { static let top: VerticalAlignment // ... } ``` `VerticalAlignment` 本身也提供了一个初始化方法,它接受一个 `AlignmentID` 的类型作为参数: ```swift-example struct VerticalAlignment { init(_ id: AlignmentID.Type) } ``` 也就是说,如果我们能定义一个自己的 `AlignmentID`,我们就可以创建 `VerticalAlignment` 的实例,并把它用在 `HStack` 的对齐上了。 那么 `AlignmentID` 又是什么呢?它是一个只有单个方法的 `protocol`: ```swift-example protocol AlignmentID { static func defaultValue( in context: ViewDimensions ) -> CGFloat } ``` 这个方法需要返回一个 `CGFloat`,该数字代表了使用对齐方式时 `View` 的偏移量。我们当然可以简单地返回一个数字,不过,更常见的做法是从 `context` 里获取需要的值。`ViewDimensions` 的定义如下: ```swift-example struct ViewDimensions { // 1 var width: CGFloat { get } var height: CGFloat { get } // 2 subscript( guide: HorizontalAlignment) -> CGFloat { get } subscript( guide: VerticalAlignment) -> CGFloat { get } // 3 subscript( explicit guide: HorizontalAlignment) -> CGFloat? { get } subscript( explicit guide: VerticalAlignment) -> CGFloat? { get } } ``` 1. 当前处理的 `View` 的宽和高,这是很直接的数据。 2. 通过 `HorizontalAlignment` 或者 `VerticalAlignment` 以下标的方式从 `ViewDimensions` 中获取数据。默认情况下,它会返回对应 alignment 的 `defaultValue` 方法的返回值。 3. 通过下标获取定义在 `View` 上的**显式**对齐值。关于对齐的“显式”和“隐式”的区别,我们稍后再说。 举个实际的例子,如果我们想要自己手动实现一个 `VerticalAlignment.center` (在下面我们把它叫做 `myCenter`),可以这样进行定义: ```swift-example extension VerticalAlignment { struct MyCenter: AlignmentID { static func defaultValue( in context: ViewDimensions ) -> CGFloat { context.height / 2 } } static let myCenter = VerticalAlignment(MyCenter.self) } ``` 在 `defaultValue(in:)` 里,我们指定对齐位置为 `height` 值的一半,这恰好就是竖直方向上各个 `View` 的水平中线所在位置。将 `HStack` 的对齐方式替换为 `.myCenter`,我们能得到的布局和默认的 `.center` 完全一样: ```swift-example HStack(alignment: .myCenter) { Image(systemName: "person.circle") .background(Color.yellow) Text("User:") .background(Color.red) Text("onevcat | Wei Wang") .background(Color.green) } .lineLimit(1) .background(Color.purple) ``` #### 隐式对齐和显式对齐 通过 `alignmentGuide`,我们可以进一步调整 `View` 在容器 (比如各类 Stack) 中的对齐方式,这提供给我们更多的灵活性。`alignmentGuide` modifier 有两个重载方法: ```swift-example func alignmentGuide( _ g: HorizontalAlignment, computeValue: @escaping (ViewDimensions) -> CGFloat ) -> some View func alignmentGuide( _ g: VerticalAlignment, computeValue: @escaping (ViewDimensions) -> CGFloat ) -> some View ``` 它负责修改 `g` (`HorizontalAlignment` 或者 `VerticalAlignment`) 的对齐方式,把原来的 `defaultValue(in:)` 所提供的默认值,用 `computeValue` 的返回值进行替代。对于容器里的 `View`,如果我们不明确指定 `alignmentGuide`,它们都将继承使用容器的对齐方式。举例来说,如果我们在 `HStack` 里不指定任何对齐: ```swift-example HStack { Image(systemName: "person.circle") Text("User:") .font(.footnote) Text("onevcat | Wei Wang") } ``` 实际上,`HStack` 默认使用 `VerticalAlignment.center`,且 `Image` 和两个 `Text` 都使用 `.center` 的默认值作为**隐式**对齐。如果将所有对齐**显式**写出来,这段代码相当于: ```swift-example HStack(alignment: .center) { Image(systemName: "person.circle") .alignmentGuide(VerticalAlignment.center) { d in d[VerticalAlignment.center] } Text("User:") .font(.footnote) .alignmentGuide(VerticalAlignment.center) { d in d[VerticalAlignment.center] } Text("onevcat | Wei Wang") .alignmentGuide(VerticalAlignment.center) { d in d[VerticalAlignment.center] } } ``` 每个 `alignmentGuide` 都返回了对应 `VerticalAlignment.center` 的默认值。对于想要使用这个默认对齐的 `View`,我们可以省略掉 `alignmentGuide`。如果我们想要对某个部分进行微调,可以在 `computeValue` 中进行计算。比如让 "User:" 变成“上标”形式 (注意,我们顺便调整了一下文本和图片的顺序): ```swift-example // 1 HStack(alignment: .center) { Text("User:") .font(.footnote) // 2 .alignmentGuide(VerticalAlignment.center) { d in d[.bottom] } // 3 Image(systemName: "person.circle") Text("onevcat | Wei Wang") } ``` 1. 对 `HStack`,我们指定了 `VerticalAlignment.center` 为对齐方式 2. 对于 "User:",返回的是 `d[.bottom]`,也即 `Text` 的底边作为对齐线。注意,只有当 `alignmentGuide` 的第一个参数 `VerticalAlignment.center` 和 `HStack` 的 `alignment` 参数一致时,它才会被考虑。因为 `alignmentGuide` API 的作用就是修改传入的 `alignment` 的数值。 3. `Image` 和最后一个 `Text` 没有显式指定 `alignmentGuide`,它们将使用默认的 `d[VerticalAlignment.center]`,也即 `height / 2` 水平中线作为对齐线。 所以,上面代码的效果是,让 `Text("User:")` 的底边与 `Image` 和实际用户名 `Text` 的中线对齐,如图:  > 当然,除了直接使用 `d[.bottom]`,你也可以进行计算并返回任意的对齐数值,比如 `d[.bottom] + 5` 或者 `d.height * 0.7` 等,从而达到设计上的任何要求。 需要特别指出的是,`alignmentGuide` 中指定的 `alignment` 必须要和 `HStack` 这类容器所指定的 `alignment` 一致,它才会在布局时被考虑。不过,对于那些和当前对齐不相关的 `alignmentGuide`,如果有需要,我们可以通过 `ViewDimensions` 的 `explicit` 下标方法读取。和普通的下标方法不同,这个方法返回的是可选值 `CGFloat?`。如果当前 `View` 上没有显式定义相关的对齐,那么会得到 `nil`。这在设定自定义对齐中,需要考虑其他方向的对齐时会很有用: ```swift-example HStack(alignment: .center) { Text("User:") .font(.footnote) .alignmentGuide(.leading) { _ in 10 } .alignmentGuide(VerticalAlignment.center) { d in d[.bottom] + (d[explicit: .leading] ?? 0) } // 3 Image(systemName: "person.circle") Text("onevcat | Wei Wang") } ``` 上例中,如果显式定义了 `.leading`,则在计算 `center` 这个 `alignmentGuide` 实际作用时,就可以通过 `explicit` 的下标方法读取它,并将它加到对齐中去。在这里的 `HStack` 里,可能看不太出这么做的意义。不过在 `ZStack` 中,同时会涉及到水平和竖直两种情况,如果两个方向上的对齐方式具有相关性,那么 `d[explicit:]` 就非常有用了。 #### 自定义 Alignment 和跨 View 对齐 上例中我们已经通过创建满足 `AlignmentID` 的 `MyCenter`,自定义了一个 `VerticalAlignment` 值。我们可以通过类似的方法,定义出任意多个对齐。不过,如果只是像上例那样的微调的话,还不需要“大动干戈”定义新的对齐。新建对齐的最主要目的,还是为了跨越 `View` 的层级来进行对齐。 例如,我们想要实现下图所示的布局,用来让用户选择想要使用的用户名称:  这个布局以上例为基础,最外层是一个 `HStack`,它包含三个元素:上标的 "User" `Text`,表示当前选中的用户的 `Image` 图标,以及一个由 `VStack` 组成的用户列表。在点击某行时,我们希望 `Image` 移动到和被选中行对齐的位置。这就需要我们拥有跨 `View` 对齐的手段。 首先,我们可以添加一个自定义对齐: ```swift-example extension VerticalAlignment { struct SelectAlignment: AlignmentID { static func defaultValue( in context: ViewDimensions ) -> CGFloat { context[VerticalAlignment.center] } } static let select = VerticalAlignment(SelectAlignment.self) } ``` 接下来,将它指定为外层 `HStack` 的 `alignment`: ```swift-example @State var selectedIndex = 0 let names = [ "onevcat | Wei Wang", "zaq | Hao Zang", "tyyqa | Lixiao Yang" ] var body: some View { HStack(alignment: .select) { Text("User:") .font(.footnote) .foregroundColor(.green) Image(systemName: "person.circle") .foregroundColor(.green) VStack(alignment: .leading) { ForEach(0..<names.count) { index in Group { if index == self.selectedIndex { Text(self.names[index]) .foregroundColor(.green) } else { Text(self.names[index]) .onTapGesture { self.selectedIndex = index } } } } } } } ``` 因为 `SelectAlignment` 默认返回的对齐值是 `context[VerticalAlignment.center]`,上面的更改中,所有的子 `View` 都隐式地使用了这个默认值,它和简单的 `.center` 对齐没有区别,`HStack` 的三部分都中央对齐:  接下来,为三部分设定各自的对齐行为,为了清晰一些,下面只写出了需要添加 `alignmentGuide` 的部分: ```swift-example HStack(alignment: .select) { Text("User:") // ... // 1 .alignmentGuide(.select) { d in d[.bottom] + CGFloat(self.selectedIndex) * 20.3 } Image(systemName: "person.circle") // ... // 2 .alignmentGuide(.select) { d in d[VerticalAlignment.center] } VStack(alignment: .leading) { ForEach(0..<names.count) { index in Group { if index == self.selectedIndex { Text(self.names[index]) // ... // 3 .alignmentGuide(.select) { d in d[VerticalAlignment.center] } } else { Text(self.names[index]) // ... } } } } } // 4 .animation(.linear(duration: 0.2)) ``` 1. 对于上标 `Text`,以底部为基准,再加上选中的行到整个 `HStack` 上端的总高度。这会将 "User:" 上标文本固定在 `HStack` 最上方且超出自身一半高度的位置上。 2. 明确指定 `Image` 的中心部位应该和其他部分对齐。 3. `VStack` 中的这个显式 `alignmentGuide` 将会覆盖其他隐式行为。`VStack` 有自己的布局规则,那就是顺次将每个子 `View` 竖直放置。在这个基础上,我们把被选中行的中线位置设定成了对齐位置。这样,SwiftUI 将尝试在满足 `VStack` 的竖直叠放特性的同时,去满足把选中行和 `HStack` 中其他部分的对齐。 4. 最后,为了让选择切换更自然一些,可以为整个效果加上动画。 经过这些修改,这个用户选择列表就可以完整工作了。相关代码可以在源码文件夹的 "11.Layout" 中找到。 ## 总结 本章里,我们研究了关于 SwiftUI 布局的一些进阶话题。就算不知道这些知识,你可能也可以通过不断尝试和修改,最终找到满足要求的布局写法。但另一方面,我们需要看到,就算是像 `frame` 或者 `alignment` 这样的每天都在使用的简单方法背后,所蕴藏的规则和使用方式,也远不止看起来那样容易。大致了解 SwiftUI 布局的背后的原理,以及掌握常用的布局方法,有助于你迅速确定 `View` 之间的关系,达到事半功倍的效果。 虽然乍看起来有这样那样的问题,但 SwiftUI 的布局系统是经过精心设计的。它使用了声明式的方法让开发者通过描述语句来布局。和传统的 Auto Layout 不同,SwiftUI 中的描述性布局不会出现冲突或者缺失,也不存在由于运行时的布局而导致错误的可能性。它提供的是一种安全的布局方式:只要能够用 SwiftUI 的语句进行描述并通过编译,布局系统就会按照一定的规律生成合理的满足描述的布局。问题在于,你是否足够熟悉这套规律和机制,解释布局语句所带来的结果,并让代码朝向你希望的方向进行改变。 ## 练习 ### 1. 灵活使用 `GeometryReader` 请尝试用 `GeometryReader` 和 `Circle` 画出以下图形:  其中灰色的矩形蓝框部分的尺寸由 `frame` 给出,且四周的圆形直径和矩形短边长度一致。你可以从下面的代码片段开始: ```swift-example var body: some View { GeometryReader { proxy in // ... // 使用 Circle 绘制 } .frame(width: 100, height: 100) .background(Color.gray.opacity(0.3)) } ``` > 提示,`Circle` 也是 `Shape` 的一种,因此可以使用 `Circle().path(in: rect)` 的方式将一个 `Circle` 重绘到另外的 `rect` 中。当然,你也可以使用像是 `offset` 这样的 modifier 来进行移动。注意我们要求对于任意矩形都能正确绘制四周贴边的圆形,你可以自行更改 `frame` 的数值,来确认你的实现对任意矩形都是通用的。 ### 2. 研究子 `View` 对提案尺寸的敏感度 在本章中布局例子中: ```swift-example HStack { Image(systemName: "person.circle") Text("User:") Text("onevcat | Wei Wang") } .lineLimit(1) .frame(width: 200) ``` 将 `.frame` 的 `width` 设定为一个很小的值时 (比如比 Image 的宽度还小),会发生什么。如果保持 `width` 足够大,而是将 `height` 设定为一个很小的值时,又会发生什么?请总结一下 `Image`,`Text`,`Rectangle` 和 `HStack` 各自对于 `frame` 宽高提案是如何响应的,它们是严格遵守提案尺寸呢,还是更多地去满足内容的尺寸? ### 3. `ZStack` 的复杂布局 尝试修改最终的 PokeMaster app,让详细信息面板的 `ZStack` 的对齐方式更加“灵活炫酷”一些:  主要来说,希望实现: 1. 详细面板的图标实现“骑墙”的对齐效果,图标的中部和面板背景上边缘齐平。 2. 宝可梦的图标和它的中文名字 ("秒蛙种子") 首端 (`.leading`) 对齐。 3. 宝可梦的英文名字 ("Bulbasaur") 的 `.leading` 与它的中文名字 ("秒蛙种子") 的 `.center` 对齐。 4. 宝可梦的英文名字 ("Bulbasaur") 和它的种族名字 ("种子宝可梦") 尾端 (`.trailing`) 对齐。 信息面板的内容和背景是以 `ZStack` 的关系组织起来的,与 `HStack` 和 `VStack` 不同,`ZStack` 可以同时接受水平和竖直两个方向上的对齐。这也是使用多个 `alignmentGuide` 进行不同方向对齐的先决条件。 你可以使用源码文件夹中 "PokeMaster-Finished" 里的项目作为本题练习的起始。