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,可能就需要进行一些自定义绘制。
Shape
协议是自定义绘制中最基本的部分,它只要求一个方法,即给定一个 CGRect
的绘制范围,返回某个 Path
。
public protocol Shape : Animatable, View {
func path(in rect: CGRect) -> Path
}
SwiftUI 提供的部分形状,比如 Circle
或者 Rectangle
,都是 Shape
协议的具体实现。如果你对传统 iOS 开发中的 Core Graphics 有所了解的话,可能对“给定 CGRect
,请开始你的绘制”这种模式感到熟悉。Shape
的 path(in:)
和 UIView
的 drawRect:
方法如出一辙。而具体的绘制也很类似,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 中添加线段和圆弧的方式,请不要纠结于具体的数字或者圆弧角度。
- 为了完成满足
Shape
所需的path(in:)
方法,直接创建一个Path
结构体是最灵活和普遍的做法。第一步我们将绘制起点设定在rect
的零点 (左上)。 - 添加一段圆弧。
- 为
path
添加线段。 - 最后一段线段不需要手动添加,可以直接使用
closeSubpath
让绘制回到原点,从而得到闭合曲线。 - 由于
Shape
是一个遵守View
的协议,所以我们可以直接按照其他View
同样的方式来使用它。使用.fill
进行单色 (比如例子中的Color.green
) 或者渐变 (比如在之前章节介绍过的LinearGradient
) 填充。最后,.frame
会给Shape
一个参考的尺寸,我们会在下一节中涉及到更多关于frame
的话题。
有时候,我们会想要在 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
的内容来确定最终尺寸。
在了解了 Shape
和 GeometryReader
后,我们可以看一个实际的例子。在 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)
)
}
}
}
- 当我们希望对 Content 进行限制时,使用
GeometryReader
来读取可用的尺寸。proxy
的内容会在 "// 4" 中使用。 - 为了实现需要的效果,我们使用
ZStack
将两个六边形 (Hexagon
) 堆叠起来,首先是外层固定的正六边形虚线,它的六个定点值和所接受的最大值相同。对于一个Shape
,我们使用.stroke
来获取它的边缘路径。 - 内层六边形通过
.fill
进行颜色填充,表示具体的数值。 - 我们始终希望外层是一个正六边形,因此需要一个正方形的
.frame
。通过比较proxy.size
的width
和height
,对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)
}
}
}
注意,在使用时,我们可以通过 frame
为 RadarView
指定尺寸。否则,AbilityList
和 RadarView
将会等分屏幕宽度。关于这种行为的原因,我们会在下一节里详细谈及。
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 中,像是 CGFloat
,Double
等都是满足 VectorArithmetic
要求的。除此之外,如果提供一对满足 VectorArithmetic
的类型,将它们组合起来生成的 AnimatablePair
也满足 VectorArithmetic
,这让我们可以同时为多个值定义动画。
SwiftUI 为包括 CGPoint
、CGSize
和 Angle
在类的很多基本类型实现了 Animatable
。这也是为什么我们能通过改变某个 View
上或者它的 modifier 上的对应值来进行动画的原因。不过对于一般的 Shape
来说,它所默认提供的 animatableData
没有做什么特殊操作。这其实很合理,像是 CGPoint
、CGSize
和 Angle
这些类型,我们给定初始值和最终值后,总是可以通过插值的方式在这两个值之间进行过渡,也就是动画。但是为一般性的 Shape
定义这类过渡是不可能的。所以想要实现 Shape
的动画,我们需要自己定义合适的 animatableData
。
作为示例,想要实现的是上图这样的动画:当雷达图出现时,我们希望它从顶端的原点开始,内外两个六边形都按照顺时针方向通过动画扇形展开并显示出来。
首先,在 Hexagon
,我们需要一个变量来控制当前的绘制进度。一个 CGFloat
的 progress
值就能很好地完成任务:
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
的值,然后触发 Shape
的 path(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 遵循的布局规则,可以总结为“协商解决,层层上报”:父层级的 View
根据某种规则,向子 View
“提议”一个可行的尺寸;子 View
以这个尺寸为参考,按照自己的需求进行布局:或占满所有可能的尺寸 (比如 Rectangle
和 Circle
),或按照自己要显示的内容确定新的尺寸 (比如 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
的父 View
是 ContentView
,而 ContentView
直接就是 UIHostingController
的 rootView
。SwiftUI 系统经历了以下步骤来进行布局:
rootView
使用整个屏幕尺寸作为“提议”,向HStack
请求尺寸。HStack
在接收到这个尺寸后,会向它的子View
们进行“提议”。- 第一步,扣除掉默认的
HStack
spacing 后,把剩余宽度三等分 (因为HStack
中存在三个子View
),并以其中一份向子View
的Image
进行提议。 Image
会按照它要显示的内容决定自身宽度,并把这个宽度汇报给HStack
。HStack
从 3 中向子View
提案的总宽度中,扣除掉 4 里Image
汇报的宽度,然后将剩余的宽度平分为两部分,把其中一份作为提案宽度提供给Text("User:")
。Text
也根据决定自身的宽度。不过和Image
不太一样,Text
并不“盲目”遵守自身内容的尺寸,而是会更多地尊重提案的尺寸,通过换行 (在没有设定.lineLimit(1)
的情况下) 或是把部分内容省略为 "..." 来修改内容,去尽量满足提案。注意,父View
的提案对于子View
来说只是一种建议。比如这个Text
如果无论如何,需要使用的宽度都比提案要多,那么它也会将这个实际需要的尺寸返回。- 对于最后一个
Text
,采取的步骤和方法与 6 类似。在三个子View
都决定好各自尺寸后,HStack
会按照设定的对齐和排列方式把子View
们水平顺序放置。 - 不要忘记,
HStack
是rootView
的子View
,因此,它也有义务将它的尺寸向上汇报。由于HStack
知道了三个子View
和使用的spacing
的数值 (在例子中我们使用了默认的spacing
值),HStack
的尺寸也得以确定。最后它把这个尺寸 (也就是图中的蓝框部分) 上报。 - 最后,
rootView
把HStack
以默认方式放到自己的座标系中,也即在水平和竖直方向上都居中。
在上例中,布局系统在处理 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
的位置,有时这导致显示内容的重叠。
继续上面的例子,如果我们把 fixedSize
从 frame
之前拿到 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)
结果为:
SwiftUI 中有不少 API 涉及到对齐,这也是最让人困惑的地方之一。你时不时总会遇到疑问:为什么写在这里的对齐不起作用?为什么这里的对齐方式表现非常奇怪?在文档中某个有关对齐的 API 并没有任何解释,它是不是并非准备给一般开发者使用的?
由于 SwiftUI 还处于早期阶段,所以对于上面这些问题,很多时候都可能被误认为是 bug 或者框架还不完善的结果,但大部分时候这并不是事实。上一节里,我们看到了 frame
中的对齐设定。在这一小节,我们会专注来看看各类 Stack View 的对齐,以及它所对应的 Alignment Guide 的相关概念。
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 里的一个静态变量:
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 }
}
- 当前处理的
View
的宽和高,这是很直接的数据。 - 通过
HorizontalAlignment
或者VerticalAlignment
以下标的方式从ViewDimensions
中获取数据。默认情况下,它会返回对应 alignment 的defaultValue
方法的返回值。 - 通过下标获取定义在
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")
}
- 对
HStack
,我们指定了VerticalAlignment.center
为对齐方式 - 对于 "User:",返回的是
d[.bottom]
,也即Text
的底边作为对齐线。注意,只有当alignmentGuide
的第一个参数VerticalAlignment.center
和HStack
的alignment
参数一致时,它才会被考虑。因为alignmentGuide
API 的作用就是修改传入的alignment
的数值。 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
。这在设定自定义对齐中,需要考虑其他方向的对齐时会很有用:
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:]
就非常有用了。
上例中我们已经通过创建满足 AlignmentID
的 MyCenter
,自定义了一个 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)
}
接下来,将它指定为外层 HStack
的 alignment
:
@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))
- 对于上标
Text
,以底部为基准,再加上选中的行到整个HStack
上端的总高度。这会将 "User:" 上标文本固定在HStack
最上方且超出自身一半高度的位置上。 - 明确指定
Image
的中心部位应该和其他部分对齐。 VStack
中的这个显式alignmentGuide
将会覆盖其他隐式行为。VStack
有自己的布局规则,那就是顺次将每个子View
竖直放置。在这个基础上,我们把被选中行的中线位置设定成了对齐位置。这样,SwiftUI 将尝试在满足VStack
的竖直叠放特性的同时,去满足把选中行和HStack
中其他部分的对齐。- 最后,为了让选择切换更自然一些,可以为整个效果加上动画。
经过这些修改,这个用户选择列表就可以完整工作了。相关代码可以在源码文件夹的 "11.Layout" 中找到。
本章里,我们研究了关于 SwiftUI 布局的一些进阶话题。就算不知道这些知识,你可能也可以通过不断尝试和修改,最终找到满足要求的布局写法。但另一方面,我们需要看到,就算是像 frame
或者 alignment
这样的每天都在使用的简单方法背后,所蕴藏的规则和使用方式,也远不止看起来那样容易。大致了解 SwiftUI 布局的背后的原理,以及掌握常用的布局方法,有助于你迅速确定 View
之间的关系,达到事半功倍的效果。
虽然乍看起来有这样那样的问题,但 SwiftUI 的布局系统是经过精心设计的。它使用了声明式的方法让开发者通过描述语句来布局。和传统的 Auto Layout 不同,SwiftUI 中的描述性布局不会出现冲突或者缺失,也不存在由于运行时的布局而导致错误的可能性。它提供的是一种安全的布局方式:只要能够用 SwiftUI 的语句进行描述并通过编译,布局系统就会按照一定的规律生成合理的满足描述的布局。问题在于,你是否足够熟悉这套规律和机制,解释布局语句所带来的结果,并让代码朝向你希望的方向进行改变。
请尝试用 GeometryReader
和 Circle
画出以下图形:
其中灰色的矩形蓝框部分的尺寸由 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
的数值,来确认你的实现对任意矩形都是通用的。
在本章中布局例子中:
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
宽高提案是如何响应的,它们是严格遵守提案尺寸呢,还是更多地去满足内容的尺寸?
尝试修改最终的 PokeMaster app,让详细信息面板的 ZStack
的对齐方式更加“灵活炫酷”一些:
主要来说,希望实现:
- 详细面板的图标实现“骑墙”的对齐效果,图标的中部和面板背景上边缘齐平。
- 宝可梦的图标和它的中文名字 ("秒蛙种子") 首端 (
.leading
) 对齐。 - 宝可梦的英文名字 ("Bulbasaur") 的
.leading
与它的中文名字 ("秒蛙种子") 的.center
对齐。 - 宝可梦的英文名字 ("Bulbasaur") 和它的种族名字 ("种子宝可梦") 尾端 (
.trailing
) 对齐。
信息面板的内容和背景是以 ZStack
的关系组织起来的,与 HStack
和 VStack
不同,ZStack
可以同时接受水平和竖直两个方向上的对齐。这也是使用多个 alignmentGuide
进行不同方向对齐的先决条件。
你可以使用源码文件夹中 "PokeMaster-Finished" 里的项目作为本题练习的起始。