在 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
技巧也能帮助实现一些用传统方法难以达到的布局效果。