Layout 与 DataFlow

屏幕上的空间是如何分配给视图的

  1. 容器视图,如 HStack、VStack、ZStack 等,把自己获得的空间的一部分或全部提供给内部子视图
  2. 子视图决定自己想要的尺寸
  3. 容器视图再根据布局规则摆放这些子视图的位置
父视图(Container)


① 提供可用空间


子视图(View)
② 决定自己需要多大


父视图(Container)
③ 把子视图摆放到合适位置

例如

HStack {
    Text("Hello")
    Image(systemName: "star")
}
.frame(width: 300, height: 100)

发生的事情

HStack 获得 300×100 空间


Text 告诉 HStack:
    "我需要 40×20"

Image 告诉 HStack:
    "我需要 20×20"


HStack 决定:
    Text 放左边
    Image 放右边

HStack 与 VStack

前面章节已经用过了

尺寸伸缩性

HStack 和 VStack 会先把父视图提供给它们的空间进行分配,然后再把这些空间分给内部的子 View。

它会优先给 尺寸伸缩性最差(least flexible) 的子视图分配空间。不同 View 的“灵活度”大致如下:

灵活度 视图
不可伸缩(inflexible) ImageText,尺寸由内容决定
中等伸缩(medium flexible) Text(...).minimumScaleFactor(xx)Image(...).resizable()
大部分可伸缩(mostly flexible) Circle,会占用可用空间,但保持 1:1 宽高比
完全可伸缩(fully flexible) Rectangle,会占用所有可用空间

其他修饰器,例如:.frame(...).aspectRatio(...) 也会改变一个View的伸缩特性。

HStack、VStack 不是平均分空间,而是先满足“不灵活”的 View,再把剩余空间分配给更灵活的 View。

先照顾最难压缩的 View、扣除已占空间、再给更灵活的 View 分配空间、同等级灵活度平均分配、最终 Stack 根据子 View 决定自身大小。

Spacer(minLength: CGFloat)

HStack {
    Text("左边")
    Spacer()
    Text("右边")
}

效果, Spacer() 会把两个控件推到两侧。

左边                    右边

Divider()

HStack {
    Text("A")
    Divider()
    Text("B")
}
// A | B
VStack {
    Text("A")
    Divider()
    Text("B")
}
//A
//---
//B
组件 作用
Spacer() 吃掉剩余空间,实现推开、对齐
Divider() 绘制分隔线

.layoutPriority(Double)

Stack(堆叠布局)在决定把空间优先分配给谁时,可以通过:

.layoutPriority(Double)

来覆盖默认行为,layoutPriority 的优先级高于系统默认的“最不灵活(least flexible)优先”规则。

HStack {
    Text("Important")
        .layoutPriority(100)

    Image(systemName: "arrow.up")

    Text("Unimportant")
}
Swift is...
// 而不是
Swift is great!

layoutPriority() 的作用:告诉 SwiftUI:空间紧张时,谁更重要,谁先拿空间。

LazyHStack 与 LazyVStack

这些 Stack(堆栈)的“懒加载(lazy)”版本不会创建当前不可见的 View。

它们不会因为内部包含可伸缩(flexible)的 View 而自动占满所有可用空间。

当你的 Stack 放在 ScrollView 中时,通常会使用它们。

LazyHGrid 与 LazyGrid

根据提供给 LazyGrid 的信息来决定其 View 的大小(例如 columns:rows: 参数)。

另一个方向可以随着更多View的加入而增长或缩小。

如果不需要,它也不会占用提供给它的全部空间

Grid

以类似“电子表格(spreadsheet)”或“表格(table)”的方式为其 View 分配空间。 每一行都包含在另一个容器 View 中,称为 GridRow。

通过修饰符(例如 .grid*() )管理跨列和跨行的对齐方式。

为什么要有 Lazy

普通

VStack {
    ForEach(0..<10000) { i in
        Text("\(i)")
    }
}

创建时会把10000个Text全部生成,而:

LazyVStack {
    ForEach(0..<10000) { i in
        Text("\(i)")
    }
}

只创建屏幕上能看到的部分,滚动时再动态创建新的View。因此:

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemView(item)
        }
    }
}

当 Stack 位于 ScrollView 中时,通常应该优先考虑 LazyStack。

ScrollView 滚动视图

ScrollView 会占用父容器提供给它的全部空间。它内部的子视图会根据滚动方向的垂直轴进行适配。

ViewThatFits 自动适配视图

适用场景:

Form、List、OutlineGroup、DisclosureGroup

这些可以理解成,功能非常强大的 VStack

除了垂直排列之外,还内置了:

自定义 Layout 协议实现

你可以自己编写一个布局容器:

  1. 给子视图提供可用空间
  2. 让子视图决定自己的大小
  3. 将子视图摆放到最终位置

这样的自定义 View 本质上就实现了 SwiftUI 的 Layout 协议。

核心方法

func sizeThatFits(...)
func placeSubviews(...)

SwiftUI 内置的 HStack、VStack、Grid 等布局容器,本质上也是按照这套 Layout 协议工作的。

ZStack

ZStack 会调整自身大小以适应其子视图 (children)。

如果哪怕只有一个子视图具有完全灵活的大小,那么 ZStack 也会变成完全灵活的大小。

但是,如果你只有两个需要堆叠的视图 (Views),你可能会使用以下这些修饰符来代替……

.overlay 修饰符

Circle().overlay(alignment: .center) { Text("Hello") }

Text 将叠加在 Circle 的上方(在 Circle 内部按照指定的对齐方式进行对齐)。

这对视图的大小将取决于 Circle(即它将具有灵活的大小),而不是取决于 Text。

.background 修饰符

Text("hello").background { Rectangle().foregroundColor(.red) }

这对视图的大小将取决于 Text(Rectangle 不参与决定整体大小)。换句话说,Text 完全决定了这个“由两个对象组成的迷你 ZStack”的布局。

参与布局的修饰符

视图修饰符函数(例如 .padding)本身也会返回一个视图(View)。从概念上来说,该视图“包含”了它所修饰的那个视图。

许多修饰符只是将分配给它们的任何空间直接传递给它们所修饰的视图(例如 .font)。

但修饰符本身也有可能直接参与到布局过程中。典型的例子包括 .padding 和 .aspectRatio。

.padding

Text("hello").padding(10) ...

这可能位于 VStack 或 HStack 内部。那么 Stack 是如何为它确定尺寸的呢?

DataFlow

视图的数据流分类 (Data flow categorization for a View)

DataIn 输入数据

.minimumScaleFactor

主要用于处理文本溢出。

当文本内容太长,而在给定的布局空间内放不下时,SwiftUI 默认会用省略号(...)截断文本。而给文本加上 .minimumScaleFactor 后,系统会自动缩小字号以尝试让文本完整显示。

Text("你的文本内容")
    .lineLimit(1) // 通常配合行数限制使用
    .minimumScaleFactor(0.5) // 允许缩小的最小比例

参数含义:接受一个 CGFloat 类型的比例值(范围在 0.0 到 1.0 之间)。

工作原理:如果设为 0.5,意味着如果文本放不下,系统最多可以将字号缩小到原来的 50%。如果缩小到 50% 还放不下,才会显示省略号。

EnvironmentValues

有时,你的视图(View)可能需要了解它当前处于什么样的“环境(environment)”中运行。例如

以上所有内容都被定义为一个名为 EnvironmentValues 的结构体(struct)中的变量(vars)。 可以去查阅该结构体的官方文档,看看有哪些可用的属性。 不过,我们通常不直接访问 EnvironmentValues,而是使用 @Environment ... 属性包装器。

struct MyView: View {
    // MARK: Data In (from my environment)
    @Enviroment(\.colorScheme) var colorScheme
    var body: some View {
        Image(systemName: "moon")
        .foregroudStyle(colorScheme == .dark ? .yellow : .blue)
    }
}

添加自己的 EnvironmentValues Entry

extenstion EnvironmentValues {
    @Entry var words = Words.shared // 必须提供默认值
}

指定某个View的局部EnvironmentValues

MatchMarkers(matches: [.exact, ...])
    .environment(\.colorScheme, .dark)

这段内容展示了如何使用 .environment 视图修饰符来局部改变视图的环境变量。在这个例子中,即使整个 App 当前没有处于深色模式,通过传入 \.colorScheme.dark,也可以强制让 MatchMarkers 视图及其子视图以此模式渲染。也就是说,环境变量在每个视图层级上都是可控的。

由View拥有的数据

最常见的 View 数据所有权形式。通常不用于“Model(模型)”数据(除非你希望数据在 View 消失时一同销毁)。

大多是临时的、用于支持 UI 的信息。例如:选中项 、 排序选项 、 搜索字符串 、 弹窗显示 、 UI 配置。

@State private var data: MyStruct = MyStruct(...)

注意这里的 private。@State var data: MyStruct 是合法的(在这种情况下,View 的创建者会设置初始值)。 但强烈不推荐这样做,因为这会给创建者带来困惑(因为他们传入的值只会被消费一次,之后就会被忽略)。

@State private var data: MyStruct = MyStruct(...)
@State private var dataArray: [Element] = []
@State private var dataDictionary: [Key:Value] = [:]

数据在视图间共享 @Binding

@Binding var dataFromSomeOtherView: Int

struct ViewA: View {
    // myData 的事实源(source of truth)
    @State private var myData: Int = 42 
    
    var body: some View {
        // $myData 意味着“一个指向 myData 的绑定(Binding)”
        ViewB(foo: $myData) 
    }
}

struct ViewB: View {
    @Binding var foo: Int
    // 可以通过使用 foo 来获取(get)和设置(set) myData 的值
    // 如果 foo 还要继续传给其他View 传的时候也要用 $foo, 
    // 也就是允许 a Binding to a Binding
}

如果 ViewB 不修改 foo,完全没有必要用 @Binding

@Binding 实际上创建了两个不同的变量:

_foo 是真正执行与另一个视图变量进行绑定工作的核心。你偶尔可能会在构造函数 init 中访问 _foo

@Binding 变量前面永远不会加上 private 修饰符。这没有任何意义,因为其他视图必须能够将这个 Binding 传递进来。

可以使用 Binding<Type>.constant(value) 来创建指向常量值的绑定(Bindings)。

例如:ViewB(foo: Binding<Int>.constant(5)) 这在 #Preview(代码预览)中非常有用。 当然,类型推断同样适用:ViewB(foo: .constant(5))

@escaping 使用函数传递数据

有时视图(Views)会使用函数来传入和传出数据。

struct MyView: View {
    // 我会调用它来给你一些数据
    let giveSomething: (Int) -> Void 
    
    // 当我想要那个数据时,我会调用它
    let getSomething: () -> Int 
}

请记住,将结构体(structs)或枚举(enums)作为参数传递,意味着它们会被不可变地复制(值传递)。

giveSomething 并不罕见(通常发生在按钮点击或类似的操作中)。getSomething 相对比较少见。但它是将一个视图传递给另一个视图的标准正统方式(因为有 @ViewBuilder 的存在)。

例如,VStack(或 ForEach)的 content 参数基本上就是一个 getSomething。

如果你将一个 giveSomething 作为参数传递给 init(构造函数),它将需要一个 @escaping 注解:

init(giveSomething: @escaping (Int) -> Void) {
    self.giveSomething = giveSomething
}

@escaping 让Swift知道你正持有保存这个函数以便稍后使用。如果没有这个注解,Swift 可能会尝试将你传递的闭包内容进行内联(inline)处理(即直接把闭包的代码行复制粘贴到调用它的地方,而不是真正去引用和调用这个闭包)。

有时你希望 giveSomething 是可选的(即它不是一个必填参数):在这种情况下,我们可以将其定义为 Optional 类型(是的,函数也可以作为 Optional 的关联数据):

// 在这种(可选值)情况下,你不需要使用 @escaping 注解。
var giveSomething: ((Int) -> Void)? = nil
// 然后使用 ? 语法来调用它
giveSomething?(theResult)

@Observable

@Observable 也是一种在视图与视图传递数据的方式