使用 SwiftUI 构建 iOS 应用

本指南的源代码可以在 GitHub 上 找到

在本教程中,你将使用 Swift 和 SwiftUI 构建一个小应用程序,向用户推荐有趣的新活动。在此过程中,你将了解 SwiftUI 应用程序的几个基本组件,包括文本、图像、按钮、形状、堆栈和程序状态。

要开始使用,你需要从 Mac App Store 下载 Xcode。它是免费的,并附带 Swift 和你遵循本教程所需的所有其他工具。

继续启动 Xcode,一旦安装完成,然后选择“Create a new Xcode Project”(创建新的 Xcode 项目)。选择顶部的“iOS”选项卡,然后选择“App”模板并按“Next”(下一步)。

提示: 虽然我们的目标是 iOS 16,但我们的代码在 macOS Ventura 及更高版本上也能很好地工作。

在创建新项目时,Xcode 会要求你提供一些信息

New Xcode Project

当你按下“Next”(下一步)时,Xcode 会询问你想将项目保存在哪里。你可以随意选择适合你的位置,但你可能会发现你的桌面是最容易的。完成后,Xcode 将为你创建新项目,然后打开 ContentView.swift 进行编辑。这是我们将编写所有代码的地方,你将在那里看到一些默认的 SwiftUI 代码。

Initial SwiftUI project

Xcode 为我们创建的示例代码创建了一个名为 ContentView 的新视图。视图是 SwiftUI 如何在屏幕上表示我们应用程序的用户界面的方式,我们可以在其中添加自定义布局和逻辑。

在 Xcode 的右侧,你将看到正在运行的代码的实时预览 - 如果你更改左侧的代码,它将立即出现在预览中。如果你看不到预览,请按照这些说明启用它。

例如,尝试用以下代码替换默认的 body 代码

var body: some View {
    Text("Hello, SwiftUI!")
}

Hello SwiftUI

你应该立即看到预览更新,这使得你在工作时能够进行非常快速的原型设计。这是一个名为 body 的计算属性,SwiftUI 会在它想要显示我们的用户界面时调用它。

构建静态 UI

在这个应用程序中,我们将向用户展示他们可以尝试保持健康的新活动,例如篮球、高尔夫和远足。为了使其更具吸引力,我们将使用每个活动的名称和代表该活动的图标来显示每个活动,然后在后面添加一点颜色。

我们用户界面的主要部分将是一个圆圈,显示当前推荐的活动。我们可以通过编写 Circle 来绘制圆圈,因此请将 Text("Hello, SwiftUI!") 视图替换为以下代码

Circle()

SwiftUI Circle

在你的预览中,你将看到一个大的黑色圆圈填充了可用的屏幕宽度。这是一个开始,但它不太正确——我们希望在其中添加一些颜色,理想情况下在两侧添加一些空间,使其看起来不那么紧凑。

这两者都可以通过在 Circle 视图上调用方法来完成。我们在 SwiftUI 中将这些称为视图修饰符,因为它们修改了圆圈的外观或工作方式,在这种情况下,我们需要使用 fill() 修饰符来为圆圈着色,然后使用 padding() 修饰符在其周围添加一些空间,如下所示

Circle()
    .fill(.blue)
    .padding()

SwiftUI Circle with Color and Padding

.blue 颜色是几个内置选项之一,例如 .red.white.green。这些都具有外观感知能力,这意味着它们的外观会根据设备处于暗模式还是亮模式而略有不同。

在蓝色圆圈之上,我们将放置一个图标,显示我们推荐的活动。iOS 附带了数千个免费图标,称为 SF Symbols,并且有一个免费应用程序,你可以下载它来显示所有选项。这些图标中的每一个都有多种权重,可以平滑地放大或缩小,并且许多图标也可以着色。

但是,在这里,我们想要一些简洁明了的东西:我们只想在圆圈上放置一个图标。这意味着使用另一个名为 overlay() 的修饰符,它将一个视图放置在另一个视图之上。将你的代码修改为如下所示

Circle()
    .fill(.blue)
    .padding()
    .overlay(
        Image(systemName: "figure.archery")
    )

SwiftUI Circle with Icon

你应该看到一个小的黑色射箭图标在我们的大蓝色圆圈之上——这个想法是正确的,但它看起来不太好。

我们真正想要的是射箭图标更大,并且在该背景上更可见。为此,我们需要另外两个修饰符:font() 来控制图标的大小,以及 foregroundColor() 来更改其颜色。是的,我们使用字体修饰符来控制图标的大小——像这样的 SF Symbols 会自动与我们的其余文本一起缩放,这使得它们非常灵活。

将你的 Image 代码调整为如下所示

Image(systemName: "figure.archery")
    .font(.system(size: 144))
    .foregroundColor(.white)

SwiftUI Circle with Icon Sized

提示: font() 修饰符要求使用 144 磅的系统字体,这在所有设备上都非常大。

现在应该看起来好多了。

接下来,让我们在图像下方添加一些文本,以便用户清楚地了解建议是什么。你已经遇到了 Text 视图和 font() 修饰符,因此你可以在 Circle 代码下方添加此代码

Text("Archery!")
    .font(.title)

与其使用固定字体大小,不如使用 SwiftUI 的内置动态类型大小之一,称为 .title。这意味着字体将根据用户的设置而增大或缩小,这通常是一个好主意。

如果一切按计划进行,你的代码应如下所示

var body: some View {
    Circle()
        .fill(.blue)
        .padding()
        .overlay(
            Image(systemName: "figure.archery")
                .font(.system(size: 144))
                .foregroundColor(.white)
        )

    Text("Archery!")
        .font(.title)
}

Circle With Title Text

但是,你在 Xcode 预览中看到的内容可能与你期望的不符:你将看到与以前相同的图标,但没有文本。这是怎么回事?

这里的问题是我们已经告诉 SwiftUI 我们的用户界面将包含两个视图——圆圈和一些文本——但我们没有告诉它如何排列它们。我们希望它们并排吗?一个在另一个之上?还是以某种其他类型的布局?

我们可以选择,但我认为在这里垂直布局看起来会更好。在 SwiftUI 中,我们使用一种名为 VStack 的新视图类型来实现这一点,它放置在我们当前代码周围,如下所示

VStack {
    Circle()
        .fill(.blue)
        .padding()
        .overlay(
            Image(systemName: "figure.archery")
                .font(.system(size: 144))
                .foregroundColor(.white)
        )

    Text("Archery!")
        .font(.title)
}

现在你应该看到你之前期望的布局:我们的射箭图标在文本 “Archery!” 之上。

Circle With Title Text in a VStack

好多了!

为了完成我们对该用户界面的首次尝试,我们可以在顶部添加一个标题。我们已经有一个 VStack,它允许我们将视图一个接一个地定位,但我不想将标题也放在那里,因为稍后我们将为屏幕的该部分添加一些动画。

幸运的是,SwiftUI 允许我们自由嵌套堆栈,这意味着我们可以将一个 VStack 放置在另一个 VStack 中,以获得我们想要的确切行为。因此,将你的代码更改为如下所示

VStack {
    Text("Why not try…")
        .font(.largeTitle.bold())

    VStack {
        Circle()
            .fill(.blue)
            .padding()
            .overlay(
                Image(systemName: "figure.archery")
                    .font(.system(size: 144))
                    .foregroundColor(.white)
            )

        Text("Archery!")
            .font(.title)
    }
}

这使新文本具有较大的标题字体,并且使其加粗,使其作为我们屏幕的真实标题更加突出。

Why Not Try Title Added

现在我们有两个 VStack 视图:一个内部视图,用于容纳圆圈和 “Archery!” 文本,一个外部视图,用于在内部 VStack 周围添加标题。当我们稍后添加动画时,这将非常有帮助!

使其生动起来

尽管射箭很有趣,但这个应用程序真正需要向用户建议一个随机活动,而不是总是显示相同的内容。这意味着向我们的视图添加两个新属性:一个用于存储可能的活动数组,另一个用于显示当前正在推荐的活动。

SF Symbols 有许多有趣的活动可供选择,因此我挑选了一些在这里效果很好的活动。我们的 ContentView 结构已经有一个包含我们的 SwiftUI 代码的 body 属性,但我们想在该属性之外添加新属性。因此,将你的代码更改为如下所示

struct ContentView: View {
    var activities = ["Archery", "Baseball", "Basketball", "Bowling", "Boxing", "Cricket", "Curling", "Fencing", "Golf", "Hiking", "Lacrosse", "Rugby", "Squash"]

    var selected = "Archery"

    var body: some View {
        // ...
    }
}

重要提示: 请注意 activitiesselected 属性是如何位于结构内部的——这意味着它们属于 ContentView,而不是仅仅是我们程序中的自由浮动变量。

这创建了一个各种活动名称的数组,并将射箭选为默认活动。现在我们可以使用字符串插值在我们的 UI 中使用选定的活动——我们可以将 selected 变量直接放在字符串中。

对于活动名称,这很简单

Text("\(selected)!")
    .font(.title)

对于图像,这稍微复杂一些,因为我们需要在它前面加上 figure.,然后将活动名称小写——我们想要 figure.archery 而不是 figure.Archery,否则将无法加载 SF Symbol。

因此,请将你的 Image 代码更改为如下所示

Image(systemName: "figure.\(selected.lowercased())")

这些更改意味着我们的 UI 将显示 selected 属性设置的任何内容,因此如果你在该属性中放置一个新字符串,你可以看到它全部更改

var selected = "Baseball"

Showing Baseball

当然,我们希望它动态更改,而不是每次都必须编辑代码,因此我们将在内部 VStack 下方添加一个按钮,每次按下该按钮时都会更改选定的活动。但这仍然在外部 VStack 内部,这意味着它将排列在标题和活动图标下方。

现在添加此代码

Button("Try again") {
    // change activity
}
.buttonStyle(.borderedProminent)

Try Again Button

因此,你的结构应如下所示

VStack {
    // "Why not try…" text

    // Inner VStack with icon and activity name

    // New button code
}

新的按钮代码执行三项操作

  1. 我们通过传入要显示为按钮标签的标题来创建 Button
  2. // change activity 注释是在按下按钮时将运行的代码。
  3. buttonStyle() 修饰符告诉 SwiftUI 我们希望此按钮突出显示,因此你将看到它以带有白色文本的蓝色矩形形式出现。

仅将注释作为按钮的操作并不是很有趣——实际上我们希望使其将 selected 设置为 activities 数组中的随机元素。我们可以通过调用在其上命名的有用的 randomElement() 方法来从数组中选择一个随机元素,因此请用以下代码替换注释

selected = activities.randomElement()

该代码看起来正确,但实际上会导致编译器错误。我们告诉 Swift 从数组中选择一个随机元素并将其放入 selected 属性中,但是 Swift 无法确定该数组中是否有任何内容——它可能为空,在这种情况下,没有随机元素可以返回。

Random Element Error

Swift 将这些称为可选randomElement() 不会返回常规字符串,它将返回一个可选字符串。这意味着字符串可能不存在,因此将其分配给 selected 属性是不安全的。

即使我们知道数组永远不会为空——它总是包含活动——我们也可以为 Swift 提供一个合理的默认值,以防万一数组将来碰巧为空,如下所示

selected = activities.randomElement() ?? "Archery"

这部分修复了我们的代码,但 Xcode 仍然会显示错误。现在的问题是 SwiftUI 不喜欢我们在没有警告的情况下直接在我们的视图结构中更改程序的 状态——它希望我们提前标记所有可变状态,以便它知道要监视更改。

Non-@State mutating

这是通过在任何将更改的视图属性之前编写 @State 来完成的,如下所示

@State var selected = "Baseball"

这称为属性包装器,这意味着它用一些额外的逻辑包装了我们的 selected 属性。@State 属性包装器允许我们自由更改视图状态,但它也会自动监视其属性的更改,以便它可以确保用户界面与最新值保持同步。

这修复了我们代码中的两个错误,因此你现在可以按 Cmd+R 来构建并在 iOS 模拟器中运行你的应用程序。默认情况下,它会建议棒球,但每次你按下 “Try again”(再试一次)时,你都会看到它发生变化。

Running The App in the Simulator

添加一些润色

在我们完成这个项目之前,让我们再添加一些调整来使其更好。

首先,一个简单的操作:Apple 建议本地视图状态始终用 private 访问控制标记。在较大的项目中,这意味着你不会意外地编写从另一个视图读取一个视图的本地状态的代码,这有助于使你的代码更易于理解。

这意味着像这样修改 selected 属性

@State private var selected = "Baseball"

其次,与其总是显示蓝色背景,不如每次选择一个随机颜色。这需要两个步骤,首先是一个新的属性,包含我们想要从中选择的所有颜色——将此属性放在 activities 属性旁边

var colors: [Color] = [.blue, .cyan, .gray, .green, .indigo, .mint, .orange, .pink, .purple, .red]

现在我们可以更改圆圈的 fill() 修饰符以在数组上使用 randomElement(),或者如果数组最终为空,则使用 .blue

Circle()
    .fill(colors.randomElement() ?? .blue)

第三,我们可以通过在它们之间添加一个新的 SwiftUI 视图(称为 Spacer)来分隔活动 VStack 和 “Try again”(再试一次)按钮。这是一个自动扩展的灵活空间,这意味着它会将我们的活动图标推到屏幕顶部,并将按钮推到底部。

将其插入两者之间,如下所示

VStack {
    // current Circle/Text code
}

Spacer()

Button("Try again") {
    // ...
}

如果你添加多个 spacers,它们将平均分配它们之间的空间。如果你尝试在 “Why not try…”(为什么不尝试…)文本之前放置第二个 spacer,你就会明白我的意思——SwiftUI 将在文本上方和活动名称下方创建相等的空间。

View With Spacers

第四,如果活动之间的更改更平滑,那将是很好的,我们可以通过动画化更改来实现。在 SwiftUI 中,这是通过将我们想要动画化的更改包装在对 withAnimation() 函数的调用中来完成的,如下所示

Button("Try again") {
    withAnimation {
        selected = activities.randomElement() ?? "Archery"
    }
}
.buttonStyle(.borderedProminent)

这将导致我们的按钮按下在活动之间以柔和的淡入淡出效果移动。如果需要,你可以通过将你想要的动画传递给 withAnimation() 调用来自定义该动画,如下所示

withAnimation(.easeInOut(duration: 1)) {
    // ...
}

这是一个改进,但我们可以做得更好!

淡入淡出发生是因为 SwiftUI 看到背景颜色、图标和文本正在更改,因此它删除了旧视图并将其替换为新视图。早些时候,我让你创建一个内部 VStack 来容纳这三个视图,现在你可以明白为什么了:我们将告诉 SwiftUI 这些视图可以被识别为一个组,并且该组的标识符可以随时间变化。

为了实现这一点,我们需要首先在我们的视图中定义更多的程序状态。这将是我们内部 VStack 的标识符,并且由于它会随着我们程序的运行而更改,我们将使用 @State。将此属性添加到 selected 旁边

@State private var id = 1

提示: 这是更多的本地视图状态,因此最好用 private 标记它。

接下来,我们可以告诉 SwiftUI 每次按下按钮时都更改该标识符,如下所示

Button("Try again") {
    withAnimation(.easeInOut(duration: 1)) {
        selected = activities.randomElement() ?? "Archery"
        id += 1
    }
}
.buttonStyle(.borderedProminent)

最后,我们可以使用 SwiftUI 的 id() 修饰符将该标识符附加到整个内部 VStack,这意味着当标识符更改时,SwiftUI 应该将整个 VStack 视为新的。这将使其动画化旧的 VStack 被删除和新的 VStack 被添加,而不仅仅是其中的单个视图。更妙的是,我们可以使用 transition() 修饰符来控制添加和删除过渡的发生方式,该修饰符具有我们可以使用的各种内置过渡。

因此,将这两个修饰符添加到内部 VStack,告诉 SwiftUI 使用我们的 id 属性来标识整个组,并使用幻灯片动画化其添加和删除过渡

.transition(.slide)
.id(id)

按 Cmd+R 再次运行你的应用程序,你应该看到按下 “Try Again”(再试一次)现在可以平滑地将旧活动动画移出屏幕,并将其替换为新活动。如果你反复按下 “Try Again”(再试一次),它甚至会重叠动画!

接下来去哪里?

在本教程中,我们介绍了许多 SwiftUI 基础知识,包括文本、图像、按钮、堆栈、动画,甚至使用 @State 来标记随时间变化的值。SwiftUI 功能强大得多,如果需要,可用于构建复杂的跨平台应用程序。

如果你想继续学习 SwiftUI,有很多免费资源可用。例如,Apple 发布了各种各样的教程,涵盖了基本主题、绘图和动画、应用程序设计等等。我们还将在 上发布指向其他一些流行教程的链接——我们是一个庞大而热情的社区,我们很高兴欢迎你加入!

本指南的源代码可以在 GitHub 上 找到