视图布局原理

1. 基本原理

所有 SwiftUI 布局都是通过以下简单的原理进行,理解这些步骤是做好布局的关键。具体原理是:

  1. 父视图 为 子视图 建议了一个尺寸
  2. 子视图会根据自身需要选择尺寸,父视图必须尊重子视图的选择,就算子视图要求比父级大
  3. 父视图 会将 子视图 放置在其坐标空间中(大多数父级都会将子视图放在自己中心,GeometryReader除外)
  4. SwiftUI 虽然在背后会将视图的位置和大小存储为浮点数;但渲染时会将所有像素四舍五入到最接近的值,以便图形保持清晰
  5. 对视图来说,其内部坐标系都是 左上角为[ 0 , 0]
//根据以上基础知识,可以演绎一下 swiftUI 布局的逻辑
Text("Hello, World!")
    .background(.red)
    .padding()

// SwiftUI 都是从下往上,一级级询问
// contentView 是【布局中性】的,它会询问其子级(在这里就是 background)需要多少空间
// background() 也是【布局中性】的,它通过询问其子级需要多少空间并使用相同的值来确定它需要多少空间
// background() 的子级是文本视图,文本视图是收缩型视图,所以只要求了一小块空间
// 所以 background 和【其内部包含元素】的大小一致;于是将紧贴文本周围
// 所以 contentView 也和【其内部包含元素】的大小一致;于是将紧贴文本周围

// 如果 ContentView() 子级是 padding() ,则它将收到包括 padding 在内的调整后的值

2. 中性视图

3. 视图、框架视图、背景视图

如果在不可调整大小的图像上使用 frame() ,会得到一个更大的框架,而内部图像的大小不会改变。以前这可能会令人困惑,但一旦您将【框架】视为图像的父级,它就好理解了:

<aside> 💡 当听 SwiftUI 工程师谈论修饰符时,您会听到他们将修饰符称为 ”视图”、 “框架视图”、“背景视图” 等。这是一个很好的思维模型,可以帮助准确理解正在发生的事情:应用修改器创建新视图,而不仅仅是就地修改现有视图。

</aside>

4. 视图布局优先级

布局优先级的逻辑是:SwiftUI 将计算低优先级视图所需的最小空间量,然后将剩余空间提供给高优先级,以便它可以占用尽可能多的空间。

HStack {
	VStack(alignment: .leading) {
}
.layoutPriority(100)

// 比如下面这个设置了优先级的Text,它就不会先截断,要截也是先截后面那个
HStack {
		Text("SwiftUI") 
			.font(.largeTitle) 
			.layoutPriority(1)
		Image("SwiftUI") .resizable()
		Text("Brings Balance")
			.font(.largeTitle).lineLimit(1)
}

视图的尺寸

1. 设置绝对尺寸

// 一般使用 frame 修饰符来设置视图的尺寸
Text("Welcome")
    .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 200)

// 使用 .infinity 设置撑满最大尺寸
// 如果1个区块 maxWidth 設定為 .infinity 時,視圖將會調整自己來填滿最大寬度
// 如果2个区块 maxWidth 設定為 .infinity 時,它们會平均分配來填滿區塊
Text("Please log in")
    .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
// 用两个 frame 修改器保持居中的办法:例如最大宽度是720,再大时可以在屏幕居中
ZStack {}
	.frame(maxWidth: 720)
	.frame(maxWidth: .infinity)

2. 设置相对尺寸

SwiftUI 允许我们创建具有精确尺寸的视图,但大部分情况下,我们希望图像能够在一维或两个维度上自动放大,以填充更多空间。例如“让图像填充屏幕宽度的80%”,而不是硬编码宽度300。这种情况下有以下实现方法:

containerRelativeFrame

containerRelativeFrame() 是一种简单但功能强大的方法,可以使视图具有相对于其容器的大小,容器可能是它们的整个窗口、它们所在的滚动视图,甚至只是布局中的一列。使用该方法需要提供3个核心值:想设置哪个轴,想要将空间分为多少部分,以及为每个视图分配多少部分

// 例如,这告诉 ScrollView 内的视图它们应该是容器(HStack)宽度的 2/5:
// count 参数指的是滚动视图的水平空间应该被分割成多少部分, span 参数指的是应该为每个文本视图分配多少部分
// 这种不均匀的跨度意味着用户将看到 2.5 个 Text,从而知道可以滚动
ScrollView(.horizontal, showsIndicators: false) {
    HStack {
        ForEach(0..<10) { i in
            Text("Item \\(i)")
                .foregroundStyle(.white)
                .containerRelativeFrame(.horizontal, count: 5, span: 2, spacing: 10)
                .background(.blue)
        }
    }
}

// 关于使用容器相对框架,还有两件事需要了解:
// 如果需要,您可以使用 [.horizontal, .vertical] 提供多个轴
// 默认对齐方式为 .center ,但您可以根据需要指定自定义 alignment 参数
// 还可以使用闭包去设置
Image(.example)
    .resizable()
    // 这种情况下必须用 scaledToFit,因为没有精确的宽高尺寸,所以无法 Fill
    .scaledToFit()
    // 我们想要给这个图像一个相对于其父图像的水平尺寸的框架。我们没有指定垂直尺寸;
    // 然后 SwiftUI 运行一个闭包,我们在其中得到一个 size 和一个轴axis。对于我们来说,轴将为 .horizontal ,因为这是我们正在使用的轴,但当您创建相对水平和垂直尺寸时,这一点更为重要。 size 值将是我们容器的大小,对于该图像来说是全屏。
    .containerRelativeFrame(.horizontal) { 
				size, axis in
        // 最后需要返回该轴所需的大小,因此返回容器宽度的 80%。
        size * 0.8
    }

GeometryReader

更多详情可查看: GeometryReader

GeometryReader{
		proxy in
    Image(.example)
        .resizable()
        .scaledToFit()
        .frame(width: proxy.size.width * 0.8, height: 300)

				//您甚至可以从图像中删除 height ,如下所示:
				//因为我们已经为 SwiftUI 提供了足够的信息,使其可以自动计算出高度:它知道原始宽度,它知道我们的目标宽度,并且它知道我们的内容模式是 Fit,因此它可以自动计算目标高度
				.frame(width: proxy.size.width * 0.8)
}
//现在有个问题是:内部的图像现在变成与 GeometryReader 的左上角对齐了,以前是居中的。
//这个问题很容易解决。如果想要将视图置于 GeometryReader 内居中,而不是与左上角对齐,请添加第2个框架,使其填充容器的整个空间,如下所示:
GeometryReader { proxy in
    Image(.example)
        .resizable()
        .scaledToFit()
        .frame(width: proxy.size.width * 0.8)
        .frame(width: proxy.size.width, height: proxy.size.height)
}

两种方法的区别

<aside> 💡 主要区别是 containerRelativeFrame 对“container”的构成有非常精确的定义:它可能是整个屏幕,可能是 NavigationStack ,可能是 ListScrollView 等,但它不会将 HStackVStack 视为容器。因此在 stack 中使用视图时,会导致问题,因为无法使用 containerRelativeFrame 轻松细分它们。

</aside>

//例如,下面的代码将两个视图放置在 HStack 中,其中一个被赋予固定宽度,另一个使用容器相对框架:
HStack {
    Text("IMPORTANT")
        .frame(width: 200)
        .background(.blue)

    Image(.example)
        .resizable()
        .scaledToFit()
        .containerRelativeFrame(.horizontal) { size, axis in
            size * 0.8
        }
}
//这根本不会很好地布局,因为 containerRelativeFrame 是读取整个屏幕宽度的大小,而不是 HStack 的;
//这意味着尽管屏幕上有 200 个点是文本视图,但图像将占屏幕宽度的 80%。

//而改用 GeometryReader 就很好
HStack {
    Text("IMPORTANT")
        .frame(width: 200)
        .background(.blue)

		GeometryReader { proxy in
		    Image(.example)
		        .resizable()
		        .scaledToFit()
		        .frame(width: proxy.size.width * 0.8)
		}
}

3. 获取设备尺寸

// 获取屏幕宽高

// 获取屏幕宽度
UIScreen.main.bounds.width

// 获取屏幕高度
UIScreen.main.bounds.height

5. fixedSize 创建相同大小视图

SwiftUI 可以通过将 frame() 修饰符与 fixedSize() 组合来轻松创建两个相同大小的视图,无论想要相同的高度还是相同的宽度。没有必要使用 GeometryReader 或类似的复杂的方法。

// 示例1:如何使两个文本视图具有相同的高度,即使它们的文本长度非常不同
HStack {
    Text("This is a short string.")
        .padding()
        .frame(maxHeight: .infinity)
        .background(.red)

    Text("This is a very long string with lots and lots of text that will definitely run across multiple lines because it's just so long.")
        .padding()
        .frame(maxHeight: .infinity)
        .background(.green)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxHeight: 200)
// 示例2:如何使两个按钮视图具有相同的宽度
VStack {
    Button("Log in") { }
        .foregroundStyle(.white)
        .padding()
        .frame(maxWidth: .infinity)
        .background(.red)
        .clipShape(Capsule())

    Button("Reset Password") { }
        .foregroundStyle(.white)
        .padding()
        .frame(maxWidth: .infinity)
        .background(.red)
        .clipShape(Capsule())
}
.fixedSize(horizontal: true, vertical: false)

视图的对齐

SwiftUI 提供了许多有价值的方法来控制视图的对齐方式:

1. frame 的 alignment 参数

实现视图的对齐,最简单的方式是使用 frame 修饰符的 alignment 参数

//请记住,文本视图始终使用显示其文本所需的精确宽度和高度,但是当在它周围放置一个可以是任意大小的框架时
//由于父级对子级的最终大小没有发言权,因此这样的代码将创建一个 300x300 的框架,并在其中【居中】放置一个文本视图
Text("Live long and prosper")
    .frame(width: 300, height: 300)

//如果希望文本视图放置在左上角,修改 alignment 参数为 topLeading
.frame(width: 300, height: 300, alignment: .topLeading)

//然后可以使用 offset(x:y:) 在该 frame 框架内微调 文本的坐标
.offset(x: 100, y: 100)

2. 堆栈的 alignment 参数

//例如:一系列水平文字视图的对齐 
HStack(alignment: .bottom){
		Text("Live")
				.font(.caption)
		Text("long")
		Text("and")
				.font(.title)
		Text("prosper")
				.font(.largeTitle)
}

//或者可以将文本对齐到第一个子项或最后一个子项的基线上
HStack(alignment: .lastTextBaseline) { ... }

3. alignmentGuide 参考线修饰符

//当 VStack 需要对齐每个文本视图时,它会要求它们提供 leading edge(视图的左边或者右边),代码类似:
var body: some View {
		VStack(alignment: .leading) {
				Text("AAAAA")
				Text("BBBBB")
		}
		.background(.red)
		.frame(width: 400, height: 400)
		.background(.blue)
}

如果想让视图具有自定义对齐方式怎么办?SwiftUI 提供了 alignmentGuide() 修饰符来实现这个目的,它需要2个参数:

  1. 想要更改的视图基准参考线 alignmentGuide
  2. 返回浮点数的闭包;闭包被赋予一个 ViewDimensions 对象,其中包含其视图的宽度和高度,以及读取其各种边缘的能力

水平方向:

//例子一:
VStack(alignment: .leading) {
    Text("AAAAA")
        .alignmentGuide(.leading) { d in d[.trailing] }
    Text("BBBBBBB")
        .alignmentGuide(.leading) { _ in -40 }
}

//以上代码的意思是:
//1. 首先 VStack 的参数声明了,正常情况下所有子元素的头部会进行对齐,基线就在这
//2. 文本 A 意思是:以文本 A 的开头为基准和 VStack 的开头对齐,但是偏移 d[.trailing],也就是往左偏移自身的长度那么长
//3. 文本 B 意思是:以文本 B 的开头为基准和 VStack 的开头对齐,但是偏移 -40,也就是往右偏移 40 那么长
//参数 d 即 ViewDimensions 对象,可以把它想象成一个方框,d[.trailing] 就是方框的末尾
 //例子二:通过将 10 个文本视图的位置乘以 -10 来创建分层效果
 VStack(alignment: .leading) {
        ForEach(0..<10) { position in
            Text("Number \\(position)")
                .alignmentGuide(.leading) { _ in Double(position) * -10 }
        }
}

//以上代码的意思是:
//1. 首先 VStack 的参数声明了,正常情况下所有子元素的头部会进行对齐,基线就在这
//2. 后续每个 Text 的头部要和 VStack 的头部对齐,但是会偏移,position越大,偏移越多(这里没用到 ViewDimensions 对象,所以下划线代替)

<aside> 💡 修改对齐方式后,父视图 VStack 将被撑开以包含子元素,整个东西仍然会在 VStack 蓝色框架内居中。此结果与使用 offset 修饰符不同:如果使用 offset 偏移文本,其原始尺寸实际上不会改变,即使结果视图呈现在不同的位置。如果我们 offset 第一个文本视图而不是更改其对齐参考线,则 VStack 将不会扩展以包含它。

</aside>

垂直方向:

        VStack {
            Text("Today's Weather")
                .font(.title)
                .border(.gray)
            
            HStack {
                Text("🌧")
                    .alignmentGuide(VerticalAlignment.center) { _ in -10 }
                    .border(.gray)
                Text("Rain & Thunderstorms")
                    .border(.gray)
                Text("⛈")
                    .alignmentGuide(VerticalAlignment.center) { _ in 80 }
                    .border(.gray)
            }
        }

4. 自定义对齐参考线(跨视图

SwiftUI 提供了视图各个边缘的对齐参考线( .leading.trailing.top.center )以及 两个基线选项可帮助文本对齐。然而当处理分散在不同视图中的视图时,例如你希望在分散在完全不同的部分中的两个视图,保持相同的对齐方式,这些方法就没有效果。

//例如:以下代码希望“@twostraws”和“Paul Hudson”垂直对齐在一起,那么您会遇到困难。
//水平堆栈内部包含两个垂直堆栈,因此没有内置方法可以实现您想要的对齐方式,像 HStack(alignment: .top) 这样也不行
HStack {
		VStack {
				Text("@twostraws")
				Image(systemName: "pencil.circle.fill")
						.resizable()
						.frame(width: 64, height: 64)
		}
		VStack {
				Text("Full name:")
				Text("PAUL HUDSON")
						.font(.largeTitle)
		}
}

为了解决这个问题,需要定义一个自定义布局参考线(custom layout guide),它有几个要求:

//示例

//1. 扩展 VerticalAlignment
extension VerticalAlignment {

		//2. 遵循 AlignmentID 协议
    struct MidAccountAndName: AlignmentID {
    
		    //3. 实作静态的 defaultValue(in:) 方法
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
		        //这里用了 .top 作为默认视图基线
            context[.top]
        }
    }
    //创建了一个名为 midAccountAndName 的静态常量,以使自定义对齐方式更易于使用
    static let midAccountAndName = VerticalAlignment(MidAccountAndName.self)
}

//前面提到使用枚举比结构更好,原因如下:我们刚刚创建了一个名为 MidAccountAndName 的新结构,这意味着我们可以(如果我们愿意)创建该结构的实例,即使这样做没有任何意义,因为它没有任何功能。如果你用 enum MidAccountAndName 替换 struct MidAccountAndName ,那么你就不能再创建它的实例了。这更清楚地说明:这个东西的存在只是为了容纳一些功能。

//无论选择枚举还是结构,用法都保持不变:将其设置为堆栈的对齐方式,然后使用 alignmentGuide() 在您想要对齐在一起的任何视图上激活它。这只是一个参考线:它可以帮助您沿一条线对齐视图,但没有说明它们应该如何对齐。这意味着您仍然需要提供 alignmentGuide() 的闭包,以根据需要沿着该参考线定位视图。