在 SwiftUI 中,许多布局容器的构造函数都包含一个默认值为 nil 的 spacing 参数,该参数负责控制临近视图之间的间隙。本文将从这一默认参数出发,深入探讨 SwiftUI 中的 Spacing 概念,并分享一些相关的技巧及注意事项。
为什么我的子视图的间距不一致?
随着开发者对 SwiftUI 的熟练度提高,他们会逐渐掌握一些特定的“经验法则”。例如,在 VStack 中,若不明确指定 spacing 参数而采用其默认值 nil,间距通常约为 8。
struct RowSpacingDemo: View {
@State var fixSpacing = false
var body: some View {
VStack {
Toggle(isOn: $fixSpacing) { Text("Spacing: \(fixSpacing ? "8" : "Nil")") }
.padding()
VStack(spacing: fixSpacing ? 8 : nil) {
rectangle
rectangle
rectangle
}
}
}
var rectangle: some View {
Rectangle()
.foregroundStyle(.red)
.frame(width: 150, height: 30)
}
}然而,若稍作修改,将中间的 rectangle 替换为一个 Text 组件,间隔就会有所变化。
VStack(spacing: fixSpacing ? 8 : nil) {
rectangle
Text("Fat")
rectangle
}从视频中可以看出,当 spacing 设置为 nil 时,Text 与其上下视图之间的距离不再是 8,且与两者的间距也不相等。
那么,spacing 设置为 nil 时代表了什么呢?
在苹果的官方文档中,对布局容器中 spacing 属性是这样介绍的:
The distance between adjacent subviews, or nil if you want the stack to choose a default distance for each pair of subviews.
相邻子视图之间的距离,如果你希望容器为每对子视图选择一个默认距离,则此值为 nil。
根据上文中的展示,显然“默认距离”并非一个固定的数值。接下来,我们将深入探索这个默认距离是如何被确定的。
Spacing:SwiftUI 视图中的隐藏属性
在许多游戏中,角色都具备玩家可见的属性,如力量、敏捷和速度。然而,经验丰富的玩家都知道,角色背后通常还隐藏着其他属性,这些不为人知的属性对角色的发展至关重要。
类似地,在 SwiftUI 中,视图不仅拥有可见的属性如尺寸、位置等,还包含一些不易觉察的隐藏属性,其中 Spacing 就是一个。直到 iOS 16 之前,我们很难获取到系统为视图设定的 Spacing 信息。但随着在 WWDC 2022 中引入的 Layout 协议,我们现在已经可以深入探索这个属性了。
Layout 协议中的 spacing 方法允许返回自定义布局容器的首选间隔值(ViewSpacing)。例如,以下示例中,我们将第一个子视图的顶部间距作为自定义容器的顶部间距,并将最后一个子视图的底部间距作为容器的底部间距:
func spacing(subviews: Subviews, cache _: inout ()) -> ViewSpacing {
var spacing = ViewSpacing()
if let firstSubview = subviews.first, let lastSubview = subviews.last {
spacing.formUnion(firstSubview.spacing, edges: [.top])
spacing.formUnion(lastSubview.spacing, edges: [.bottom])
}
return spacing
}虽然 ViewSpacing 类型是公开的,但实际上开发者无法完全自定义间距值(除 zero 外),只能基于子视图提供的间距进行处理。
即便如此, spacing 方法仍为我们提供了探查 SwiftUI 为不同组件设置的默认间距的能力。
我们创建了一个名为 SpacingPrint 的自定义布局容器,它只接受一个子视图,在将其默认间距作为容器的首选间距返回的同时会打印该子视图的默认间距,如此可帮助我们观察不同子视图的默认间距:
struct SpacingPrint: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
guard subviews.count == 1, let subview = subviews.first else { fatalError() }
return subview.sizeThatFits(proposal)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) {
guard subviews.count == 1, let subview = subviews.first else { fatalError() }
subview.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: proposal)
}
func spacing(subviews: Subviews, cache _: inout ()) -> ViewSpacing {
guard subviews.count == 1, let subview = subviews.first else { fatalError() }
print(subview.spacing)
return subview.spacing
}
}使用以下代码查看 SpacingPrint 容器中 Rectangle 的默认间距:
struct SpacingPrintDemo:View {
var body: some View {
VStack {
SpacingPrint {
Rectangle()
}
}
}
}什么?未能看到任何 Spacing 信息?那就对了。在 VStack 中,只有当子视图的数量超过一个时,VStack 才会需要获取并应用子视图之间的默认间隔信息。现在,让我们向 VStack 中添加一个新的视图:
struct SpacingPrintDemo:View {
var body: some View {
VStack {
SpacingPrint {
Rectangle()
}
Text("hello")
}
}
}运行后会获得如下的输出:
进一步分析输出,我们可以得到以下关键信息:
ViewSpacing包含一个spacing属性,该属性对应一个未公开的Spacing类型。Spacing类型包括一个minima属性,这是一个字典,其键是SwiftUI.Spacing.Key,值是SwiftUI.Spacing.Value。这些键值对描述了在各个方向或特定情境下的默认间隔信息。- 在四个基本方向上(
top、bottom、left、right),Rectangle的默认间隔均为0(即distance(0.0))。
现在,我们用 SpacingPrint 来查看一下 Text 的默认间隔:
SpacingPrint {
Text("Fatbobman's Blog")
}显然,Text 的默认间距与 Rectangle 有很大的不同。
除了在四个基本方向上的间隔设置,SwiftUI 还为 Text 组件添加了许多与文字排版相关的间隔信息。进一步的测试表明,同一段代码在不同的硬件和平台上运行时,其默认间隔值也可能会有所不同。
这揭示了 SwiftUI 在为各种视图和组件添加 Spacing 属性时会考虑多种因素。这也解释了我们文章开头所提到的疑问:spacing 参数的默认值 nil 的真正含义。
- 当
spacing的值设置为nil时,布局容器会根据相邻视图的默认间隔自动计算它们之间的间隔。这意味着,使用默认值时,相邻子视图之间的间隔是动态变化的。 - 相反,当为
spacing指定具体数值时,布局容器会忽略子视图的默认间隔设置,严格按照指定值调整相邻子视图之间的间隔。
在 SwiftUI 中,不仅是 VStack,还有许多其他布局容器如 HStack、LazyVStack、LazyHStack、LazyVGrid、LazyHGrid、和 Grid,它们的构造方法中都包含 spacing 参数,并且都遵循相同的逻辑处理。
了解
Layout协议的更多具体用法,请参阅 SwiftUI 布局 —— 对齐 和 SwiftUI 布局 —— 尺寸。
必需为 Spacing 参数设置具体数值吗?
在使用默认值 nil 时,子视图间的间隔可能会变化,这引发了一个问题:我们是否应该在所有情况下为 spacing 明确设置数值?
是否设置 spacng 应该视情况而定。
Apple 在 SwiftUI 中设计了一个复杂的默认间隔计算系统,旨在根据不同的硬件、平台和字体等条件,提供符合人体工学的间隔逻辑。因此,除非默认间隔明显不符合开发需求,通常建议在默认间隔符合设计要求的情况下,继续使用 nil 值。
然而,在某些特定情况下,明确设置 spacing 的具体值确实是必要的,以解决特定的布局问题。例如,在我们关于 ScrollView 新功能的 文章 中,我们讨论了 safeAreaPadding 视图修饰器,并指出它在特定情境下与 safeAreaInset 的行为有所不同。如果不将 safeAreaInset 的 spacing 设为 0,在滚动容器底部的内容和安全区之间会因为默认值 nil 而出现不必要的空隙。
为了消除这种空隙,可以将 spacing 显式设置为 0:
ScrollView {
ForEach(0 ..< 20) { i in
CellView(width: nil)
.idView(i)
}
}
.safeAreaInset(edge: .bottom, spacing: 0){ // spacing: 0
Text("Bottom View")
.font(.title3)
.foregroundColor(.indigo)
.frame(maxWidth: .infinity, maxHeight: 40)
.background(.green.opacity(0.6))
}
此外,动态调整 safeAreaInset 的 spacing 也可以解决在 SwiftUI 中,List 中的 TextField 被键盘部分遮挡的问题。完整的解决方案可以在 这里 找到。
struct KeyboardAvoidingDemo:View {
var body: some View {
List(0..<20){
TextField("\($0)",text:.constant(""))
}
.keyboardAvoiding() // lift TextField by adding spacing
}
}是否可以将 Spacing 设置为负数?
由于 spacing 的属性类型为 CGFloat,这确实意味着我们可以将其设置为负数。实际上,在某些开发场景中,将 spacing 设置为负数是一个非常实用的技巧。对于支持 spacing 属性的布局容器,一旦开发者为其提供了具体的 spacing 值,容器就会根据这个值来精确计算尺寸并对子视图进行排列,而不关心这个值是正还是负。
在介绍 zIndex 的 文章 中,我们展示了一个示例,其中通过调整 spacing 动态地改变了 VStack 中子视图的呈现方式:
struct SpacingNegativeDemo: View {
@State var cells: [Cell] = []
@State var spacing: CGFloat = -95
@State var toggle = true
var body: some View {
VStack {
Button("New Cell") {
newCell()
}
.buttonStyle(.bordered)
Text("Spacing: \(spacing)")
Slider(value: $spacing, in: -150 ... 20)
.padding()
Toggle("New view appears on top", isOn: $toggle)
.padding()
.onChange(of: toggle) {
withAnimation {
cells.removeAll()
spacing = -95
}
}
VStack(spacing: spacing) {
Spacer()
ForEach(cells) { cell in
cell
.onTapGesture { delCell(id: cell.id) }
.zIndex(zIndex(cell.timeStamp))
}
}
}
.padding()
}
func zIndex(_ timeStamp: Date) -> Double {
if toggle {
return timeStamp.timeIntervalSince1970
} else {
return Date.distantFuture.timeIntervalSince1970 - timeStamp.timeIntervalSince1970
}
}
func newCell() {
let cell = Cell(
color: ([Color.orange, .green, .yellow, .blue, .cyan, .indigo, .gray, .pink].randomElement() ?? .red).opacity(Double.random(in: 0.9 ... 0.95)),
text: String(Int.random(in: 0 ... 1000)),
timeStamp: Date()
)
withAnimation {
cells.append(cell)
}
}
func delCell(id: UUID) {
guard let index = cells.firstIndex(where: { $0.id == id }) else { return }
withAnimation {
let _ = cells.remove(at: index)
}
}
}
struct Cell: View, Identifiable {
let id = UUID()
let color: Color
let text: String
let timeStamp: Date
var body: some View {
RoundedRectangle(cornerRadius: 15)
.fill(color)
.frame(width: 300, height: 100)
.overlay(Text(text))
.compositingGroup()
.shadow(radius: 3)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
Spacing 参数并非总用于添加间隔
虽然在大多数布局容器的构造方法中,spacing 参数主要用于设定子视图之间的明确间隔,但也有例外。
在 精通 SwiftUI 的 containerRelativeFrame 修饰器 一文中,我们讨论了这个修饰器提供的三种构造方法。这些方法中的一个版本包含了 spacing 属性,但这里的 spacing 并不像在 VStack 或 HStack 中那样直接用于添加间隔。
在 containerRelativeFrame 的上下文中,spacing 参数用途不同:它不直接增加间隔,而是作为变换规则中需要考虑的一个因素。这意味着子视图间的实际间隔仍然由它们所在的容器的 spacing 属性决定。
总结
在本文中,我们通过探讨子视图间不一致的间距问题,深入分析了 spacing 参数的默认值 nil 所代表的含义。此外,我们还讨论了与 SwiftUI 视图的隐藏属性 Spacing 相关的多个方面。了解 Spacing 的构成和原理对开发者在处理复杂布局时极为重要,同时,掌握一些 spacing 技巧也能帮助实现一些用传统方法难以达到的布局效果。