Skip to content

第92天 项目 18 第一部分

在这100天的最终技术项目中,我们将探讨SwiftUI如何处理布局和几何结构。是的,我知道你可能期望这类内容更早被涵盖,但SwiftUI的一大优势在于它为我们处理了大量工作——这类教程在本系列中出现得如此靠后,恰恰证明了SwiftUI标准布局的出色之处。

xkcd漫画的作者兰德尔·芒罗(Randall Munroe)曾说过:“如果你真的讨厌某个人,就教他们识别糟糕的字距调整(kerning)。”如果你之前不了解,字距调整指的是字母之间的间距,而糟糕的字距调整其实非常常见——一旦你学会识别它,就会发现它无处不在。

今天你将学习“对齐”(alignment),这也是一种你了解之后就很难忽略的概念。当然,一眼就能看出某个元素居中而另一个没有居中,但如果两个元素的前缘对齐位置略有不同呢?在你了解之前,这种差异是看不见的,但一旦开始留意,就再也无法忽视了!

今天你需要学习五个主题,从中你将了解布局规则、对齐方式、自定义参考线等更多内容。

  • 布局与几何:简介
  • SwiftUI中的布局工作原理
  • 对齐与对齐参考线
  • 如何创建自定义对齐参考线
  • SwiftUI视图的绝对定位

布局与几何:简介

作者:Paul Hudson 2021年10月15日

在这个技术项目中,我们将探索SwiftUI如何处理布局。其中一些内容之前已经做过少量解释,有些你可能已经自己摸索出来了,但还有很多内容你可能只是想当然地接受了,所以希望通过详细的探索,能让你真正理解SwiftUI的工作方式。

在此过程中,你还将学习创建更高级的布局对齐方式、使用GeometryReader构建特殊效果等——这些都是非常实用的高级功能,相信你会很乐意在自己的应用中使用它们。

现在,使用App模板创建一个新的iOS项目,将其命名为LayoutAndGeometry。为了跟上“自定义对齐参考线”章节的内容,你需要在资源目录中准备一张图片,图片内容不限,它只是一个占位符而已。

SwiftUI中的布局工作原理

作者:Paul Hudson 2024年4月25日

SwiftUI的所有布局都通过三个简单的步骤完成,理解这些步骤是每次都能做出出色布局的关键。这三个步骤如下:

  1. 父视图为子视图提议一个尺寸。
  2. 子视图根据该信息选择自己的尺寸,而父视图必须尊重这个选择。
  3. 父视图随后在其坐标空间中定位子视图。

在底层,SwiftUI还会执行第四步:尽管它会将位置和尺寸存储为浮点数,但在渲染时,SwiftUI会将所有像素值四舍五入到最接近的整数,以确保图形保持清晰。

这三条规则看似简单,却能让我们创建出极其复杂的布局——每个视图都能自主决定如何以及何时调整尺寸,而无需父视图干预。

为了实际演示这些规则的作用,建议你使用一个带有background()修饰符的简单Text视图,代码如下:

swift
struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .background(.red)
    }
}

你会看到背景色紧紧围绕着文本本身——它只占据刚好能容纳我们提供的内容的空间。

现在思考一个问题:ContentView的尺寸有多大?正如你所见,ContentView的body(即它要渲染的内容)是带有背景色的文本。因此,ContentView的尺寸始终与它的body尺寸完全一致,不多也不少。这被称为“布局中立”(layout neutral):ContentView本身没有固定尺寸,而是会灵活调整以适应所需的尺寸。

在项目3中,我曾向你解释过,当你为视图应用修饰符时,实际上会得到一个名为ModifiedContent的新视图类型,它既存储了原始视图,也存储了对应的修饰符。这意味着当你应用修饰符时,进入视图层级结构的实际是经过修改的视图,而非原始视图。

在我们这个简单的background()示例中,这意味着ContentView内部的顶层视图是背景视图,而文本视图则位于背景视图内部。与ContentView一样,背景视图也是“布局中立”的,因此它会按需传递所有布局信息——最终可能会形成一条布局信息传递链,直到得到明确的尺寸结果。

如果将这个过程代入三步布局系统,大致会产生如下“对话”:

  • SwiftUI:“嘿,ContentView,整个屏幕都可以给你用——你需要多大空间?”(父视图提议尺寸)
  • ContentView:“我无所谓,我是布局中立的。我来问问我的子视图:嘿,背景视图,整个屏幕都可以给你用——你需要多大空间?”(父视图提议尺寸)
  • 背景视图:“我也无所谓,我也是布局中立的。我来问问我的子视图:嘿,文本视图,整个屏幕都可以给你用——你需要多大空间?”(父视图提议尺寸)
  • 文本视图:“嗯,我显示的是默认字体的‘Hello, World’,所以我需要的宽度正好是X像素,高度是Y像素。我用不了整个屏幕,只要这么大就行。”(子视图选择自身尺寸)
  • 背景视图:“明白了。嘿,ContentView:我需要X像素宽、Y像素高的空间。”
  • ContentView:“好的。嘿,SwiftUI:我需要X像素宽、Y像素高的空间。”
  • SwiftUI:“很好。这么一来,还剩下很多空间,所以我会把你(ContentView)以这个尺寸放在屏幕中央。”(父视图在其坐标空间中定位子视图)

因此,当我们编写Text("Hello, World!").background(.red)时,文本视图会成为其背景视图的子视图。 在处理视图及其修饰符时,SwiftUI实际上是从底层往顶层工作的。

现在来看这个布局:

swift
Text("Hello, World!")
    .padding(20)
    .background(.red)

这次的“对话”会更复杂一些:padding()不会再将所有可用空间都提供给子视图,因为它需要从每一侧减去20个点,以确保有足够的空间用于内边距。然后,当文本视图返回所需尺寸后,padding()会按照要求在每一侧增加20个点,以实现内边距效果。

所以,对话大致如下:

  • SwiftUI:ContentView,整个屏幕都可以给你用,你需要多大空间?
  • ContentView:背景视图,整个屏幕都可以给你用,你需要多大空间?
  • 背景视图:padding视图,整个屏幕都可以给你用,你需要多大空间?
  • padding视图:文本视图,整个屏幕每侧减去20个点后的空间都可以给你用,你需要多大空间?
  • 文本视图:我需要X像素宽、Y像素高的空间。
  • padding视图:那我需要X像素宽、Y像素高,再加上每侧20个点的空间。
  • 背景视图:我需要X像素宽、Y像素高,再加上每侧20个点的空间。
  • ContentView:我需要X像素宽、Y像素高,再加上每侧20个点的空间。
  • SwiftUI:好的,我会把你放在中央。

如果你还记得,修饰符的顺序很重要。也就是说,下面这段代码:

swift
Text("Hello, World!")
    .padding()
    .background(.red)

和这段代码:

swift
Text("Hello, World!")
    .background(.red)
    .padding()

会产生两种不同的结果。现在你应该明白原因了:background()是布局中立的,因此它会通过询问子视图所需的空间来确定自己的空间大小。如果background()的子视图是文本视图,那么背景会紧紧围绕文本;但如果子视图是padding(),那么背景会接收到包含内边距的调整后尺寸。

这些布局规则会带来两个有趣的副作用。

首先,如果你的视图层级结构完全是布局中立的,那么它会自动占据所有可用空间。例如,形状和颜色都是布局中立的,因此如果你的视图中只包含一种颜色(没有其他内容),它会自动填满整个屏幕,如下所示:

swift
var body: some View {
    Color.red
}

记住,Color.red本身就是一个视图,但由于它是布局中立的,所以可以被绘制为任意尺寸。当我们在background()中使用它时,简化后的布局对话如下:

  • 背景视图:嘿,文本视图,整个屏幕都可以给你用——你需要多大空间?
  • 文本视图:我需要X像素宽、Y像素高的空间,用不了更多了。
  • 背景视图:好的。嘿,Color.red,X像素宽、Y像素高的空间可以给你用——你需要多大空间?
  • Color.red:我无所谓,我是布局中立的,所以X像素宽、Y像素高的空间很合适。

第二个有趣的副作用我们之前遇到过:如果对一个不可调整大小的图片使用frame(),会得到一个更大的框架,但内部的图片尺寸不会改变。以前这可能让人困惑,但只要将框架视为图片的父视图,一切就说得通了:

  • ContentView向框架提供整个屏幕的空间。
  • 框架反馈说它需要300x300的空间。
  • 框架随后询问内部的图片需要多大空间。
  • 图片(不可调整大小)反馈说它需要一个固定尺寸,例如64x64。
  • 框架随后将图片定位在自身的中央。

当你听苹果官方的SwiftUI工程师谈论修饰符时,会经常听到他们将修饰符称为“视图”——比如“frame视图”“background视图”等等。我认为这是一个很好的思维模型,能帮助你准确理解实际发生的事情:应用修饰符会创建新的视图,而不仅仅是在原地修改现有视图。

对齐与对齐参考线

作者:Paul Hudson 2024年2月21日

SwiftUI提供了多种实用的方式来控制视图的对齐方式,我会带你逐一了解这些方式,让你看到它们的实际效果。

最简单的对齐选项是使用frame()修饰符的alignment参数。记住,文本视图始终使用刚好能显示其文本的宽度和高度,但当我们在它周围添加框架时,框架可以是任意尺寸。由于父视图无法决定子视图的最终尺寸,因此像下面这样的代码会创建一个300x300的框架,内部的小文本视图会居中显示:

swift
Text("Live long and prosper")
    .frame(width: 300, height: 300)

如果你不希望文本居中,可以使用frame()alignment参数。例如,在从左到右排列的环境中,下面的代码会将视图放置在左上角:

swift
    .frame(width: 300, height: 300, alignment: .topLeading)

之后,你可以使用offset(x:y:)在该框架内移动文本。

下一个对齐选项是使用栈(stack)的alignment参数。例如,下面是四个尺寸不同的文本视图,排列在一个HStack(水平栈)中:

swift
HStack {
    Text("Live")
        .font(.caption)
    Text("long")
    Text("and")
        .font(.title)
    Text("prosper")
        .font(.largeTitle)
}

我们没有指定对齐方式,因此默认会居中对齐。这种效果看起来不太好,所以你可能会想将它们都对齐到某一条边,以形成更整齐的线条,如下所示:

swift
HStack(alignment: .bottom) {

但这样效果也不好:因为每个文本视图的尺寸不同,它们的“基线”(baseline)也不同——基线指的是“abcde”这类字母所在的线条,不包括“gjpy”这类向下延伸的字母。因此,小文本的底部会比大文本的底部更低。

幸运的是,SwiftUI有两个特殊的对齐方式,可以将文本对齐到第一个子视图或最后一个子视图的基线上。这会使栈中的所有视图都对齐到同一条统一的基线上,无论它们的字体大小如何:

swift
HStack(alignment: .lastTextBaseline) {

接下来,为了实现更精细的控制,我们可以为每个单独的视图自定义“对齐”的含义。为了更好地理解这一点,我们先从下面的代码开始:

swift
struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("Hello, world!")
            Text("This is a longer line of text")
        }
        .background(.red)
        .frame(width: 400, height: 400)
        .background(.blue)
    }
}

运行这段代码后,你会看到VStack(垂直栈)紧紧围绕着两个文本视图,背景为红色。两个文本视图的长度不同,但由于我们使用了.leading对齐方式,在从左到右排列的环境中,它们都会对齐到自己的左边缘。在VStack外部,有一个更大的框架,背景为蓝色。由于框架比VStack大,VStack会位于框架的中央。

现在,当VStack对齐其内部的每个文本视图时,它会要求文本视图提供自己的前缘(leading edge)。默认情况下,这很明确:根据系统语言,使用视图的左边缘或右边缘。但如果我们想改变这种方式——比如想让某个视图拥有自定义的对齐方式,该怎么做呢?

SwiftUI为此提供了alignmentGuide()修饰符。它接受两个参数:要修改的参考线,以及一个返回新对齐位置的闭包。这个闭包会接收一个ViewDimensions对象,该对象包含视图的宽度和高度,以及读取视图各边缘位置的能力。

默认情况下,视图的.leading对齐参考线就是它自身的前缘——这听起来很直白,但实际上相当于下面的代码:

swift
VStack(alignment: .leading) {
    Text("Hello, world!")
        .alignmentGuide(.leading) { d in d[.leading] }
    Text("This is a longer line of text")
}

我们可以重写这个对齐参考线,让视图使用自身的后缘(trailing edge)作为前缘对齐参考线,代码如下:

swift
VStack(alignment: .leading) {
    Text("Hello, world!")
        .alignmentGuide(.leading) { d in d[.trailing] }
    Text("This is a longer line of text")
}

现在你就明白我为什么要添加颜色了:第一个文本视图会向左移动,使其右边缘正好位于下方视图的左边缘正上方,VStack会扩大以容纳它,而整个VStack仍会位于蓝色框架的中央。

这个结果与使用offset()修饰符不同:如果你偏移一个文本视图,即使最终渲染的视图位置发生了变化,它的原始尺寸实际上并不会改变。如果我们使用偏移而非修改对齐参考线,VStack不会扩大以容纳偏移后的文本视图。

尽管对齐参考线的闭包会接收视图的尺寸,但你也可以选择不使用这些尺寸——你可以返回一个固定数值,或者进行其他计算。例如,下面的代码通过将每个文本视图的位置乘以-10,为10个文本视图创建了一种阶梯式效果:

swift
var body: some View {
    VStack(alignment: .leading) {
        ForEach(0..<10) { position in
            Text("Number \(position)")
                .alignmentGuide(.leading) { _ in Double(position) * -10 }
        }
    }
    .background(.red)
    .frame(width: 400, height: 400)
    .background(.blue)
}

要完全控制对齐参考线,你需要创建自定义对齐参考线。我认为这一点值得单独用一个小节来讲解……

如何创建自定义对齐参考线

作者:Paul Hudson 2024年2月21日

SwiftUI为视图的各个边缘(.leading.trailing.top等)提供了对齐参考线,还提供了.center(中心)和两种基线选项,以帮助实现文本对齐。但是,当你处理的视图分散在不同的视图中时——比如需要让两个位于用户界面完全不同位置的视图对齐,这些默认的参考线就不够用了。

为了解决这个问题,SwiftUI允许我们创建自定义对齐参考线,并在整个用户界面的视图中使用这些参考线。无论这些视图前后有什么其他视图,它们都能保持对齐。

例如,下面的布局在左侧显示我的Twitter账号名和头像,在右侧显示“Full name:”以及用大号字体显示的“Paul Hudson”:

swift
struct ContentView: View {
    var body: some View {
        HStack {
            VStack {
                Text("@twostraws")
                Image(.paulHudson)
                    .resizable()
                    .frame(width: 64, height: 64)
            }

            VStack {
                Text("Full name:")
                Text("PAUL HUDSON")
                    .font(.largeTitle)
            }
        }
    }
}

如果你想让“@twostraws”和“Paul Hudson”垂直对齐,目前会比较困难。水平栈内部包含两个垂直栈,因此没有内置的方法可以实现你想要的对齐效果——像HStack(alignment: .top)这样的方式根本无法满足需求。

要解决这个问题,我们需要定义一个自定义布局参考线。这需要对VerticalAlignment(垂直对齐)或HorizontalAlignment(水平对齐)进行扩展,并创建一个符合AlignmentID协议的自定义类型。

当我说“自定义类型”时,你可能会想到结构体(struct),但实际上,用枚举(enum)来实现会更好,原因我稍后会解释。AlignmentID协议只有一个要求:遵循该协议的类型必须提供一个静态方法defaultValue(in:),该方法接收一个ViewDimensions对象,并返回一个CGFloat值,用于指定视图在没有alignmentGuide()修饰符时的对齐位置。你会收到该视图的现有ViewDimensions对象,因此可以选择其中一个维度作为默认值,或者使用固定数值。

让我们写出代码,以便你更好地理解:

swift
extension VerticalAlignment {
    struct MidAccountAndName: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context[.top]
        }
    }

    static let midAccountAndName = VerticalAlignment(MidAccountAndName.self)
}

可以看到,我默认使用了.top(顶部)视图维度,并且创建了一个名为midAccountAndName的静态常量,以便更方便地使用这个自定义对齐方式。

之前我提到,用枚举比用结构体更好,原因如下:我们刚刚创建了一个名为MidAccountAndName的结构体,这意味着(如果愿意的话)我们可以创建该结构体的实例,尽管这样做没有意义,因为它没有任何功能。如果你将struct MidAccountAndName替换为enum MidAccountAndName,就无法再创建它的实例了——这会更明确地表明,这个类型的存在只是为了承载某些功能。

无论你选择枚举还是结构体,其使用方式都是相同的:将它设置为栈的对齐方式,然后使用alignmentGuide()在你想要对齐的任何视图上激活它。这只是一个“参考线”:它帮助你将视图沿同一条线对齐,但不会规定具体的对齐方式。这意味着你仍然需要为alignmentGuide()提供闭包,以按你的需求将视图沿该参考线定位。

例如,我们可以更新Twitter布局的代码,使用.midAccountAndName作为对齐方式,然后让账号名和全名视图使用各自的中心位置作为参考线。明确地说,这表示“将这两个视图对齐,使它们的中心都位于.midAccountAndName参考线上”。

代码实现如下:

swift
HStack(alignment: .midAccountAndName) {
    VStack {
        Text("@twostraws")
            .alignmentGuide(.midAccountAndName) { d in d[VerticalAlignment.center] }
        Image(.paulHudson)
            .resizable()
            .frame(width: 64, height: 64)
    }

    VStack {
        Text("Full name:")
        Text("PAUL HUDSON")
            .alignmentGuide(.midAccountAndName) { d in d[VerticalAlignment.center] }
            .font(.largeTitle)
    }
}

这样无论前后添加什么内容,这两个视图都会保持垂直对齐。建议你尝试在示例前后添加更多文本视图——SwiftUI会重新定位所有元素,确保我们要对齐的两个视图保持对齐状态。

SwiftUI视图的绝对定位

作者:Paul Hudson 2024年2月21日

SwiftUI提供了两种定位视图的方式:使用position()进行绝对定位,以及使用offset()进行相对定位。它们看起来可能相似,但一旦你理解了SwiftUI如何在框架内放置视图,position()offset()之间的根本区别就会变得清晰。

一个简单的SwiftUI视图如下所示:

swift
struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
    }
}

SwiftUI会将所有可用空间提供给ContentView,而ContentView又会将这些空间传递给文本视图。文本视图会自动只使用显示文本所需的空间,因此它会将这个尺寸反馈给ContentView——而ContentView的尺寸始终与它的body尺寸完全一致(因此会紧紧围绕文本)。最终,SwiftUI会将ContentView定位在可用空间的中央,从用户的角度来看,这就使文本位于了屏幕中央。

如果你想对SwiftUI视图进行绝对定位,可以使用position()修饰符,如下所示:

swift
Text("Hello, world!")
    .position(x: 100, y: 100)

这会将文本视图定位在其父视图内的(100, 100)坐标处。现在,为了真正看清这里发生的事情,建议你添加一个背景色:

swift
Text("Hello, world!")
    .background(.red)
    .position(x: 100, y: 100)

你会看到文本周围有一个紧紧贴合的红色背景。现在尝试将background()修饰符移到position()修饰符之后,代码如下:

swift
Text("Hello, world!")
    .position(x: 100, y: 100)    
    .background(.red)

现在你会看到,文本的位置没有变化,但整个安全区域(safe area)都变成了红色。

要理解这一现象,你需要记住SwiftUI的三步布局流程:

  1. 父视图为子视图提议一个尺寸。
  2. 子视图根据该信息选择自己的尺寸,而父视图必须尊重这个选择。
  3. 父视图随后在其坐标空间中定位子视图。

因此,负责定位子视图的是父视图,而不是子视图。这就产生了一个问题:我们刚刚指定了文本视图的精确位置——SwiftUI如何解决这个矛盾呢?

答案也正是为什么我们的background()颜色会让整个安全区域变红:当我们使用position()时,会得到一个占据所有可用空间的新视图,这样它才能将其子视图(文本)定位在正确的位置。

当我们按照“文本 → position → background”的顺序应用修饰符时,position视图会占据所有可用空间,以确保能将文本定位在正确位置,然后background视图会使用这个尺寸作为自身的尺寸。当我们按照“文本 → background → position”的顺序应用修饰符时,background视图会使用文本的尺寸作为自身的尺寸,然后position视图会占据所有可用空间,并将background视图定位在正确的位置。

之前讨论offset()修饰符时,我说过:“如果你偏移一个文本视图,即使最终渲染的视图位置发生了变化,它的原始尺寸实际上并不会改变。”考虑到这一点,尝试运行下面的代码:

swift
var body: some View {
    Text("Hello, world!")
        .offset(x: 100, y: 100)
        .background(.red)
}

你会看到文本显示在一个位置,而背景显示在另一个位置。我会解释其中的原因,但首先希望你自己思考一下——如果你能理解这一点,就真正掌握了SwiftUI布局系统的工作原理。

当我们使用offset()修饰符时,我们只是改变了视图的渲染位置,而没有改变它的底层几何结构。这意味着当我们在offset()之后应用background()时,background视图会使用文本的原始位置,而不是偏移后的位置。如果你调整修饰符的顺序,让background()offset()之前,效果会更符合你的预期——这再次证明了修饰符顺序的重要性。

本站使用 VitePress 制作