Skip to content

Instantly share code, notes, and snippets.

@onevcat
Created December 28, 2019 14:39
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save onevcat/a7ef0d562d2693b084fc8358bc01f890 to your computer and use it in GitHub Desktop.
Save onevcat/a7ef0d562d2693b084fc8358bc01f890 to your computer and use it in GitHub Desktop.

用户体验和布局进阶

PokeMaster app 现在已经是一个具有完整功能的 SwiftUI app 了。麻雀虽小,五脏俱全,在这个示例 app 里,我们涉及到了一个一般性的 iOS app 所需要的大部分内容:

  • 如何构建内容展示的列表
  • 如何构建用户交互的表单
  • 如何进行网络请求并把内容展示出来
  • 如何响应用户的手势
  • 如何在不同页面之间进行导航
  • 以及,如何通过一定的架构将所有上面的内容整合起来

这一章里,我们会涉及到一些稍微进阶的内容,包括自定义绘制的方法,和一些 SwiftUI 布局方面的话题。在学习和实际练习里,可能你已经遇到过这样的情况:有时候觉得某个布局难以实现,或者需要不断通过试错,来确定 View 的某种布局写法确实有效,甚至或者觉得某些“奇怪”的行为只是由于 SwiftUI 还在初期,所以是一个暂时性的 bug。根据笔者自身的经验,有时候确实是因为 SwiftUI 还不完善,但在更多情况下,这意味着你还没有真正理解 SwiftUI 的布局和工作方式。

本章将会试图带领你去触及 SwiftUI 一些表层之下的更深入的话题,包括自定义的 Path 绘制和动画,SwiftUI 布局的原理,View 对齐方式和基础等。不过由于 SwiftUI 的文档并不丰富,本书写作时,很多内容的注解在 Apple 开发者网站上也还是一片空白。所以这些内容更多的是基于尝试后的猜测和经验总结。如果你想获取更精确和深入的理解,还是需要自己动手加以实践。

自定义绘制和动画

SwiftUI 提供了很多常见的标准 UI 控件,比如 ButtonTextImageToggle 等等。如果我们想要更复杂和更自由的 UI,可能就需要进行一些自定义绘制。

Path 和 Shape

Shape 协议是自定义绘制中最基本的部分,它只要求一个方法,即给定一个 CGRect 的绘制范围,返回某个 Path

public protocol Shape : Animatable, View {
  func path(in rect: CGRect) -> Path
}

SwiftUI 提供的部分形状,比如 Circle 或者 Rectangle,都是 Shape 协议的具体实现。如果你对传统 iOS 开发中的 Core Graphics 有所了解的话,可能对“给定 CGRect,请开始你的绘制”这种模式感到熟悉。Shapepath(in:)UIViewdrawRect: 方法如出一辙。而具体的绘制也很类似,Core Graphics 为我们提供的最基本的在上下文中绘制线段和圆弧的 API,在 SwiftUI 的 Path 中也能找到等价的方法。比如下面的代码就定义绘制了一个底部为圆弧的三角形箭头:

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 结构体:

struct GeometryReader<Content> 
  : View where Content : View 
{
  init(@ViewBuilder content: 
    @escaping (GeometryProxy) -> Content)
  //...
}

GeometryProxy 中包括了 SwiftUI 中父 View 层级向当前 View 提议的布局信息,它会为 Content View 提供一个上下文 (关于 SwiftUI 布局的过程和更多详细信息,我们会在下一小节进行更多介绍)。GeometryProxy 的定义如下:

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

可以使用下面的代码:

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 的内容来确定最终尺寸。

六边形绘制实例

在了解了 ShapeGeometryReader 后,我们可以看一个实际的例子。在 PokeMaster app 的详细信息面板,其实在最初的设计稿中还有一个表示宝可梦能力的雷达图,即下图红框部分:

SwiftUI 里并没有这种六边形的内建形状,所以我们需要自己对它进行绘制。

为了简明,下面的代码只展示了布局和绘制 API 的使用,不会具体解释绘制逻辑和线段位置的计算 (它们涉及到一些基本的三角函数)。如果你对完整的代码感兴趣的话,可以查阅随书 "source/PokeMaster-Finished" 里 "RadarView.swift" 的源码。

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.sizewidthheight,对 ZStack 的尺寸进行限制。

Hexagon 满足 Shape 协议,其中核心的 path(in:) 代码如下:

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 了。比如:

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)
    }
  }
}

注意,在使用时,我们可以通过 frameRadarView 指定尺寸。否则,AbilityListRadarView 将会等分屏幕宽度。关于这种行为的原因,我们会在下一节里详细谈及。

Animatable Data

SwiftUI 中 Shape 非常强大,对 Shape 按照路径制作动画也很简单。如果你有注意,会发现在 Shape 协议定义已经规定了 Shape 是满足 Animatable 的:

protocol Shape : Animatable, View {
  // ...
}

Animatable 本身的定义很简单:

protocol Animatable {
  associatedtype AnimatableData : VectorArithmetic
  var animatableData: Self.AnimatableData { get set }
}

基本在事实上,它只要求你定义一个可以读取和设置的 animatableData,而这个值需要是一个可以支持加减的矢量值 VectorArithmetic。在 SwiftUI 中,像是 CGFloatDouble 等都是满足 VectorArithmetic 要求的。除此之外,如果提供一对满足 VectorArithmetic 的类型,将它们组合起来生成的 AnimatablePair 也满足 VectorArithmetic,这让我们可以同时为多个值定义动画。

SwiftUI 为包括 CGPointCGSizeAngle 在类的很多基本类型实现了 Animatable。这也是为什么我们能通过改变某个 View 上或者它的 modifier 上的对应值来进行动画的原因。不过对于一般的 Shape 来说,它所默认提供的 animatableData 没有做什么特殊操作。这其实很合理,像是 CGPointCGSizeAngle 这些类型,我们给定初始值和最终值后,总是可以通过插值的方式在这两个值之间进行过渡,也就是动画。但是为一般性的 Shape 定义这类过渡是不可能的。所以想要实现 Shape 的动画,我们需要自己定义合适的 animatableData

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

首先,在 Hexagon,我们需要一个变量来控制当前的绘制进度。一个 CGFloatprogress 值就能很好地完成任务:

struct Hexagon: Shape {
  var progress: CGFloat
  
  // ...
}

CGFloat 已经满足 VectorArithmetic 了,在我们实现 animatableData 的 getter 和 setter 时,直接将它和 progress 关联起来就可以了:

struct Hexagon: Shape {
  // ...
  var animatableData: CGFloat {
    set { progress = newValue }
    get { progress }
  }
}

当我们通过 .animate 或者 withAnimation 操作 Hexagon (或者它的 Container View) 时,SwiftUI 将自动按照动画要求的曲线为 animatableData 从 0 到 1 进行插值。这会调用它的 setter,并通过 setter 更新 progress 的值,然后触发 Shapepath(in:) 进行绘制。所以,我们最后只需要把 progress 应用到 path(in:) 里,让每次插值重绘时的形状满足要求就可以了。Path 提供一个 trimmedPath(from:to:) 方法,它可以按照输入值截取一段路径:

struct Hexagon: Shape {
  // ...
  func path(in rect: CGRect) -> Path {
    Path { path in
      // ...
    }
    .trimmedPath(from: 0, to: progress)
  }
}

比如 progress 值为 0.4 时,将返回上面图中左起第二张所显示的路径图形。

最后,只需要我们设置合适的 progress 值,就可以让 Hexagon 以动画方式显示出来了。相关代码可能类似这样:

@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 以这个尺寸为参考,按照自己的需求进行布局:或占满所有可能的尺寸 (比如 RectangleCircle),或按照自己要显示的内容确定新的尺寸 (比如 Text),或把这个任务再委托给自己的子 View 继续进行布局 (比如各类 Stack View)。在子 View 确定自己的尺寸后,它将这个需要的尺寸汇报回父 View,父 View 最后把这个确定好尺寸的子 View 放置在座标系合适的位置上。

让我们回归本源,来看一个最简单的情况,结合这个实例说明布局流程:

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 的父 ViewContentView,而 ContentView 直接就是 UIHostingControllerrootView。SwiftUI 系统经历了以下步骤来进行布局:

  1. rootView 使用整个屏幕尺寸作为“提议”,向 HStack 请求尺寸。
  2. HStack 在接收到这个尺寸后,会向它的子 View 们进行“提议”。
  3. 第一步,扣除掉默认的 HStack spacing 后,把剩余宽度三等分 (因为 HStack 中存在三个子 View),并以其中一份向子 ViewImage 进行提议。
  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. 不要忘记,HStackrootView 的子 View,因此,它也有义务将它的尺寸向上汇报。由于 HStack 知道了三个子 View 和使用的 spacing 的数值 (在例子中我们使用了默认的 spacing 值),HStack 的尺寸也得以确定。最后它把这个尺寸 (也就是图中的蓝框部分) 上报。
  9. 最后,rootViewHStack 以默认方式放到自己的座标系中,也即在水平和竖直方向上都居中。

布局优先级

在上例中,布局系统在处理 HStack 的三个子 View 时,会按照顺序处理 Image,"User:" Text 和最后的用户名 Text。如果我们对它的 frame 进行一些限制,就可以看到 Text 在处理布局上的一些细节。对上面的代码做一些修改,将宽度限制到 200,为了清楚,我们还可以为它们加上一些背景颜色:

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 显示全部内容:

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:" 也将被显示出来。

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

继续上面的例子,如果我们把 fixedSizeframe 之前拿到 frame 之后的话,会发现布局和不加 fixedSize 的时候完全一致。这至少给我们提供了一个很重要的暗示:这些 modifier 的调用顺序不同,可能会产生不同的结果。

究其原因,这是由于大部分 View modifier 所做的,并不是“改变 View 上的某个属性”,而是“用一个带有相关属性的新 View 来包装原有的 View”。frame 也不例外:它并不是将所作用的 View 的尺寸进行更改,而是新创建一个 View,并强制地用指定的尺寸,对其内容 (其实也就是它的子 View) 进行提案。这也是为什么将 fixedSize 写在 frame 之后会变得没有效果的原因:因为 frame 这个 View 的理想尺寸就是宽度 200,它已经是按照原本的理想尺寸进行布局了,再用 fixedSize 包装也不会带来任何改变。

除了直接指定宽高的 frame(width:height:alignment:) 以外,我们也在之前章节多次看到过另一方法:

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 方法的最后一个参数表示所使用的对齐方式。不过,很多时候单纯地改变这个对齐方式不会有任何效果:

HStack {
  //...
}
.frame(alignment: .leading)
.background(Color.purple)

因为这个 alignment 指定的是 frame View 中的内容在其内部的对齐方式,如果不指定宽度或者高度,那么 frame 的尺寸将完全由它的内容决定。换言之,内容都已经占据了 frame 的全部空间,不论采用哪种方式,内容在 frame 里都是“贴边的”。对齐也就没有任何意义了。想要体现和实验 frame 里的对齐方式,可以为 frame 添加一个多余内容所需空间的尺寸参数:

HStack {
  //...
}
.frame(width: 300, alignment: .leading)
.background(Color.purple)

结果为:

Alignment Guide

SwiftUI 中有不少 API 涉及到对齐,这也是最让人困惑的地方之一。你时不时总会遇到疑问:为什么写在这里的对齐不起作用?为什么这里的对齐方式表现非常奇怪?在文档中某个有关对齐的 API 并没有任何解释,它是不是并非准备给一般开发者使用的?

由于 SwiftUI 还处于早期阶段,所以对于上面这些问题,很多时候都可能被误认为是 bug 或者框架还不完善的结果,但大部分时候这并不是事实。上一节里,我们看到了 frame 中的对齐设定。在这一小节,我们会专注来看看各类 Stack View 的对齐,以及它所对应的 Alignment Guide 的相关概念。

Stack View 的对齐

HStackVStackZStack 的初始化方法都可以接受名为 alignment 的参数,不过它们的类型却略有不同:

  • HStack 接受 VerticalAlignment,典型值为 .top.center.bottomlastTextBaseline 等。
  • VStack 接受 HorizontalAlignment,典型值为 .leading.center.trailing
  • ZStack 在两个方向上都有对齐的需求,它接受 AlignmentAlignment 其实就是对 VerticalAlignmentHorizontalAlignment 组合的封装。

三种具体的对齐类型中都定义了 .center,它也是默认情况下各类 Stack 所定义的对齐方式。不过由于重名,在一些既可以接受 VerticalAlignment 又可以接受 HorizontalAlignment 的地方,简单地使用 .center 会导致歧义,这种情况下,我们可能会需要在前面加上合适的类型名称。

让我们仔细看看 VerticalAlignmentHorizontalAlignment 的这些值到底都是什么。以 VerticalAlignment.top 为例,它其实是定义在 VerticalAlignment extension 里的一个静态变量:

extension VerticalAlignment {
  static let top: VerticalAlignment
  // ...
}

VerticalAlignment 本身也提供了一个初始化方法,它接受一个 AlignmentID 的类型作为参数:

struct VerticalAlignment {
  init(_ id: AlignmentID.Type)
}

也就是说,如果我们能定义一个自己的 AlignmentID,我们就可以创建 VerticalAlignment 的实例,并把它用在 HStack 的对齐上了。

那么 AlignmentID 又是什么呢?它是一个只有单个方法的 protocol

protocol AlignmentID {
  static func defaultValue(
    in context: ViewDimensions
  ) -> CGFloat
}

这个方法需要返回一个 CGFloat,该数字代表了使用对齐方式时 View 的偏移量。我们当然可以简单地返回一个数字,不过,更常见的做法是从 context 里获取需要的值。ViewDimensions 的定义如下:

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),可以这样进行定义:

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 完全一样:

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 有两个重载方法:

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 里不指定任何对齐:

HStack {
  Image(systemName: "person.circle")
  Text("User:")
    .font(.footnote)
  Text("onevcat | Wei Wang")
}

实际上,HStack 默认使用 VerticalAlignment.center,且 Image 和两个 Text 都使用 .center 的默认值作为隐式对齐。如果将所有对齐显式写出来,这段代码相当于:

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:" 变成“上标”形式 (注意,我们顺便调整了一下文本和图片的顺序):

// 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.centerHStackalignment 参数一致时,它才会被考虑。因为 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,如果有需要,我们可以通过 ViewDimensionsexplicit 下标方法读取。和普通的下标方法不同,这个方法返回的是可选值 CGFloat?。如果当前 View 上没有显式定义相关的对齐,那么会得到 nil。这在设定自定义对齐中,需要考虑其他方向的对齐时会很有用:

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 对齐

上例中我们已经通过创建满足 AlignmentIDMyCenter,自定义了一个 VerticalAlignment 值。我们可以通过类似的方法,定义出任意多个对齐。不过,如果只是像上例那样的微调的话,还不需要“大动干戈”定义新的对齐。新建对齐的最主要目的,还是为了跨越 View 的层级来进行对齐。

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

这个布局以上例为基础,最外层是一个 HStack,它包含三个元素:上标的 "User" Text,表示当前选中的用户的 Image 图标,以及一个由 VStack 组成的用户列表。在点击某行时,我们希望 Image 移动到和被选中行对齐的位置。这就需要我们拥有跨 View 对齐的手段。

首先,我们可以添加一个自定义对齐:

extension VerticalAlignment {
    struct SelectAlignment: AlignmentID {
        static func defaultValue(
          in context: ViewDimensions
        ) -> CGFloat {
            context[VerticalAlignment.center]
        }
    }

    static let select = 
      VerticalAlignment(SelectAlignment.self)
}

接下来,将它指定为外层 HStackalignment


@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 的部分:

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

请尝试用 GeometryReaderCircle 画出以下图形:

其中灰色的矩形蓝框部分的尺寸由 frame 给出,且四周的圆形直径和矩形短边长度一致。你可以从下面的代码片段开始:

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 对提案尺寸的敏感度

在本章中布局例子中:

HStack {
  Image(systemName: "person.circle")
  Text("User:")
  Text("onevcat | Wei Wang")
}
.lineLimit(1)
.frame(width: 200)

.framewidth 设定为一个很小的值时 (比如比 Image 的宽度还小),会发生什么。如果保持 width 足够大,而是将 height 设定为一个很小的值时,又会发生什么?请总结一下 ImageTextRectangleHStack 各自对于 frame 宽高提案是如何响应的,它们是严格遵守提案尺寸呢,还是更多地去满足内容的尺寸?

3. ZStack 的复杂布局

尝试修改最终的 PokeMaster app,让详细信息面板的 ZStack 的对齐方式更加“灵活炫酷”一些:

主要来说,希望实现:

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment