-
- 2.1. What is the Layout Protocol?
- 2.2. Family Dynamics of the View Hierarchy
- 2.3. Our First Layout Implementation
- 2.4. Container Alignment
- 2.5. Layout Priorities
- 2.6. Custom Values: LayoutValueKey
- 2.7. Default Spacing
- 2.8. Layout Properties and Spacer()
- 2.9. Layout Cache
- 2.10. Great Pretenders
- 2.11. Switching Layouts with AnyLayout
The SwiftUI Layout Protocol – Part 1
1. Introduction
今年最好的SwiftUI更新之一必须是Layout protocol。我们不仅终于掌握了布局流程,而且也是更好地了解布局如何在SwiftUI中工作的绝佳机会。
早在2019年,我就写了一篇关于使用Frame Behaviors with SwiftUI的文章。在其中,我描述了父视图和子视图如何协商view的最终大小。那里描述的许多事情都必须通过观察各种测试的结果来猜测。
现在,有了布局协议,这就像前往遥远的太阳系,亲眼看到它。这非常令人兴奋。
创建基本的布局并不难,我们只需要实现两种方法。尽管如此,我们可以使用很多选项来实现更复杂的容器。我们将在典型的布局示例之外进行探索。有一些有趣的话题我还没有在任何地方看到解释过,所以我会在这里展示它们。然而,在我们深入这些领域之前,我们需要从奠定坚实的基础开始。
有很多东西要涵盖,所以我将把这篇文章分成两部分:
1.1. Part 1 – The Basics:
- What is the Layout Protocol?
- Family Dynamics of the View Hierarchy
- Our First Layout Implementation
- Container Alignment
- Custom Values: LayoutValueKey
- Default Spacing
- Layout Properties and Spacer()
- Layout Cache
- Great Pretenders
- Switching Layouts with AnyLayout
- Part 1 Conclusion
1.2. Part 2 – Advanced Layouts:
- And The Fun Begins!
- Custom Animations
- Bi-directional Custom Values
- Avoiding Layout Loops and Crashes
- Recursive Layouts
- Layout Composition
- Another Composition Example: Interpolating Two Layouts
- Using Binding Parameters
- A Helpful Debugging Tool
- Final Thoughts
如果您已经熟悉Layout protocol,您可能需要跳到第2部分。没关系,尽管我仍然建议你阅览第一部分,至少表面上是这样。当我们开始探索第2部分中描述的更高级功能时,这将确保我们都在同一个频道上。
如果在阅读这篇文章的任何时候,您觉得Layout protocol不适合您(至少目前是这样),我建议您查看:A Helpful Debugging Tool (in part 2)。该工具一般可以帮助您使用SwiftUI,并且不需要您了解布局协议才能使用它。我把它放在第二部分的末尾是有原因的。这是因为该工具是使用这篇文章中的知识构建的。尽管如此,您只需复制代码并按原样使用即可。
2. Part 1 – The Basics:
2.1. What is the Layout Protocol?
采用布局协议的类型的任务是告诉SwiftUI如何放置一组视图,以及它需要多少空间来这样做。这些类型被用作视图容器。虽然布局协议是今年的一个新事物(至少是公开的),但我们从了解SwiftUI的第一天起就一直在使用它,每次都在HStack或VStack中放置视图。
请注意,至少目前,Layout Protocol不能用于创建惰性容器,如LazyHStack或LazyVStack。惰性容器是指那些仅在滚入时渲染视图并在滚出时停止渲染的容器。
重要的是要知道Layout类型不是View。例如,他们没有视图那样的body属性。但别担心,暂时你可以把它们当成视图,并把它们用作视图。当您将Layout插入SwiftUI代码时,该框架使用一些不错的swift语言技巧,使您的布局透明地生成视图。我将在稍后的《Great Pretenders》一节中解释这一切。
2.2. Family Dynamics of the View Hierarchy
在开始编码布局之前,让我们刷新SwiftUI框架的支柱。正如我在旧帖子Frame Behaviors with SwiftUI中描述的那样,在布局过程中,父视图向子视图提出了一个尺寸,但最终由孩子们决定如何绘制自己。然后,它会向父视图传达这一点,以便它能够采取相应的行动。有三种可能的情景。我们将专注于水平轴(宽度),但垂直轴(高度)也是如此:
- Scenario 1: If the child takes less than what’s been offered:
在本例中,该Text视图提供了比绘制文本所需的更多空间:

struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Rectangle().fill(.green)
Text("Hello World!")
Rectangle().fill(.green)
}
.padding(20)
}
}
在这个例子中,界面窗口宽400 pt。因此,文本提供了HStack宽度的三分之一((400-40)/3 = 120)。在这120pt中,文本只需要74pt,并将其传达给父视图(HStack)。父视图现在可以拿走多余的46 pt,并与其他子视图一起使用。
因为其他的子视图是shapes,所以这些占据了给他们的一切空间。在这种情况下,120 + 46 / 2 = 143。
- Scenario 2: If the child takes exactly what’s been offered:
shapes是views的一个例子,可以拿走提供给他们的任何空间。在上一个示例中,这些绿色矩形占据了提供的一切,但不能多取一个像素。
- Scenario 3: If the child takes more than what’s been offered:
考虑以下示例。图像视图(除非使用resizable的方法进行了修改)是固执的。他们需要多少空间就占用多少空间。在下面的示例中,图像是300×300,这就是它用来绘制自己的尺寸。然而,通过调用frame(width:100),子视图只能获得100pt。父视图是否无能为力,应该照子视图说的做?不完全是。子视图将使用300 pt来绘图,但父视图会像子视图只有100pt宽一样布局其他视图。因此,我们有一个子视图溢出它的边界,但周围的视图不受图像使用的额外空间的影响。在下面的示例中,黑色边框显示了提供给图像的空间。

struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Rectangle().fill(.yellow)
Image("peach")
.frame(width: 100)
.border(.black, width: 3)
.zIndex(1)
Rectangle().fill(.yellow)
}
.padding(20)
}
}
view的行为方式有很多差异。例如,我们看到文本占用的比提供的要少。但是,如果它需要的超过提供的,可能会发生几件事,具体取决于您如何配置视图。例如,它可能会截断文本以保持在提供的大小范围内,或者它可能垂直增长以以提供的宽度显示文本。或者,如果您使用fixedSize修饰符,它甚至可以像示例中的图像一样溢出。请记住,fixSize告诉视图使用其理想尺寸,无论它们提供得多么少。
2.3. Our First Layout Implementation
创建Layout类型需要我们至少实现两种方法:sizeThatFits和 placeSubviews。这些方法接收一些新类型作为参数: ProposedViewSize 和 LayoutSubview。在我们开始编写方法之前,让我们看看这些参数是什么样子的:
ProposedViewSize
父视图使用 ProposedViewSize 来告诉子视图如何计算自己的大小。这个类型虽然简单,但功能强大。它只是一对可选的CGFloats,用于拟定的宽度和高度。然而,正是我们如何解释这些值,才使它们变得有趣。
这些属性可以具有具体值(例如35.0、74.0等),但当这些值为0.0、nil或.infinity时,也有特殊意义:
- 对于具体宽度,例如45.0,父宽度正好提供45.0 pt,视图应确定该宽度的自身大小。
- 对于0.0的宽度,子视图应该用最小尺寸做出响应。
- 对于.infinity宽度,子视图应以其最大尺寸做出响应。
- 对于nil值,子视图应该用其理想大小来响应。
ProposedViewSize还具有一些预定义值:
ProposedViewSize.zero = ProposedViewSize(width: 0, height: 0)
ProposedViewSize.infinity = ProposedViewSize(width: .infinity, height: .infinity)
ProposedViewSize.unspecified = ProposedViewSize(width: nil, height: nil)
LayoutSubview
sizeTheFits和 placeSubviews方法还接收Layout.Subviews参数,它们是LayoutSubview元素的集合。每个视图都是父节点的直系后代一个。尽管它的名字如此,但该类型不是view,而是view的代理。我们可以查询这些代理,以了解我们正在布局的单个视图的布局信息。例如,自SwiftUI推出以来,我们可以首次直接查询视图的最小、理想或最大尺寸,或者我们还可以获得每个视图的布局优先级,以及其他有趣的值。
Writing the sizeThatFits Method
func sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGSize
SwiftUI将调用我们的sizeThatFits方法,使用我们的布局确定容器的大小。在写这种方法时,我们应该认为自己既是父视图又是子视图:我们是父视图,询问子视图的尺寸。但根据我们子视图的回复,我们也是一个子视图,告诉我们的父视图我们的尺寸。
该方法接收size提案、子视图代理集合和缓存。这最后一个参数可用于提高我们布局和其他一些高级应用程序的性能,但目前我们不会使用它。
当sizeThatFits方法在给定维度(即宽度或高度)中给出nil时,我们应该返回该维度容器的理想大小。当给定维度的值为0.0时,我们应该返回该维度中容器的最小尺寸。当给定维度的值为.infinity时,我们应该返回该维度中容器的最大尺寸。
请注意,可以通过不同的提议多次调用sizeThatFits,以测试容器的灵活性。提议可以是每个维度上述情况的任何组合。例如,您可能会接到使用 ProposedViewSize(宽度:0.0,高度:.infinity)的调用。
根据我们目前掌握的信息,让我们从我们的第一个布局开始。我们将从创建一个basic HStack开始。我们将称之为SimpleHStack。为了将两者进行比较,我们将创建一个视图,在SimpleHStack(绿色)之上放置一个标准的HStack(蓝色)。在我们的第一次尝试中,我们将实现sizeThatFits,但我们会暂时将其他所需的方法(placeSubviews)留空。

struct ContentView: View {
var body: some View {
VStack(spacing: 20) {
HStack(spacing: 5) {
contents()
}
.border(.blue)
SimpleHStack(spacing: 5) {
contents()
}
.border(.blue)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white)
}
@ViewBuilder func contents() -> some View {
Image(systemName: "globe.americas.fill")
Text("Hello, World!")
Image(systemName: "globe.europe.africa.fill")
}
}
struct SimpleHStack: Layout {
let spacing: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
/// 计算每个视图的所有理想尺寸
let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let spacing = spacing * CGFloat(subviews.count - 1)
let width = spacing + idealViewSizes.reduce(0) { $0 + $1.width }
/// 子视图最高的就是容器的高度
let height = idealViewSizes.reduce(0) { max($0, $1.height) }
return CGSize(width: width, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
// ...
}
}
正如您所观察到的,两个堆栈的size是相同的。然而,由于我们没有在 placeSubviews 方法中编写任何代码,因此所有视图都放置在我们的容器中间。如果您没有显式放置视图,这是默认值。
在我们的sizeThatFits方法中,我们首先计算每个视图的所有理想尺寸。我们可以很容易地做到这一点,因为我们收到的子视图代理具有返回给定提案子视图大小的方法。
一旦我们计算了所有理想尺寸,我们就会通过添加所有子视图宽度和这些视图之间的间距来计算容器大小。就高度而言,我们的堆栈将与最高的子视图一样高。
您可能已经观察到,我们完全忽略了我们提供的尺寸。我们将在一分钟内回到这个。现在,让我们现在实施 placeSubviews。
Writing the placeSubviews Method
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache)
在SwiftUI测试了我们几个提案的容器视图后,通过使用不同的提案值反复调用sizeThatFits,它最终将调用我们的placeSubviews方法。我们在这里的目标是迭代子视图,确定它们的位置并将其放置在那里。
除了sizeThatFits接收的相同参数大小外, placeSubviews还获得CGRect参数(bounds)。边界矩形具有我们在sizeThatFits方法中要求的size。通常,rect的起源是(0,0),但你不应该假设这一点。如果我们正在编写布局,原点可能具有不同的值,我们将在稍后看到。
放置视图很简单,这要归功于子视图代理,它们有一个place方法。我们必须提供视图的坐标、锚点(如果是unspecified就是center)和提案,以便子视图可以相应地绘制自己。
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for v in subviews {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
现在,还记得我提到过我们忽略了从容器的父视图收到的提案吗?这意味着我们的SimpleHStack容器将始终具有相同的尺寸。无论提供什么,容器都会使用.unspecified计算尺寸和位置,这意味着容器将始终具有理想的尺寸。在这种情况下,容器的理想尺寸是让它将所有子视图用自己的理想尺寸放置的大小。看看如果我们改变提供的尺寸会发生什么。提供的宽度由红色边框表示:

观察我们的SimpleHStack将如何忽略提供的尺寸,它总是以其理想的尺寸绘制,该尺寸适合其所有子视图的理想尺寸。
2.4. Container Alignment
Layout协议还允许我们定义容器的对齐指南。请注意,这指示容器作为一个整体如何与其他视图对齐。它对容器内的视图没有影响。
在下面的示例中,我们使我们的SimpleHStack容器与第二个视图对齐,但前提是容器与leading对齐(如果您将VStack对齐更改为trailing,您将不会看到任何特殊的对齐)。
带有红色边框的视图是SimpleHStack,黑色边框视图是标准的HStack容器。绿色边框表示封闭的VStack。

struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
contents()
}
.border(.black)
SimpleHStack(spacing: 5) {
contents()
}
.border(.red)
HStack(spacing: 5) {
contents()
}
.border(.black)
}
.background { Rectangle().stroke(.green) }
.padding()
.font(.largeTitle)
}
@ViewBuilder func contents() -> some View {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
}
struct SimpleHStack: Layout {
// ...
func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGFloat? {
if guide == .leading {
return subviews[0].sizeThatFits(proposal).width + spacing
} else {
return nil
}
}
}
2.5. Layout Priorities
使用HStack时,我们知道所有视图都平等地竞争宽度,除非它们具有不同的布局优先级。默认情况下,所有视图的布局优先级为0.0。但是,您可以通过调用 layoutPriority()修饰符来更改视图的布局优先级。
执行布局优先级是容器布局的责任。因此,如果我们创建一个新的布局(如果相关),我们应该添加一些逻辑来考虑视图的布局优先级。我们怎么做,这取决于我们。虽然有更好的方法(我们将在一分钟内解决它们),但您可以使用视图的布局优先级值并赋予其任何意义。例如,在上一个示例中,我们将根据视图的布局优先级值将视图从左到右放置。
为了实现这一目标,我们不是迭代子视图集合,而是按其优先级排序:
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for v in subviews.sorted(by: { $0.priority > $1.priority }) {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
在下面的示例中,蓝色圆圈将首先出现,因为它的优先级高于其他圆圈。

SimpleHStack(spacing: 5) {
Circle().fill(.yellow)
.frame(width: 30, height: 30)
Circle().fill(.green)
.frame(width: 30, height: 30)
Circle().fill(.blue)
.frame(width: 30, height: 30)
.layoutPriority(1)
}
2.6. Custom Values: LayoutValueKey
不建议将布局优先级用于优先级以外的其他内容。这可能会混淆您容器的其他用户,甚至您未来的用户。幸运的是,我们有另一种机制为视图添加新值。此值不限于CGFloat,它们可以具有任何类型(正如我们将在后面的其他示例中看到的那样)。
我们将使用我们称为PreferredPosition的新值重写上一个示例。首先要做的是创建一个符合LayoutValueKey的类型。我们只需要一个具有静态默认值的结构体。当没有明确指定时,将使用此默认值。
struct PreferredPosition: LayoutValueKey {
static let defaultValue: CGFloat = 0.0
}
就这样,我们视图中有一个新属性。要设置值,我们使用 layoutValue() 修饰符。要读取该值,我们使用LayoutValueKey类型作为视图代理的下标:
SimpleHStack(spacing: 5) {
Circle().fill(.yellow)
.frame(width: 30, height: 30)
Circle().fill(.green)
.frame(width: 30, height: 30)
Circle().fill(.blue)
.frame(width: 30, height: 30)
.layoutValue(key: PreferredPosition.self, value: 1.0)
}
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
let sortedViews = subviews.sorted { v1, v2 in
v1[PreferredPosition.self] > v2[PreferredPosition.self]
}
for v in sortedViews {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
这个代码看起来不像我们用layoutPriority编写的代码那么干净,但可以通过以下两个扩展轻松修复:
extension View {
func preferredPosition(_ order: CGFloat) -> some View {
self.layoutValue(key: PreferredPosition.self, value: order)
}
}
extension LayoutSubview {
var preferredPosition: CGFloat {
self[PreferredPosition.self]
}
}
现在我们可以像这样重写我们的代码:
SimpleHStack(spacing: 5) {
Circle().fill(.yellow)
.frame(width: 30, height: 30)
Circle().fill(.green)
.frame(width: 30, height: 30)
Circle().fill(.blue)
.frame(width: 30, height: 30)
.preferredPosition(1)
}
struct SimpleHStack: Layout {
// ...
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for v in subviews.sorted(by: { $0.preferredPosition > $1.preferredPosition }) {
v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
pt.x += v.sizeThatFits(.unspecified).width + spacing
}
}
}
2.7. Default Spacing
到目前为止,我们的SimpleHStack一直在使用我们在初始化布局时提供的间距值。但是,如果您已经使用HStack一段时间了,您知道如果没有指定间距,堆栈将提供默认间距,该间距将根据视图的平台和上下文而有所不同。
如果视图位于文本视图旁边,则可以具有间距,如果位于图像旁边,则可以具有不同的间距。此外,每条边都有自己的偏好。
那么,我们如何用我们的SimpleHStack复制相同的行为呢?好吧,我之前提到过,这些子视图代理是丰富的布局知识......它们并不令人失望。他们也有办法查询他们的空间偏好。
struct SimpleHStack: Layout {
var spacing: CGFloat? = nil
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let accumulatedWidths = idealViewSizes.reduce(0) { $0 + $1.width }
let maxHeight = idealViewSizes.reduce(0) { max($0, $1.height) }
let spaces = computeSpaces(subviews: subviews)
let accumulatedSpaces = spaces.reduce(0) { $0 + $1 }
return CGSize(width: accumulatedSpaces + accumulatedWidths,
height: maxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
let spaces = computeSpaces(subviews: subviews)
for idx in subviews.indices {
subviews[idx].place(at: pt, anchor: .topLeading, proposal: .unspecified)
if idx < subviews.count - 1 {
pt.x += subviews[idx].sizeThatFits(.unspecified).width + spaces[idx]
}
}
}
func computeSpaces(subviews: LayoutSubviews) -> [CGFloat] {
if let spacing {
return Array<CGFloat>(repeating: spacing, count: subviews.count - 1)
} else {
return subviews.indices.map { idx in
guard idx < subviews.count - 1 else { return CGFloat(0) }
return subviews[idx].spacing.distance(to: subviews[idx+1].spacing, along: .horizontal)
}
}
}
}
请注意,除了使用空间首选项外,您还可以告诉系统容器视图的空间首选项是什么。这样,SwiftUI将知道如何用周围的视图间隔它。为此,您需要实现布局方法spacing(subviews:cache:)。
2.8. Layout Properties and Spacer()
Layout协议有一个静态属性,您可以实现,称为layoutProperties。根据文档,LayoutProperties包含布局容器的布局特定属性。在撰写本文时,只有一个属性定义:stackOrientation。
struct MyLayout: Layout {
static var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .vertical
return properties
}
// ...
}
stackOrientation告诉像Spacer这样的视图是否应该在其水平轴或垂直轴上展开。例如,如果您检查间隔视图代理的最小、理想和最大尺寸,这就是它在不同容器中返回的内容,每个容器都有不同的堆栈方向:
stackOrientation | minimum | ideal | maximum |
---|---|---|---|
.horizontal | 8.0 × 0.0 | 8.0 × 0.0 | .infinity × 0.0 |
.vertical | 0.0 × 8.0 | 0.0 × 8.0 | 0.0 × .infinity |
.none or nil | 8.0 × 8.0 | 8.0 × 8.0 | .infinity × .infinity |
2.9. Layout Cache
Layout的缓存通常被视为提高我们布局性能的一种方式。然而,它还有其他用途。只需将其视为一个存储数据的地方,我们需要在sizeThatFits和placeSubviews调用中持久化。想到的第一个应用场景是性能改进。然而,它对于与其他 sub-layouts共享信息也非常有用。当我们到达布局组合示例时,我们将探索这一点,但让我们从学习如何使用缓存来提高性能开始。
在布局过程中,SwiftUI多次调用sizeThatFits和 placeSubviews方法。该框架测试我们的容器是否具有灵活性,以确定整个视图层次结构的最终布局。为了提高布局容器的性能,SwiftUI允许我们实现一个缓存,该缓存仅在容器内部至少一个视图发生变化时才会更新。由于对于单个视图更改,sizeThatFits和 placeSubviews可以多次调用,因此保留不需要为每次调用重新计算的数据缓存是有意义的。
使用缓存不是强制性的。事实上,通常情况下,你不需要它。无论如何,在没有缓存的情况下编码我们的布局可能更容易,如果我们发现需要它,稍后添加它。SwiftUI已经做了一些缓存。例如,从子视图代理获得的值会自动存储在缓存中。具有相同参数的重复调用将使用缓存的结果。在makeCache(subviews:)文档页面中,对您可能想要实现自己的缓存的原因进行了很好的讨论。
另请注意,sizeThatFits和 placeSubviews中的缓存参数是一个inout参数。这意味着您也可以更新这些函数中的缓存存储。我们将在RecursiveWheel示例中看到这如何特别有帮助。
例如,这是使用更新cache的SimpleHStack。我们需要做的是:
- 创建一个具有我们缓存数据的类型。在本例中,我称之为CacheData,它将计算maxHeight和视图之间的spaces。
- 实现makeCache(subviews:)来创建缓存。
- 可选实现updateCache(subviews:)。当检测到更改时,会调用此方法。提供了一个默认实现,基本上通过调用makeCache重新创建缓存。
- 请记住在sizeThatFits和 placeSubviews中更新缓存参数的类型。
struct SimpleHStack: Layout {
struct CacheData {
var maxHeight: CGFloat
var spaces: [CGFloat]
}
var spacing: CGFloat? = nil
func makeCache(subviews: Subviews) -> CacheData {
return CacheData(maxHeight: computeMaxHeight(subviews: subviews),
spaces: computeSpaces(subviews: subviews))
}
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
cache.maxHeight = computeMaxHeight(subviews: subviews)
cache.spaces = computeSpaces(subviews: subviews)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {
let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let accumulatedWidths = idealViewSizes.reduce(0) { $0 + $1.width }
let accumulatedSpaces = cache.spaces.reduce(0) { $0 + $1 }
return CGSize(width: accumulatedSpaces + accumulatedWidths,
height: cache.maxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {
var pt = CGPoint(x: bounds.minX, y: bounds.minY)
for idx in subviews.indices {
subviews[idx].place(at: pt, anchor: .topLeading, proposal: .unspecified)
if idx < subviews.count - 1 {
pt.x += subviews[idx].sizeThatFits(.unspecified).width + cache.spaces[idx]
}
}
}
func computeSpaces(subviews: LayoutSubviews) -> [CGFloat] {
if let spacing {
return Array<CGFloat>(repeating: spacing, count: subviews.count - 1)
} else {
return subviews.indices.map { idx in
guard idx < subviews.count - 1 else { return CGFloat(0) }
return subviews[idx].spacing.distance(to: subviews[idx+1].spacing, along: .horizontal)
}
}
}
func computeMaxHeight(subviews: LayoutSubviews) -> CGFloat {
return subviews.map { $0.sizeThatFits(.unspecified) }.reduce(0) { max($0, $1.height) }
}
}
如果我们每次调用其中一个布局函数时都会打印一条消息,请参阅下面我们获得的结果。如您所见,缓存计算两次,但其他方法被调用25次!
makeCache called <<<<<<<<
sizeThatFits called
sizeThatFits called
sizeThatFits called
sizeThatFits called
placeSubiews called
placeSubiews called
updateCache called <<<<<<<<
sizeThatFits called
sizeThatFits called
sizeThatFits called
sizeThatFits called
placeSubiews called
placeSubiews called
sizeThatFits called
sizeThatFits called
placeSubiews called
sizeThatFits called
placeSubiews called
placeSubiews called
sizeThatFits called
placeSubiews called
placeSubiews called
sizeThatFits called
sizeThatFits called
sizeThatFits called
placeSubiews called
请注意,除了使用缓存参数来提高性能外,还有另一种用途。我们将在本帖的第二部分的RecursiveWheel示例中回顾它。
2.10. Great Pretenders
正如我已经提到的,Layout局协议不采用View协议。那么,为什么我们在ViewBuilder闭包中使用布局容器,就好像它们是视图一样呢?事实证明,当您将Layout放在代码中时,Layout有一个系统调用的函数来生成视图。那个函数叫什么?你可能已经猜到了:
func callAsFunction<V>(@ViewBuilder _ content: () -> V) -> some View where V : View
由于语言新的添加(在SE-0253中描述和解释),名为callAsFunction的方法是特殊的。当我们像使用函数一样使用类型实例时,会调用这些方法。在这种情况下,这可能令人困惑,因为我们似乎只是在初始化类型,而实际上,我们做的不止这些。我们正在初始化该类型,然后调用其callAsFunction方法。由于此callAsFunction方法的返回值是一个视图,我们可以将其放置在SwiftUI代码中。
SimpleHStack(spacing: 10).callAsFunction({
Text("Hello World!")
})
// Thanks to SE-0253 we can abbreviate it by removing the .callAsFunction
SimpleHStack(spacing: 10)({
Text("Hello World!")
})
// And thanks to trailing closures, we end up with:
SimpleHStack(spacing: 10) {
Text("Hello World!")
}
如果我们的布局没有初始化器参数,代码会更加简化:
SimpleHStack().callAsFunction({
Text("Hello World!")
})
// Thanks to SE-0253 we can abbreviate it by removing the .callAsFunction
SimpleHStack()({
Text("Hello World!")
})
// And thanks to single trailing closures, we end up with:
SimpleHStack {
Text("Hello World!")
}
因此,您有它,layout类型不是视图,但当您将它们放置在SwiftUI代码中时,它们确实会产生视图。这个快速的技巧(callAsFunction)还可以切换到不同的布局,同时保持视图标识,如下一节所述。
2.11. Switching Layouts with AnyLayout
Layout容器的另一个有趣方面是,我们可以更改容器的布局,SwiftUI将在两者之间很好地动画化。不需要额外的代码!这是因为保留了视图的身份。SwiftUI将此视为视图更改,而不是两个单独的视图。


struct ContentView: View {
@State var isVertical = false
var body: some View {
let layout = isVertical ? AnyLayout(VStackLayout(spacing: 5)) : AnyLayout(HStackLayout(spacing: 10))
layout {
Group {
Image(systemName: "globe")
Text("Hello World!")
}
.font(.largeTitle)
}
Button("Toggle Stack") {
withAnimation(.easeInOut(duration: 1.0)) {
isVertical.toggle()
}
}
}
}
三元运算符(condition ?Result1:Result2)要求两个返回表达式使用相同的类型。AnyLayout(一种类型擦除的布局)来拯救这里。
注意:如果您观看2022年WWDC关于SwiftUI的session,您可能已经看到苹果工程师使用类似的示例,但使用VStack而不是VStackLayout,使用HStack而不是HStackLayout。这太过时了。在测试版3之后,HStack和VStack不再采用布局协议,他们添加了VStackLayout和HStackLayout布局(分别由VStack和HStack视图使用)。他们还添加了ZStackLayout和GridLayout。
3. Conclusion
如果我们停下来考虑每个可能的场景,编写布局容器的前景可能会很大。有些视图使用尽可能多的空间,其他人试图容纳,其他人会使用更少,等等。还有布局优先级,当多个视图争夺同一空间时,这会让事情变得更难。然而,这项任务可能并不像看起来那么艰巨。我们可能会成为我们自己布局的用户,我们可能会提前知道我们的容器将拥有哪种类型的视图。例如,如果您计划仅将容器与正方形图像或文本视图一起使用,或者如果您知道容器将具有特定的大小,或者如果您确定您的所有视图都将具有相同的优先级等。这些信息可以大大简化您的任务。即使你不能有这种奢侈来做出这个假设,它也可能是一个开始编码的好地方。让您的布局在某些场景中工作,然后开始为更复杂的情况添加代码。
在这篇文章的第二部分,我们将开始探索一些非常有趣的主题,如自定义动画、双向自定义值、递归布局或组合布局。我还将介绍一个非常有用的调试工具,即使您没有创建自己的布局,您也可以使用该工具。