所有 SwiftUI 布局都是通过以下简单的原理进行,理解这些步骤是做好布局的关键。具体原理是:
//根据以上基础知识,可以演绎一下 swiftUI 布局的逻辑
Text("Hello, World!")
.background(.red)
.padding()
// SwiftUI 都是从下往上,一级级询问
// contentView 是【布局中性】的,它会询问其子级(在这里就是 background)需要多少空间
// background() 也是【布局中性】的,它通过询问其子级需要多少空间并使用相同的值来确定它需要多少空间
// background() 的子级是文本视图,文本视图是收缩型视图,所以只要求了一小块空间
// 所以 background 和【其内部包含元素】的大小一致;于是将紧贴文本周围
// 所以 contentView 也和【其内部包含元素】的大小一致;于是将紧贴文本周围
// 如果 ContentView() 子级是 padding() ,则它将收到包括 padding 在内的调整后的值
布局中性的视图,其大小始终和“其内部包含的元素”大小一致。例如 ContentView
没有自己的大小,它只是根据内部内容调整自身大小。
布局中性视图会自动扩张:如果视图层次结构都是布局中性的(也就是说全部层级里都是中立视图,没有 Text 这些内容视图),那么它将自动占用所有可用空间。例如,形状和颜色是布局中性的,因此如果您的视图包含颜色而没有其他内容,它将自动填充屏幕,如下所示:
//Color.red 本身就是一个视图,它也是布局中立的,如果它里面没其他子元素,它就会占满空间
var body: some View {
Color.red
}
//如果 Color 视图是以下这样用,那就不会占满空间,因为它有子元素 Text
Text("Hello, World!")
.background(Color.red)
.padding()
ModifiedContent
的新视图类型,它存储原始视图及其修饰符。如果在不可调整大小的图像上使用 frame()
,会得到一个更大的框架,而内部图像的大小不会改变。以前这可能会令人困惑,但一旦您将【框架】视为图像的父级,它就好理解了:
ContentView
提供整个屏幕的框架<aside> 💡 当听 SwiftUI 工程师谈论修饰符时,您会听到他们将修饰符称为 ”视图”、 “框架视图”、“背景视图” 等。这是一个很好的思维模型,可以帮助准确理解正在发生的事情:应用修改器创建新视图,而不仅仅是就地修改现有视图。
</aside>
布局优先级的逻辑是: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)
}
// 一般使用 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)
SwiftUI 允许我们创建具有精确尺寸的视图,但大部分情况下,我们希望图像能够在一维或两个维度上自动放大,以填充更多空间。例如“让图像填充屏幕宽度的80%”,而不是硬编码宽度300。这种情况下有以下实现方法:
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{
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
,可能是 List
或 ScrollView
等,但它不会将 HStack
或 VStack
视为容器。因此在 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)
}
}
// 获取屏幕宽高
// 获取屏幕宽度
UIScreen.main.bounds.width
// 获取屏幕高度
UIScreen.main.bounds.height
SwiftUI 可以通过将 frame()
修饰符与 fixedSize()
组合来轻松创建两个相同大小的视图,无论想要相同的高度还是相同的宽度。没有必要使用 GeometryReader
或类似的复杂的方法。
fixedSize()
应用于它们所在的容器,这告诉 SwiftUI 这些视图应该只占用它们需要的空间// 示例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 提供了许多有价值的方法来控制视图的对齐方式:
实现视图的对齐,最简单的方式是使用 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)
//例如:一系列水平文字视图的对齐
HStack(alignment: .bottom){
Text("Live")
.font(.caption)
Text("long")
Text("and")
.font(.title)
Text("prosper")
.font(.largeTitle)
}
//或者可以将文本对齐到第一个子项或最后一个子项的基线上
HStack(alignment: .lastTextBaseline) { ... }
//当 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个参数:
alignmentGuide
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)
}
}
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),它有几个要求:
VerticalAlignment
或 HorizontalAlignment
的扩展AlignmentID
协议的自定义类型。当提到“自定义类型”时,可能会想到结构体。但实际上将其实现为枚举是一个好主意。AlignmentID
协议只有一个要求,即必须提供一个静态 defaultValue(in:)
方法,该方法接受 ViewDimensions
对象并返回 CGFloat
修饰符的情况下应如何对齐。您将获得视图的现有 ViewDimensions
对象,因此您可以选择其中一个作为默认值,也可以使用硬编码值。//示例
//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() 的闭包,以根据需要沿着该参考线定位视图。