Skip to content

Instantly share code, notes, and snippets.

@onevcat
Created December 28, 2019 14:39
Show Gist options
  • Select an option

  • Save onevcat/a7ef0d562d2693b084fc8358bc01f890 to your computer and use it in GitHub Desktop.

Select an option

Save onevcat/a7ef0d562d2693b084fc8358bc01f890 to your computer and use it in GitHub Desktop.

Revisions

  1. onevcat created this gist Dec 28, 2019.
    848 changes: 848 additions & 0 deletions sample-file.md
    Original 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` 中也能找到等价的方法。比如下面的代码就定义绘制了一个底部为圆弧的三角形箭头:

    ![](artwork/path-layout-01.png)

    ```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。比如我们想要按照下面的尺寸百分比进行布局:

    ![](artwork/path-layout-02.png)

    可以使用下面的代码:

    ```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 的详细信息面板,其实在最初的设计稿中还有一个表示宝可梦能力的雷达图,即下图红框部分:

    ![](artwork/path-layout-03.png)

    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`

    ![](artwork/path-layout-04.png)

    作为示例,想要实现的是上图这样的动画:当雷达图出现时,我们希望它从顶端的原点开始,内外两个六边形都按照顺时针方向通过动画扇形展开并显示出来。

    首先,在 `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` 的尺寸:

    ![](artwork/path-layout-05.png)

    在这里,在 `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)
    ```

    ![](artwork/path-layout-06.png)

    绿色部分的 `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)
    ```

    ![](artwork/path-layout-07.png)

    #### 强制固定尺寸

    除去那些刻意而为的自定义绘制,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)
    ```

    ![](artwork/path-layout-08.png)

    不过,需要特别注意的是,代表 `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)
    ```

    结果为:

    ![](artwork/path-layout-09.png)

    ### 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` 的中线对齐,如图:

    ![](artwork/path-layout-10.png)

    > 当然,除了直接使用 `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` 的层级来进行对齐。

    例如,我们想要实现下图所示的布局,用来让用户选择想要使用的用户名称:

    ![](artwork/path-layout-11.png)

    这个布局以上例为基础,最外层是一个 `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` 的三部分都中央对齐:

    ![](artwork/path-layout-12.png)

    接下来,为三部分设定各自的对齐行为,为了清晰一些,下面只写出了需要添加 `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` 画出以下图形:

    ![](artwork/path-layout-13.png)

    其中灰色的矩形蓝框部分的尺寸由 `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` 的对齐方式更加“灵活炫酷”一些:

    ![](artwork/path-layout-14.png)

    主要来说,希望实现:

    1. 详细面板的图标实现“骑墙”的对齐效果,图标的中部和面板背景上边缘齐平。
    2. 宝可梦的图标和它的中文名字 ("秒蛙种子") 首端 (`.leading`) 对齐。
    3. 宝可梦的英文名字 ("Bulbasaur") 的 `.leading` 与它的中文名字 ("秒蛙种子") 的 `.center` 对齐。
    4. 宝可梦的英文名字 ("Bulbasaur") 和它的种族名字 ("种子宝可梦") 尾端 (`.trailing`) 对齐。

    信息面板的内容和背景是以 `ZStack` 的关系组织起来的,与 `HStack``VStack` 不同,`ZStack` 可以同时接受水平和竖直两个方向上的对齐。这也是使用多个 `alignmentGuide` 进行不同方向对齐的先决条件。

    你可以使用源码文件夹中 "PokeMaster-Finished" 里的项目作为本题练习的起始。