历经六个版本的迭代,SwiftUI 已不再是一个新兴框架。然而,开发者在使用过程中仍然会不时遇到由框架代码 Bug 引发的各种奇怪问题。本文将通过剖析一个 Grid
布局异常的案例,探讨在日常 SwiftUI 开发中遇到问题时的分析思路和解决策略。
Grid 布局中的一个反常现象
近日,一位网友在我的 Discord 社区中分享了他在使用 Grid
进行布局时遇到的一个与预期不符的情况。
这位开发者的目标是创建一个单行的 Grid
,包含三个元素,其中第三个元素是一个跨两列的内嵌 Grid
。以下是相关代码:
struct GridBug: View {
var body: some View {
Grid {
GridRow {
ColorView(.orange)
ColorView(.indigo)
// 内嵌 Grid
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
.gridCellColumns(2) // 跨两列
}
}
.border(.red, width: 2)
.frame(width: 350, height: 350)
.border(.blue, width: 2)
}
}
struct ColorView: View {
let color: Color
init(_ color: Color) {
self.color = color
}
var body: some View {
color
}
}
理想情况下,布局应该呈现如下效果:外部 Grid
填满指定的尺寸空间,而内嵌的 Grid
占据其中的一半宽度。
然而,实际运行代码后,得到的结果却是:外部的 Grid
未能完全填充给定的空间。
乍看之下,代码中并没有明显的错误声明。那么,问题究竟出在哪里呢?
定位问题源头
面对这种情况,相信许多开发者和我一样,会首先采用注释法来定位问题所在。
当我们注释掉 .gridCellColumns(2)
修饰符后,布局结果与预期一致:外部 Grid
完全填充了给定空间,并均匀地分为三列。以下是修改后的代码:
struct GridBug: View {
var body: some View {
Grid {
GridRow {
ColorView(.orange)
ColorView(.indigo)
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
// .gridCellColumns(2)
}
}
.border(.red, width: 2)
.frame(width: 350, height: 350)
.border(.blue, width: 2)
}
}
经过反复注释测试和使用其他视图替换内嵌 Grid
的实验,我们最终锁定了问题的源头:内嵌 Grid
与 gridCellColumns
修饰符的组合使用。
换言之,当我们在一个 Grid
中内嵌另一个跨列的 Grid
时,外层 Grid
在计算内部列宽时出现了异常。
问题排查思路
复杂布局中嵌套多个 Grid
的需求显然不容忽视。除了寻找当前问题的解决方案,如果能够精确定位问题并向苹果反馈,很可能会加速 SwiftUI 开发团队修复这一问题。
不久前,我在 AdventureX 上进行了名为《探索 SwiftUI 尺寸的秘密》的演讲,深入讲解了 SwiftUI 布局系统的底层逻辑,以及各种布局容器如何确定子视图和自身尺寸。
作为布局容器之一,Grid
自然同样遵循 SwiftUI 的布局约定,并有其特有的布局规则。
我们可以将 Grid
的布局逻辑概括如下:
- 拥有多列的行需使用
GridRow
声明 - 整个
Grid
的列数由最多列的行决定 - 未用
GridRow
声明的行视为跨所有列 - 单列宽度由该列中最宽单元格决定
Grid
总宽度为所有列宽加上设定的间距之和
可以说,确定列数后,单行 Grid
的布局与 HStack
在接收到明确建议尺寸时颇为相似,它会询问子视图在最小和最大建议尺寸下的需求尺寸,据此判断子视图特征,最终决定布局尺寸。
假设我们的推断正确(即 Grid
向子视图提案的思路),我们可以通过观察 Grid
与子视图间的布局协商数据来验证原因。
SwiftUI 在 iOS 16 中引入的 Layout
协议非常适合这项工作。我们可以创建一个简单的 Layout
实现来一探究竟:
struct LayoutMonitor: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
guard subviews.count == 1, let subView = subviews.first else { fatalError() }
print("Proposal Mode:", proposal)
let result = subView.sizeThatFits(proposal)
print("Required Size:", result)
return result
}
func placeSubviews(in _: CGRect, proposal _: ProposedViewSize, subviews _: Subviews, cache _: inout ()) {}
}
这段代码中的 LayoutMonitor
只接收一个子视图。它将父视图的建议尺寸(Proposed Size)传递给子视图,并将子视图的需求尺寸(Required Size)传回父视图。通过控制台,我们可以观察到这些尺寸信息。LayoutMonitor
是我日常分析布局容器底层实现的主要工具。
建议阅读 SwiftUI 布局 —— 尺寸 一文,深入了解 SwiftUI 中的尺寸概念和底层协商逻辑。
分析问题成因
让我们使用 LayoutMonitor
包装内嵌的 Grid
,观察它与外层 Grid
之间的布局协商过程。
LayoutMonitor { // 只包装 Grid, LayoutMonitor 仍然需要跨两列
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
}
.gridCellColumns(2)
运行后,控制台输出如下:
Proposal Mode: ProposedViewSize(width: Optional(0.0), height: Optional(0.0))
Required Size: (8.0, 16.0)
Proposal Mode: ProposedViewSize(width: Optional(inf), height: Optional(inf))
Required Size: (inf, inf)
Proposal Mode: ProposedViewSize(width: Optional(167.0), height: Optional(350.0))
Required Size: (167.0, 350.0)
分析输出结果,我们发现外层 Grid
与内部 Grid
进行了三次通信:
- 最小建议尺寸模式:内部
Grid
返回了一个令人费解的需求尺寸(8.0, 16.0)
,这与预期不符。 - 最大建议尺寸模式:内部
Grid
返回的需求尺寸(inf, inf)
符合预期。 - 明确建议尺寸模式:外部
Grid
给内部Grid
提供的横向尺寸167
是正确的(350/2 - 8
,其中 8 是默认间距)。
除了最小建议尺寸模式下的异常返回值,其他模式下的返回值都正常。那么,问题是否出在这个 (8.0, 16.0)
的需求尺寸上?更准确地说,是否 Grid
在应用 gridCellColumns
后,在最小建议尺寸模式下返回了错误的需求尺寸?
为进一步验证这一猜测,我又进行了以下实验:
- 使用
frame
调整内部 Grid 在最小建议尺寸下的需求尺寸:
LayoutMonitor {
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
.frame(minWidth: 0, minHeight: 0) // 使用 frame 调整 Grid 在最小建议尺寸下的需求尺寸
}
.gridCellColumns(2)
- 使用
frame
调整内部Grid
+gridCellColumns
组合视图在最小建议尺寸下的需求尺寸:
LayoutMonitor {
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
}
.gridCellColumns(2)
.frame(minWidth: 0, minHeight: 0) // 调整 Grid + gridCellColumns 的复合视图
- 将内部
Grid
替换为Color
:
LayoutMonitor {
Color.red
}
.gridCellColumns(2)
这些测试的结果均符合预期,且在最小建议尺寸模式下返回的需求尺寸正常((0,0)
)。
通过这些实验,我们可以确定当前的布局问题源自内部 Grid
+ gridCellColumns
在最小建议尺寸模式下返回的异常需求尺寸。至此,我们就确定了问题的成因。
至于这个异常返回值如何影响了最终布局结果,由于无法查看 Grid
的内部实现代码,我们无从得知。不过,我已将这一情况反馈给苹果( FB14556654 ),希望他们能借此线索尽早修复这个错误。
优化解决方案
尽管我们可以通过 frame
修饰符调整最小建议尺寸模式下的需求尺寸来解决当前问题,但这种方法并不直观。这种解决方案虽然针对问题本质,却过于具体,难以成为一种通用的开发范式来从根本上避免类似问题。
鉴于问题源自 Grid
和 gridCellColumns
的组合使用,我们应该寻找替代方案来构建代码。在这里,我推荐使用 Color
配合 overlay
修饰器的方式来满足当前需求:
Color.clear
.overlay(
Grid {
GridRow {
ColorView(.cyan)
ColorView(.yellow)
}
ColorView(.mint)
ColorView(.green)
}
)
.gridCellColumns(2)
在我的博客中,多篇文章都运用了类似的技巧。使用 Color.clear
作为占位视图不仅确保了布局的准确性,还能通过 overlay
为内部视图( 此处为内嵌 Grid
)提供正确的建议尺寸。此外,overlay
修饰器允许我们设置对齐指南,为布局提供了更大的灵活性。
要深入了解
overlay
在布局中的应用技巧,推荐阅读 深入探索 SwiftUI 中的 Overlay 和 Background 修饰器 一文。
总结与启示
本文探讨了一个特定场景下的 Grid
布局异常问题。虽然一些经验丰富的开发者可能能够凭直觉找到解决方案,但通过本文介绍的系统分析方法,我们不仅解决了问题,还揭示了其背后的原因。
这种分析的过程具有双重价值:
- 问题解决:我们不仅找到了解决方案,还理解了问题的根源,这有助于在未来遇到类似问题时更快速、更有效地应对。
- 知识积累:通过分析过程,我们加深了对 SwiftUI 布局系统的理解。这种理解不仅限于
Grid
,还涉及到 SwiftUI 的整体布局机制,包括尺寸协商、布局优先级等概念。
此外,这种分析方法本身就是一种宝贵的技能。它教会我们如何系统地思考、假设、验证,以及如何利用现有手段(如 LayoutMonitor
)来调试复杂的布局问题。
作为开发者,我们应该保持好奇心和探索精神,不满足于简单的”能用就行”,而是追求对技术的深入理解。这样不仅能解决眼前的问题,还能在长远上提升我们的技术水平和问题解决能力。