《SwiftUI编程思想》 onevcat 第一版笔记 中
布局
View
布局过程的任务是为view
树中的每个view
分配位置和大小。
布局算法
原则上很简单:对于层级中的每个 view,SwiftUI 都会提供一个建议尺寸 (proposed size,可用空间)。View 将自己布局在这个可用空间内,并报告自己的实际尺寸。然后,系统 (默认情况下)将 view 置于可用空间的中心。想象每个 View 都实现了以下方法
1 | struct ProposedSize { |
在 SwiftUI 中,让布局变得复杂的原因是,在通过建议尺寸来决定实际尺寸的时候,每个 view(或者是 view 修饰器) 的表现会有所不同。
基本View
把任意的 view 包装到一个 frame 里。frame 的尺寸由两个滑条来控制,来探索view 的确切的布局行为
1 | struct MeasureBehavior<Content: View>: View { |
灰色边线表示的是 view 的边框范围,而黑色则是 frame 的边框
Path
总会将建议的尺寸作为实际尺寸返回。如果所建议的某个方向的值为 nil,那么它返回默认值 10
Shape
想让 Path 适配或者填充被建议的尺寸。我们可以通过使用 Shape 协议来达到这个目的。下面是 Shape 的完整定义:
1 | protocol Shape : Animatable, View { |
Shape 的 layout 方法也总会将建议的尺寸作为实际尺寸返回。类似于 Path,Shape 也会在建议尺寸的某个维度为 nil 时选择使用默认值 10。
像是 Rectangle、Circle、Ellipse 和 Capsule 这些内建的形状,会将它们自身绘制在建议尺寸中。那些没有高宽比约束的形状,像是 Rectangle,会选择填满整个可用的空间
当我们创建自定义形状时,最好遵守同样的行为,把可用空间纳入考虑。
1 | struct Triangle: Shape { |
它定义了一个填满建议尺寸的 Shape
Image
默认情况下,Image view 的尺寸是固定的,也就是图片以 point 为单位的大小。这意味着图片view 的 layout 方法会忽略掉布局系统所建议的尺寸,总是返回图片的尺寸。
要让一个图片 view 尺寸可变,或者说,想让它能接受建议尺寸,并将图片适配显示在这个空间里,我们可以在它上面调用
.resizable
。
大部分的图片应该是需要以固定的高宽比展示的,所以 .aspectRatio
经常被直接搭配在.resizable
后面组合使用。
.aspectRatio
修饰器会去获取建议尺寸,并且基于给定的高宽比,创建一个能最大限度填满建议尺寸的新的尺寸值。
Text
Text view 的 layout
方法总会尝试将它的内容适配到建议尺寸中去
1 | view 修饰器来自定义大部分的行为: |
布局修饰器
Frame
.frame
修饰器有两个版本:一个指定固定尺寸的边界,另一个指定可变边界。
想要在尺寸的某个维度上建议 nil,我们可以使用
.fixedSize()
修饰器
Offset
offset 修饰器将建议尺寸转发给它的子 view,并将子 view 的尺寸作为自身尺寸进行汇报。换句话说,它并不影响布局。
Padding
padding 修饰器用来为 view 添加边缘填充,在 padding 修饰器的 layout 方法里,它会将接收到的建议尺寸减去设定的 padding,然后将得到的新尺寸建议给它的子 view。然后,它将子 view 返回的尺寸加上这些 padding 作为自己的尺寸进行返回。
Overlay 和 Background
当我们使用content.overlay(other)
时,系统会创建一个带有两个子 view 的 overlay 修饰器 view:它们分别是 content
和 other
。
当布局一个 overlay 修饰器时,被建议的尺寸会被传给 content。然后 content 报告的尺寸会被作为建议尺寸传递给 other。overlay 修饰器会把 content 的尺寸作为它自己的尺寸进行返回:换句话说,other 所报告的尺寸将被忽略。
1 | extension Overlay { |
overlay 的 layout 实现类似这样
对于 content.background(other)
,除了 other 现在被绘制在 content 的后面之外,整个过程大部分是一样的。
注意的是,content.overlay(other) 和 other.background(content)是不一样的:
前一种写法,content 的尺寸被用作布局尺寸,而后一种写法中 other 的尺寸被用作最终尺寸
clip 和 mask
两者都不会影响布局,但是它们会影响屏幕上的绘制。
当我们使用 .clipped() 时,view 会按照它的边界矩形进行“裁切”。换句话说,view 绘制在边界矩形外部的部分都将变得不可见
clipShape
方法,和 clipped 不同,它接受一个形状作为裁剪蒙版,而不是直接使用边界矩形。有一个有趣的事实,圆角效果 (使用 .cornerRadius) 是通过使用一个RoundedRectangle
调用 clipShape
实现的
通过 .mask 来提供遮罩,mask 可以接受任意的view,并使用这个 view 来作为下层 view 的掩模板 (让下层 view 的内容只在上层 view 的像素区域中进行显示)
Stack View
Stack view 有三种不同形式:它们可以将子 view 们按照水平方向 (HStack),竖直方向 (VStack) 或是垂直于屏幕表面的方向 (ZStack) 来进行布局。
布局算法
Stack
的 layout
方法将经过两轮工作。
在第一轮中,stack
的 layout
方法决定每个子 view 的尺寸是固定的还是可变的
在第二轮中,可变的空间被分配给可变子 view。
布局优先级
当布局优先级被设置时,第二轮会更加复杂一些。元素们会被按照布局优先级分组:拥有最高布局优先级的组将会首先被提供可变空间,然后是第二高的组,以此类推。
1 | HStack(spacing: 0) { |
Stack 对齐
选择让 view 们沿着文本基线 (text baseline) 进行对齐,这在处理多个不同字号的文本时会很有用
自定义对齐
可以创建自定义的对齐准线,或者为每个独立的 view 修改它的对齐行为。对齐准线本质上来说很简单:它是一种计算 view 中某个点的方式。
首先,我们需要一个自定义的识别类型,它拥有一个默认的实现,来计算 view 中竖直方向上的对齐方式:
1 | enum MyCenterID: AlignmentID { |
接下来,我们需要为 VerticalAlignment
添加一个扩展,来使我们的自定义对齐对外可见:
1 | extension VerticalAlignment { |
最后,我们就可以使用这个新的布局了:
1 | HStack(alignment: .myCenter) { |
组织布局代码
可以创建新的 View 结构体,可以在 View 上写一些扩展方法,或者可以创建 ViewModifier。
可以将我们上面的蓝色圆圈按钮写成一个 View 上的扩展,这样我们
就可以在任意 view 上调用它,并在其后方放置一个蓝色圆圈。
1 | extension View { |
现在,我们可以为任意 view 添加圆圈背景了:
1 | Text("Hello") |
也可以将它写成一个自定义的 View 结构体。当创建容器 view 的时
候,接受一个 view builder 而不是常规的普通 view
1 | struct CircleWrapper<Content: View>: View { |
创建 CircleWrapper 的语法会稍有不同:
1 | CircleWrapper { |
可以创建一个 ViewModifier。这通常用在将其他 view 进行包装,或者
是改变一个 view 的布局方式的时候:
1 | struct CircleModifier: ViewModifier { |
要通过这个修饰器创建一个按钮,我们可以使用 View 上的 modifier 方法。在下面的代码中,结果的类型是 ModifiedContent<Text, CircleModifier>:
1 | Text("Hello").modifier(CircleModifier()) |
按钮样式
要为按钮加上样式,我们可以使用 ButtonStyle
协议。它和 ViewModifier 类似,只不过它并不是接受一个 content 作为参数,而是接受一个包含有按钮文本标签以及按钮点击状态的结构体。
1 | struct CircleStyle: ButtonStyle { |
要按照这个按钮样式创建按钮,我们可以在按钮上调用 buttonStyle:
1 | Button("Button", action: {}) |
buttonStyle 的另一个优点是,我们可以一次性地为多个按钮添加样式。buttonStyle 修饰器是定义在 View 上的,它会更改环境。
1 | HStack { |
自定义布局
GeometryReader
可以通过使用 GeometryReader
来与布局流程进行挂钩,以改变部分布局行为。几何读取器最重要的功能,是让我们可以接收到一个 view 的被建议的布局尺寸。
挂钩 (hook)
当使用 GeometryReader 时,有一个重要的注意事项,那就是它会把被建议的尺寸作为实际尺寸进行返回。由于这一尺寸特性,几何读取器经常被用作其他 view 的背景或叠层:它们的尺寸与对象 view 的尺寸将完全一致。
让按钮自动适配文本
1 | Text("Start") |
问题:整个 view 的尺寸将会是文本的尺寸加上填充部分的尺寸;圆圈的
高度会被忽略掉。
Perference 和 GeometryReader
Preference 是通过键和值来进行设置的,要解决前面一节中的问题,我们的策略是:使用 GeometryReader 来测量我们按钮中的 Text尺寸。然后我们使用 preference 将这个值沿树向上传递,并在整个 view 外面加上一个高度和宽度都等于文本宽度的 frame (我们假设文本的宽度始终要比它的高度更大)。
1 | struct WidthKey: PreferenceKey { |
1 | struct TextWithCircle: View { |
当渲染 TextWithCircle
view 时,布局引擎首先为 .frame
修饰器建议一个尺寸。因为TextWithCircle 的 width 属性初始值为 nil,frame 将会把这个建议尺寸沿 view 层级向下进行传递,直至到达 Text view。在 Text 完成自身布局后,文本背景中的几何读取器将使用和文本完全一样的尺寸进行布局。在里面,我们使用了 .preference 来把文本的尺寸沿树向上传递。
接下来,.onPreferenceChange 会被触发,view 的状态发生改变,于是 view 的构建和布局过程再次发生。这一次,self.width 将包含文本的宽度,于是 view 被正确布局了。所有这一切都发生在同一次渲染流程 (也就是在屏幕刷新之间) 中。
尝试了使用 EmptyView() 而非 Color.clear 来传递 preference,但令人惊讶的
是,这种方式无法正确工作。我们不是很清楚这是不是一个 bug,还是说有更深层的
原因导致了 EmptyView() 和 Color.clear 的不同。
注意不要写出无限循环
锚点
锚点 (anchor) 可以用来在布局层级的不同部分之间进行点或者矩形的传递。锚点本身是对一个值 (比如,一个点) 的包装,它能够在 view 层级中的其他不同 view 的坐标系统内进行解析并获取到新的坐标。我们可以把锚点想象为 UIKit 中 UIView 的 convert(_:from:)
的一个更加安全的
替代方法。
带下划线的Tab bar
1 | struct BoundsKey: PreferenceKey { |
1 | struct ContentView: View { |
.onPreferenceChange
要求值必须满足 Equatable 协议。但不幸的是,Anchor 并没有满足Equatable。作为替代,我们需要使用 .overlayPreference
或者 .backgroundPreference
,但是不被允许在两者里改变状态
要将锚点解析到另一个 view 的坐标系统中,我们需要使用 GeometryReader。
自定义布局
构建一个自定义布局的容器 view:一个能将自身布局为水平或者竖直的stack。
第一步,我们要收集子 view 的尺寸,为此我们定义一个 preference key。Preference 的值是一个字典,它的 key 是子 view 的 index,值为它的尺寸。
1 | struct CollectSizePreference: PreferenceKey { |
接下来,我们用这个 preference key 创建一个 view 修饰器,来将 view 的尺寸和 index 向上传递。
1 | struct CollectSize: ViewModifier { |
最后构建 stack view本身。SwiftUI 内建的 stack 接受一个 view builder,并会指出里面的内容具体是一个单独的view,是一个 tuple view,还是一个 ForEach。
1 | struct Stack<Element, Content: View>: View { |