第16天 项目 1 第一部分
既然你已经掌握了 Swift 语言的基础知识,现在是时候将所学技能应用到实际代码中,开启我们的第一个项目了。
这个项目是一款账单分摊应用,它能根据人数和你想支付的小费金额,计算出每个人需要分摊的账单金额。这个项目本身并不复杂,但我们会放慢进度,让你清晰地看到这些基础知识是如何协同工作的。
从某些方面来说,像这样回归基础可能会让你感觉有些奇怪 —— 毕竟你已经学习了闭包、可选类型和抛出函数,现在却要 “倒回去” 学习 SwiftUI 的基础知识。但请放心:以全新的心态接触新主题会带来巨大价值。正如埃克哈特大师(Meister Eckhart)所说:“愿你每天清晨都愿意以初学者的身份面对一切”—— 做到这一点,你的学习速度会快得多。
今天是 “项目概述” 日,我们将逐一拆解完成项目所需理解的各个代码模块。明天我们将进入 “项目实现” 日,届时你将把这些新技巧应用到我们的应用开发中。
今天你需要学习七个主题,还会接触到 Form、NavigationStack、@State 等知识点。
- WeSplit:项目介绍
- 理解 SwiftUI 应用的基本结构
- 创建表单(Form)
- 添加导航栏
- 修改程序状态
- 将状态与用户界面控件绑定
- 通过循环创建视图
当你学完这些主题后,一定要在网上分享你的进度 —— 这是保持动力、督促自己的简单有效方法!
WeSplit:介绍
作者:Paul Hudson 2023 年 10 月 7 日
在本项目中,我们将构建一个账单分摊应用,你可以在餐厅用餐后使用它 —— 输入餐费金额、选择想要支付的小费比例以及同行人数,它就会计算出每个人需要支付的金额。
这个项目并非要构建复杂的功能,其核心目的是通过实用的方式教你掌握 SwiftUI 的基础知识,同时为你提供一个可根据需求进一步扩展的真实项目案例。
你将学习 UI 设计的基础、如何让用户输入数值并从选项中进行选择,以及如何跟踪程序状态。由于这是第一个项目,我们会放慢节奏,对所有内容逐一讲解 —— 后续项目的进度会逐渐加快,不过目前我们会循序渐进。
与所有涉及完整应用构建的项目一样,本项目分为三个阶段:
- 实操介绍你将学习的所有技术。
- 分步指导项目构建过程。
- 供你独立完成的挑战任务,助力你进一步拓展项目功能。
这三个阶段都至关重要,请勿急于跳过其中任何一个。
在第一阶段,我会单独讲解每个新组件,让你理解它们各自的工作原理。这部分会包含大量代码,同时也会有相关解释,以便你清晰了解每个组件的独立运作方式。此阶段相当于一个 “概述”:我们将使用哪些工具、这些工具如何工作、以及如何使用它们。
在第二阶段,我们会将这些概念应用到实际项目中。这是你了解知识实际应用场景的环节,同时也能获得更多背景信息 —— 比如我们为什么要使用这些工具,以及它们如何与其他组件配合使用。
在最后一个阶段,我们会总结你所学的内容,然后为你安排一个小测试,确保你真正理解了所涵盖的知识点。此外,你还会面临三个挑战任务:这三个全新的任务需要你独立完成,以此检验你是否能够运用所学技能。我们不会提供这些挑战的解决方案(因此请不要发邮件索要),因为这些任务的目的是考验你,而不是让你跟着解决方案一步步操作。
好了,闲话不多说:现在开始第一个项目吧。我们首先会了解构建账单分摊应用所需的技术,然后将这些技术应用到实际项目中。
那么,现在启动 Xcode,选择 “创建一个新的 Xcode 项目”(Create A New Xcode Project)。你会看到一系列选项,请选择 “iOS” 和 “App”,然后点击 “下一步”(Next)。在接下来的界面中,你需要完成以下操作:
- 在 “产品名称”(Product Name)处输入 “WeSplit”。
- 在 “组织标识符”(Organization Identifier)处,你可以输入任意内容,但如果你有网站,建议按反向格式输入网站域名:例如 “hackingwithswift.com” 应输入为 “com.hackingwithswift”。如果没有域名,自行编造一个即可 ——“me.你的姓氏.你的名字” 这样的格式就完全可行。
- 在 “界面”(Interface)处选择 “SwiftUI”。
- 在 “语言”(Language)处确保选择 “Swift”。
- 在 “存储”(Storage)处选择 “无”(None)。
- 确保底部所有复选框均处于 “未勾选” 状态。
如果你对组织标识符感到好奇,可以查看其下方的文字:“软件包标识符”(Bundle Identifier)。苹果公司需要确保所有应用都能被唯一识别,因此会将组织标识符(即反向格式的网站域名)与项目名称组合起来。例如,苹果公司的组织标识符可能是 “com.apple”,那么苹果的 Keynote 应用的软件包标识符可能就是 “com.apple.keynote”。
准备就绪后,点击 “下一步”(Next),然后选择项目的保存位置并点击 “创建”(Create)。Xcode 会进行一两秒的处理,之后创建项目并打开相关代码,等待你进行编辑。
之后我们会使用这个项目来构建账单分摊应用,但目前我们会将其用作一个 “沙盒”,在此处尝试编写一些代码。
好了,让我们开始吧!
了解 SwiftUI 应用的基本结构
作者:Paul Hudson 2023 年 10 月 7 日
当你创建一个新的 SwiftUI 应用时,会得到若干文件,代码总量大约 20 行左右。
在 Xcode 中,你应该能在左侧名为 “项目导航器”(project navigator)的区域看到以下文件:
WeSplitApp.swift:包含应用启动相关的代码。如果需要在应用启动时创建某些内容并使其在整个应用运行期间保持活跃,就可以将相关代码放在这里。ContentView.swift:包含程序的初始用户界面(UI),本项目中所有的开发工作都将在此文件中进行。Assets.xcassets:这是一个 “资源目录”(asset catalog),用于存放你想在应用中使用的图片。你还可以在此处添加颜色、应用图标、iMessage 贴纸等资源。Preview Content:这是一个分组文件夹,内部包含Preview Assets.xcassets—— 这也是一个资源目录,但专门用于存放你在设计用户界面时所需的示例图片,方便你预览程序运行时界面的大致效果。
提示:根据 Xcode 的配置不同,你在项目导航器中可能会看到文件扩展名,也可能看不到。你可以通过以下方式进行控制:打开 Xcode 的偏好设置(preferences),选择 “通用”(General)标签页,然后调整 “文件扩展名”(File Extensions)选项。
本项目的所有开发工作都将在 ContentView.swift 中进行,Xcode 通常会自动为你打开该文件。文件顶部有一些注释(以两个斜杠 // 开头的内容),这些注释会被 Swift 忽略,你可以通过注释来添加代码功能说明。
注释下方大约有 15 行代码:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}在开始编写我们自己的代码之前,有必要先了解这些代码的作用,因为其中有几个知识点可能是新的。
首先,import SwiftUI 告诉 Swift,我们希望使用 SwiftUI 框架提供的所有功能。苹果为我们提供了许多框架,涵盖机器学习、音频播放、图像处理等多个领域。由于不能默认假设程序需要使用所有框架,因此我们需要明确指定要使用的框架,以便加载相应的功能。
其次,struct ContentView: View 创建了一个名为 ContentView 的新结构体,并声明该结构体遵循 View 协议。View 协议来自 SwiftUI,是任何你想在屏幕上绘制的内容都必须遵循的基础协议 —— 所有文本、按钮、图片等元素都是视图(View),包括你自己组合其他视图形成的布局也不例外。
第三,var body: some View 定义了一个名为 body 的计算属性,其类型比较特殊:some View。这意味着该属性会返回一个遵循 View 协议的对象,也就是我们的界面布局。在底层,根据布局中包含的所有元素,实际返回的类型会非常复杂,但 some View 让我们无需关心具体的复杂类型,只需关注返回的是一个视图即可。
View 协议只有一个必须满足的要求:必须拥有一个名为 body、返回类型为 some View 的计算属性。你可以(并且以后会)在视图结构体中添加更多的属性和方法,但 body 是唯一的必填项。
第四,VStack 及其内部的代码展示了一个 “地球” 图标和下方的文本 “Hello, world!”。这个地球图标来自苹果的 SF Symbols 图标集,iOS 系统中内置了数千个此类图标。文本视图(Text)是用于在屏幕上绘制静态文本的简单组件,会根据需要自动换行显示。
第五,imageScale()、foregroundStyle() 和 padding() 是调用在图片和 VStack 上的方法。在 SwiftUI 中,这类方法被称为 “修饰符”(modifier)—— 它们本质上是普通方法,但有一个特点:始终返回一个新的视图,这个新视图既包含原始视图的数据,又包含你所设置的额外修改效果。在我们的代码中,这意味着地球图标会以更大的尺寸和蓝色显示,而整个 body 属性返回的将是一个带有内边距的文本视图,而非普通的文本视图。
在 ContentView 结构体下方,你会看到 #Preview 代码块,其中包含 ContentView()。这是一段特殊的代码,不会成为最终提交到 App Store 的应用的一部分,专门用于 Xcode 预览你的 UI 设计(与代码并列显示)。
这些预览功能依赖于 Xcode 的 “画布”(canvas)功能,画布通常显示在代码的右侧。你可以根据需要自定义预览代码,这些自定义只会影响画布中界面布局的显示效果,不会改变实际运行的应用。
画布默认会使用某一款特定的苹果设备进行预览,例如 iPhone 15 Pro 或 iPad。若要更改预览设备,可查看 Xcode 窗口顶部中央显示的当前设备,点击该设备名称后选择其他设备即可。这一设置也会影响后续在 iOS 模拟器中运行代码时所使用的设备。
重要提示:如果在 Xcode 窗口中没有看到画布,可前往 “编辑器”(Editor)菜单,选择 “画布”(Canvas)选项。
很多时候,代码中的错误会导致 Xcode 画布无法更新 —— 你可能会看到 “预览已暂停”(Preview paused)之类的提示,此时点击刷新按钮即可修复。由于你以后会经常用到刷新操作,这里推荐一个重要的快捷键:Option+Cmd+P,其功能与点击刷新按钮相同。
创建表单
作者:Paul Hudson 2023 年 10 月 7 日
许多应用都需要用户输入某种信息 —— 可能是让用户设置一些偏好,可能是让用户确认希望车辆接驾的地点,也可能是让用户从菜单中点餐,或是其他类似场景。
SwiftUI 为这类场景提供了一种专门的视图类型,名为 Form(表单)。表单是包含静态控件(如文本和图片)的滚动列表,同时也可包含用户交互控件,如文本框、切换开关、按钮等。
只需将文本视图包裹在 Form 中,就能创建一个基础表单,代码如下:
var body: some View {
Form {
Text("Hello, world!")
}
}如果使用 Xcode 的画布功能,你会看到界面发生显著变化:之前 “Hello World” 是在白色屏幕中央显示,而现在屏幕变为浅灰色,“Hello World” 在左上角以白色字体显示。
你看到的正是数据列表的初始形态,就像在 “设置” 应用中看到的那样。我们的表单中目前只有一行数据(即 “Hello World” 文本),但可以自由添加更多内容,它们会立即显示在表单中:
Form {
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
}实际上,表单中可以包含任意数量的内容。例如,以下代码能正常显示 10 行文本:
Form {
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
Text("Hello, world!")
}如果想像 “设置” 应用那样,将表单拆分为多个视觉区块,可以使用 Section(分区),代码如下:
Form {
Section {
Text("Hello, world!")
}
Section {
Text("Hello, world!")
Text("Hello, world!")
}
}关于何时应将表单拆分为多个分区,并没有严格规定 —— 分区的作用仅仅是将相关项进行视觉上的分组。
添加导航栏
作者:Paul Hudson 2023 年 10 月 7 日
如果我们主动设置,iOS 允许我们将内容放置在屏幕的任意位置,包括顶部系统时钟下方和底部主屏幕指示器上方。但这样的显示效果并不好,因此 SwiftUI 默认会确保组件放置在不会被系统界面或设备圆角遮挡的区域 —— 这个区域被称为 “安全区域”(safe area)。
在 iPhone 15 上,安全区域的范围是从灵动岛正下方一直延伸到主屏幕指示器正上方。通过以下界面代码可以直观看到安全区域的作用:
struct ContentView: View {
var body: some View {
Form {
Section {
Text("Hello, world!")
}
}
}
}尝试在 iOS 模拟器中运行这段代码 —— 点击 Xcode 窗口左上角的 “运行” 按钮,或按下 Cmd+R 快捷键。
你会发现表单从灵动岛下方开始显示,因此默认情况下表单中的行是完全可见的。但表单支持滚动,在模拟器中滑动屏幕时,你会发现可以将行向上移动,使其进入时钟下方的区域,导致两者都难以辨认。
解决此问题的常用方法是在屏幕顶部添加导航栏。导航栏可以包含标题和按钮,在 SwiftUI 中,它还能让我们在用户执行操作时显示新视图。
后续项目中我们会学习按钮和新视图的相关内容,不过这里至少要向你展示如何添加导航栏并设置标题 —— 这能让表单在滚动时的显示效果更好。
你已经了解到,我们可以通过用 Section 包裹文本视图,将文本视图放入分区中;类似地,也可以将分区放入 Form 中。添加导航栏的方式与此类似,只不过对应的视图类型名为 NavigationStack(导航栈)。
var body: some View {
NavigationStack {
Form {
Section {
Text("Hello, world!")
}
}
}
}此时界面看起来与之前完全一致,但通常我们希望在导航栏中添加标题。只需为导航栈内部的内容附加一个 “修饰符”(modifier),就能实现这一需求。
让我们尝试添加一个修饰符,为表单设置导航标题:
NavigationStack {
Form {
Section {
Text("Hello, world!")
}
}
.navigationTitle("SwiftUI")
}当我们为表单附加 .navigationTitle() 修饰符时,Swift 实际上会创建一个新表单 —— 这个新表单包含原表单的所有内容,同时还带有导航标题。
为导航栏添加标题后,你会发现标题使用大号字体显示。若要使用小号字体,可以再添加一个修饰符:
.navigationBarTitleDisplayMode(.inline)你可以在 “设置” 应用中观察 Apple 对大号和小号标题的使用方式:首屏的 “设置” 文字使用大号字体,后续屏幕的标题则使用小号字体。
修改程序状态
作者:Paul Hudson 2023 年 10 月 7 日
在 SwiftUI 开发者中有这样一种说法:“视图是其状态的函数”。虽然这句话只有短短几个字,但对于初学者来说,可能难以理解其含义。
假设你正在玩一款格斗游戏,你可能损失了几条生命值、获得了一些分数、收集了一些宝物,或许还拾取了一些强力武器。在编程领域,这些内容被称为 “状态”(state)—— 即描述游戏当前状况的一组动态设置。
当我们说 “SwiftUI 的视图是其状态的函数” 时,意思是用户界面的外观(用户能看到的内容以及可交互的元素)由程序的状态决定。例如,只有当用户在文本框中输入姓名后,“继续” 按钮才会变为可点击状态。
让我们通过一个按钮来实践这一概念。在 SwiftUI 中,创建按钮时需要指定标题字符串,以及按钮被点击时执行的动作闭包:
struct ContentView: View {
var tapCount = 0
var body: some View {
Button("点击次数: \(tapCount)") {
tapCount += 1
}
}
}这段代码看起来似乎合理:创建一个按钮,显示 “点击次数:” 加上按钮被点击的次数,每当用户点击按钮时,将 tapCount 的值加 1。
但这段代码无法编译 —— 它不是有效的 Swift 代码。原因是 ContentView 是一个结构体(struct),而结构体可能会被创建为常量。回想一下结构体的相关知识就会知道,这意味着结构体是 “不可变的”(immutable)—— 我们无法随意修改其属性值。
如果要在结构体方法中修改属性,需要为方法添加 mutating 关键字,例如 mutating func doSomeWork()。但 Swift 不允许我们创建可变的计算属性,这意味着我们无法编写 mutating var body: some View—— 这种写法是不被允许的。
这似乎让我们陷入了僵局:我们希望在程序运行时修改值,但由于视图是结构体,Swift 不允许我们这样做。
幸运的是,Swift 为我们提供了一种特殊的解决方案 ——“属性包装器”(property wrapper):一种可以放在属性前的特殊属性,能为属性赋予 “超能力”。对于存储按钮点击次数这类简单程序状态的场景,我们可以使用 SwiftUI 提供的 @State 属性包装器,代码如下:
struct ContentView: View {
@State var tapCount = 0
var body: some View {
Button("点击次数: \(tapCount)") {
self.tapCount += 1
}
}
}这一微小的修改足以让程序正常工作,现在你可以编译并运行它了。
@State 帮助我们绕过了结构体的限制:我们知道结构体的属性无法修改(因为结构体是不可变的),但 @State 允许将属性值存储在 SwiftUI 管理的一个独立、可修改的位置。
是的,这看起来有点像 “作弊”,你可能会疑惑为什么不使用类(class)—— 类的属性是可以随意修改的。但请相信,使用 @State 是值得的:随着学习的深入,你会发现 SwiftUI 会频繁销毁并重新创建结构体视图,因此保持结构体的精简对性能至关重要。
提示:在 SwiftUI 中有多种存储程序状态的方式,后续你会逐一学习。@State 专门用于存储单个视图内部的简单属性。因此,Apple 建议我们为这类属性添加 private 访问控制,例如:@State private var tapCount = 0。
将状态绑定到用户界面控件
作者:Paul Hudson 2023 年 10 月 7 日
SwiftUI 的 @State 属性包装器允许我们自由修改视图结构体,这意味着当程序状态发生变化时,我们可以更新视图属性以保持同步。
但对于用户界面控件,情况会稍微复杂一些。例如,如果你想创建一个用户可输入的编辑框(文本框),可能会编写如下 SwiftUI 视图代码:
struct ContentView: View {
var body: some View {
Form {
TextField("请输入你的姓名")
Text("Hello, world!")
}
}
}这段代码尝试创建一个包含文本框和文本视图的表单。但它无法编译,因为 SwiftUI 需要知道文本框中的文本应存储在何处。
请记住,视图是其状态的函数 —— 文本框要显示内容,必须对应程序中存储的值。SwiftUI 期望我们的结构体中存在一个字符串属性,该属性既用于在文本框中显示内容,同时也用于存储用户在文本框中输入的内容。
因此,我们可以将代码修改为:
struct ContentView: View {
var name = ""
var body: some View {
Form {
TextField("请输入你的姓名", text: name)
Text("Hello, world!")
}
}
}这段代码添加了 name 属性,并将其用于创建文本框。但它仍然无法正常工作 —— 因为 Swift 需要能够根据用户在文本框中的输入来更新 name 属性,所以你可能会尝试使用 @State,如下所示:
@State private var name = ""但这仍然不够,代码依旧无法编译。
问题在于,Swift 区分了两种情况:“在此处显示该属性的值” 和 “在此处显示该属性的值,并且将所有修改写回该属性”。
对于我们的文本框,Swift 需要确保文本框中的内容与 name 属性的值始终一致 —— 这样才能实现 “视图是状态的函数” 这一设计理念,即用户看到的所有内容,都是程序中结构体和属性的可视化呈现。
这就是所谓的 “双向绑定”(two-way binding):我们将文本框与属性绑定,不仅让文本框显示属性的值,还让文本框的任何修改都能更新该属性。
在 Swift 中,我们使用一个特殊符号来标记双向绑定 —— 在属性名前添加美元符号($)。这会告诉 Swift:既要读取该属性的值,也要在发生修改时将新值写回属性。
因此,正确的结构体代码如下:
struct ContentView: View {
@State private var name = ""
var body: some View {
Form {
TextField("请输入你的姓名", text: $name)
Text("Hello, world!")
}
}
}现在尝试运行这段代码 —— 你应该可以点击文本框并输入姓名,一切符合预期。
在继续之前,让我们修改文本视图,使其在文本框正下方显示用户输入的姓名:
Text("你的姓名是 \(name)")注意到这里使用的是 name 而非 $name 吗?这是因为我们不需要在此处使用双向绑定 —— 我们确实需要读取属性的值,但不需要写回(因为文本视图的内容不会主动变化)。
因此,当你看到属性名前带有美元符号时,请记住:这表示创建了双向绑定 —— 既会读取属性的值,也会将修改写回属性。
循环创建视图
作者:Paul Hudson 2023 年 10 月 7 日
在开发中,经常需要通过循环创建多个 SwiftUI 视图。例如,我们可能希望遍历一个姓名数组,为每个姓名创建一个文本视图;或者遍历一个菜单选项数组,为每个选项创建一个图片视图。
SwiftUI 为此提供了一种专门的视图类型,名为 ForEach。它可以遍历数组或范围,创建所需数量的视图。
ForEach 会为遍历的每个元素执行一次闭包,并将当前元素传入闭包。例如,如果我们遍历 0 到 100 的范围,闭包会依次接收到 0、1、2 等参数。
以下代码创建了一个包含 100 行的表单:
Form {
ForEach(0..<100) { number in
Text("第 \(number) 行")
}
}由于 ForEach 会传入闭包,我们可以使用参数名的简写语法,代码如下:
Form {
ForEach(0 ..< 100) {
Text("第 \($0) 行")
}
}ForEach 在配合 SwiftUI 的 Picker(选择器)视图使用时尤为实用 ——Picker 视图可用于向用户展示多个选项,供其选择。
为了演示这一点,我们将创建一个视图,该视图具备以下功能:
- 包含一个存储学生姓名的数组;
- 包含一个
@State属性,用于存储当前选中的学生; - 创建一个
Picker视图,提示用户选择 “最喜欢的学生”,并通过双向绑定关联到@State属性; - 使用
ForEach遍历所有学生姓名,为每个姓名创建一个文本视图。
对应的代码如下:
struct ContentView: View {
let students = ["哈利", "赫敏", "罗恩"]
@State private var selectedStudent = "哈利"
var body: some View {
NavigationStack {
Form {
Picker("选择学生", selection: $selectedStudent) {
ForEach(students, id: \.self) {
Text($0)
}
}
}
}
}
}这段代码并不复杂,但有几个要点需要说明:
students数组不需要标记@State,因为它是一个常量(不会发生变化);selectedStudent属性初始值为 “哈利”,但允许修改,因此标记为@State;Picker视图有一个标签 “选择学生”,用于告知用户该控件的用途,同时也为屏幕阅读器提供了描述性内容(方便朗读);Picker视图与selectedStudent建立双向绑定,这意味着初始时会显示 “哈利” 作为选中项,当用户选择其他选项时,该属性的值会随之更新;- 在
ForEach内部,我们遍历所有学生姓名; - 为每个学生姓名创建一个文本视图,用于显示该姓名。
其中唯一可能令人困惑的部分是:ForEach(students, id: \.self)。这段代码的作用是遍历 students 数组,为每个元素创建一个文本视图,但 id: \.self 这部分至关重要。SwiftUI 需要能够唯一标识屏幕上的每个视图,才能检测到视图的变化。
例如,如果我们调整数组顺序,让 “罗恩” 排在第一位,SwiftUI 需要同步移动对应的文本视图。因此,我们需要告诉 SwiftUI:如何唯一标识字符串数组中的每个元素 —— 每个字符串的 “唯一标识” 是什么?
如果我们有一个结构体数组,可能会说 “我的结构体有一个始终唯一的 title 字符串”,或者 “我的结构体有一个始终唯一的 id 整数”。但在这个例子中,我们只有一个简单的字符串数组,而每个字符串的唯一标识就是其本身 —— 数组中的每个字符串都是不同的,因此这些字符串天然具备唯一性。
因此,当我们使用 ForEach 创建多个视图,且 SwiftUI 询问 “字符串数组中每个元素的唯一标识是什么” 时,我们的答案是 \.self,它表示 “字符串本身就是唯一标识”。当然,这意味着如果你的 students 数组中存在重复字符串,可能会出现问题,但在当前示例中是完全没问题的。
后续我们会学习 ForEach 的其他使用方式,但目前这些内容对于本项目来说已经足够。
以上是本项目概述的最后一部分,很快我们就要开始编写实际代码了。如果你想保存之前编写的示例代码,可以将项目目录复制到其他位置。
准备就绪后,请将 ContentView.swift 文件恢复为以下代码,以便我们从干净的状态开始:
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
}
}
#Preview {
ContentView()
}