第96天 项目 19 第一部分
到目前为止,我们所有的项目在iPad上都能“正常运行”,但我们从未真正花时间停下来深入关注过iPad适配。不过,在我们的新项目中,情况将有所改变——我们要开发一款应用,充分利用iPad提供的额外屏幕空间,甚至在Max尺寸iPhone的横屏模式下,也能利用其额外空间。
尽管苹果在2019年将iOS拆分为iPadOS,但从软件层面来看,iPad和iPhone几乎完全相同。这意味着我们可以编写同时在两个平台上运行的代码,只需做少量修改,就能充分发挥每个平台的优势。
2010年,史蒂夫·乔布斯发布首款iPad时曾说:“因为我们已经售出了超过7500万台iPhone,所以已经有7500万用户知道如何使用iPad。”这也意味着用户同样能从两个平台的相似性中受益——由于他们已经习惯在iPhone上使用我们的应用,所以在iPad上使用时能立刻上手。
虽然自定义用户界面可能看起来更美观、体验更出色,但千万不要低估这种“固有使用认知”的力量!
今天你需要学习五个主题,内容包括分栏视图控制器、将警告框与可选类型绑定、使用组实现灵活布局等。
- SnowSeeker:项目介绍
- 在SwiftUI中实现两个并排视图
- 结合可选类型使用alert()和sheet()
- 将组用作透明布局容器
- 为SwiftUI视图添加搜索功能
- 通过SwiftUI的环境共享@Observable对象
SnowSeeker:项目介绍
作者:Paul Hudson 2021年10月15日
在这个项目中,我们将开发一款名为SnowSeeker的应用,用户可以通过它浏览全球的滑雪场,帮助自己找到适合下次度假的滑雪场。
这将是我们首款专门针对iPad优化的应用,会通过并排显示两个视图来提升iPad端体验。同时,你还将深入学习如何解决布局问题、掌握显示弹出层和警告框的新方法等内容。
和往常一样,在开始主要项目前,我们需要先学习一些相关技术。请创建一个新的iOS项目,选择“App”模板,并将项目命名为SnowSeeker。
现在开始吧!
在SwiftUI中实现两个并排视图
作者:Paul Hudson 2024年3月17日
你已经熟悉NavigationStack的基本用法,用它可以创建如下所示的视图:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Hello, world!")
.navigationTitle("Primary")
}
}
}这种方式在iPhone上表现很好,但在iPad上就不够理想了——iPad的屏幕大得多,只用一个大视图来实现导航不仅浪费空间,而且当新视图全屏滑入时,体验也会很生硬。
在这种情况下,更好的选择是使用另一种导航视图:NavigationSplitView。它允许我们指定两列或三列数据并排显示,iPadOS会根据具体配置,在合适的时机显示或隐藏这些列。
尝试运行以下简单代码:
NavigationSplitView {
Text("Primary")
} detail: {
Text("Content")
}启动应用后,你看到的界面会因设备和屏幕方向而异:
- 在iPhone上,你会看到“Primary”视图。
- 在iPad横屏模式下,“Primary”视图会以侧边栏的形式显示在设备的前缘,“Content”视图则填充剩余的屏幕空间。
- 在iPad竖屏模式下,只有“Content”视图会填充整个屏幕。
iPadOS在这里有两个非常智能的设计:
第一,无论设备处于什么方向,你都会看到一个用于显示或隐藏主视图(Primary)的按钮,用户可以根据自己的需求选择布局方式。
第二,当你开启多任务处理(例如,在旁边打开Safari窗口)时,应用的视图会自动适配——系统会识别到可用空间减少,从而隐藏主视图(Primary)。
SwiftUI会自动关联主视图和详情视图,这意味着如果主视图中有一个NavigationLink,点击后它会自动在详情视图中加载对应的内容:
NavigationSplitView {
NavigationLink("Primary") {
Text("New view")
}
} detail: {
Text("Content")
}你可以通过多种方式自定义分栏视图的行为。
例如,你可以告诉iOS,在空间部分受限时,优先保留主视图,代码如下:
NavigationSplitView(columnVisibility: .constant(.all)) {
NavigationLink("Primary") {
Text("New view")
}
} detail: {
Text("Content")
.navigationTitle("Content View")
}
.navigationSplitViewStyle(.balanced)这段代码要求以“平衡”样式显示所有列,这样一来,iPad在竖屏模式下也会显示主视图(Primary)。
提示:columnVisibility实际上是一个绑定属性,因此你可以将其值存储在某个状态中,并动态更新。
其次,你可以告诉系统默认优先显示详情视图(detail),这在iPhone上非常有用——因为iPhone默认会选中主视图(Primary):
NavigationSplitView(preferredCompactColumn: .constant(.detail)) {同样,你也可以将其与绑定属性结合使用,以便在应用运行过程中根据需要修改。
最后,虽然你可以使用.toolbar(.hidden, for: .navigationBar)来隐藏详情视图中的工具栏,但要注意——这样做也会隐藏用于切换侧边栏的按钮!
提示:你甚至可以为NavigationSplitView添加第三个视图,用来创建侧边栏。这种设计常见于“备忘录”等应用中——你可以从备忘录列表向上导航,浏览备忘录文件夹。也就是说,第一个视图中的导航链接控制第二个视图,第二个视图中的导航链接控制第三个视图。对于需要额外层级组织的场景,这种设计非常实用。
结合可选类型使用alert()和sheet()
作者:Paul Hudson 2024年3月17日
SwiftUI提供了两种创建警告框(alert)和弹出层(sheet)的方式,到目前为止,我们主要使用的是其中一种:通过绑定一个布尔值,当布尔值变为true时显示警告框或弹出层。
第二种方式允许我们将可选类型与警告框或弹出层绑定。关键在于,使用可选的Identifiable对象作为显示弹出层的条件,闭包会将用于判断条件的非可选值传递给你,这样你就可以安全地使用它。
为了演示这一点,我们可以创建一个简单的User结构体,并让它遵循Identifiable协议:
struct User: Identifiable {
var id = "Taylor Swift"
}然后,在ContentView中创建一个属性,用于跟踪选中的用户,默认值设为nil:
@State private var selectedUser: User? = nil接下来,修改ContentView的body——点击按钮时给selectedUser赋值,当selectedUser有值时显示弹出层:
Button("Tap Me") {
selectedUser = User()
}
.sheet(item: $selectedUser) { user in
Text(user.id)
}运行这段简单的代码,每当你点击“Tap Me”按钮,就会弹出一个显示“Taylor Swift”的弹出层。一旦弹出层被关闭,SwiftUI会自动将selectedUser重置为nil。
警告框(alert)也有类似的功能,但需要同时传递布尔值和可选的Identifiable值。这样一来,既可以在需要时显示警告框,又能像使用弹出层那样享受可选类型解包的便捷性。
首先,添加一个用于监听的布尔属性:
@State private var isShowingUser = false然后,在按钮的点击事件中切换这个布尔值:
isShowingUser = true最后,将之前的sheet()修饰符替换为以下代码:
.alert("Welcome", isPresented: $isShowingUser, presenting: selectedUser) { user in
Button(user.id) { }
}掌握了这些内容,你几乎已经了解了关于弹出层和警告框的所有知识。不过,还有最后一个知识点我想补充一下,让你的知识体系更完整。
显示弹出层时,我们可以添加“呈现范围”(presentation detents),让弹出层不占据整个屏幕空间。这可以通过presentationDetents()修饰符实现,该修饰符接受一组用于定义弹出层大小的参数。
例如,我们可以指定弹出层的大小为“中等”(medium)和“大”(large):
Text(selectedUser.id)
.presentationDetents([.medium, .large])由于这里指定了两种大小,弹出层首次显示时会使用初始大小(中等),但iOS会显示一个小的拖动手柄,用户可以通过拖动手柄将弹出层拉到全屏大小。
将组用作透明布局容器
作者:Paul Hudson 2024年3月17日
SwiftUI中的Group视图初看可能有些奇怪,因为它实际上根本不影响布局。但作为“透明布局容器”,它有一个重要作用:让我们能够在不改变布局的情况下,为多个视图添加SwiftUI修饰符;或者在不使用@ViewBuilder的情况下,返回多个视图。
例如,下面的UserView中有一个包含三个文本视图的Group:
struct UserView: View {
var body: some View {
Group {
Text("Name: Paul")
Text("Country: England")
Text("Pets: Luna and Arya")
}
.font(.title)
}
}这个组除了为三个文本视图统一设置字体外,不包含任何布局信息,因此我们无法确定这三个文本视图会垂直堆叠、水平排列还是按深度层叠。这正是Group“透明布局”特性的关键所在:无论哪个父视图包含UserView,都由父视图决定其内部文本视图的排列方式。
例如,我们可以创建如下所示的ContentView:
struct ContentView: View {
@State private var layoutVertically = false
var body: some View {
Button {
layoutVertically.toggle()
} label: {
if layoutVertically {
VStack {
UserView()
}
} else {
HStack {
UserView()
}
}
}
}
}每次点击按钮,布局都会在垂直和水平之间切换。
你可能会疑惑,这种“可选布局”的使用场景到底有多少?答案可能会让你惊讶——非常普遍!因为当我们编写跨设备尺寸的代码时,恰好需要这样的功能:当水平空间有限时垂直布局,空间充足时则水平布局。苹果提供了一个非常简单的解决方案,叫做“尺寸类”(size classes),它用一种非常笼统的方式告诉我们视图有多少可用空间。
我说它“笼统”,是因为无论从最大的iPad Pro横屏模式到最小的iPhone竖屏模式,水平和垂直方向上都只有两种尺寸类:“紧凑”(compact)和“常规”(regular)。这并不意味着它没用——恰恰相反,它非常有用——只是它只能让我们从最宽泛的角度来设计用户界面。
为了演示尺寸类的实际用法,我们可以创建一个视图,通过一个属性跟踪当前的尺寸类,从而自动在VStack和HStack之间切换:
struct ContentView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .compact {
VStack {
UserView()
}
} else {
HStack {
UserView()
}
}
}
}提示:在这种场景下,如果栈(Stack)中只有一个视图,且该视图不需要任何参数,你可以直接将视图的初始化器传递给VStack,让代码更简洁:
if sizeClass == .compact {
VStack(content: UserView.init)
} else {
HStack(content: UserView.init)
}我知道代码简洁不代表一切,但当你用这种方式实现分组视图布局时,这种写法确实非常简洁优雅。
运行这段代码后看到的效果会因设备而异。例如,iPhone 15 Pro在竖屏和横屏模式下,水平尺寸类都是“紧凑”(compact);而iPhone 15 Pro Max在横屏模式下,水平尺寸类则是“常规”(regular)。
无论我们是通过尺寸类还是按钮来切换布局,核心在于UserView完全不需要关心这些——它内部的Group只是将文本视图组合在一起,不影响任何布局,因此UserView最终的布局方式完全取决于它被如何使用。
最后,我想提一下,SwiftUI还提供了一个视图,可以在某种程度上简化这种布局适配工作,它就是ViewThatFits。你可以为它提供多种不同的布局方案,它会自动按顺序尝试这些方案,直到找到一个能适配可用空间的方案。
例如,下面的代码会首先尝试显示一个500x200的矩形,如果无法适配可用空间,则显示一个200x200的圆形:
ViewThatFits {
Rectangle()
.frame(width: 500, height: 200)
Circle()
.frame(width: 200, height: 200)
}如果可以使用ViewThatFits,那就尽量使用它——因为当你以多种形式显示同一个视图时,SwiftUI会在布局变化时正确保留该视图的状态。不过,它在控制粒度上会有所限制!
为SwiftUI视图添加搜索功能
作者:Paul Hudson 2024年3月17日
通过searchable()修饰符,iOS可以为我们的视图添加搜索栏,我们还可以将一个字符串属性与搜索栏绑定,以便根据用户输入过滤数据。
为了理解它的用法,我们来看一个简单的例子:
struct ContentView: View {
@State private var searchText = ""
var body: some View {
NavigationStack {
Text("Searching for \(searchText)")
.searchable(text: $searchText, prompt: "Look for something")
.navigationTitle("Searching")
}
}
}重要提示:请确保你的视图包含在NavigationStack中,否则iOS无法确定搜索框的放置位置。
运行这段代码后,你会看到一个可以输入的搜索栏,输入的内容会显示在下方的视图中。
在实际开发中,searchable()最适合用于数据过滤场景。记住,当@State属性发生变化时,SwiftUI会重新调用body属性,因此我们可以通过计算属性来处理实际的过滤逻辑:
struct ContentView: View {
@State private var searchText = ""
let allNames = ["Subh", "Vina", "Melvin", "Stefanie"]
var filteredNames: [String] {
if searchText.isEmpty {
allNames
} else {
allNames.filter { $0.localizedStandardContains(searchText) }
}
}
var body: some View {
NavigationStack {
List(filteredNames, id: \.self) { name in
Text(name)
}
.searchable(text: $searchText, prompt: "Look for something")
.navigationTitle("Searching")
}
}
}运行这段代码后,iOS可能会将搜索栏自动隐藏在列表的最顶部——你需要轻轻下拉才能显示它,这种交互方式与其他iOS应用保持一致。iOS并不强制要求我们为列表添加搜索功能,但这一功能能极大提升用户体验!
提示:localizedStandardContains()是处理用户输入搜索的最佳方式,因为它会自动忽略大小写和重音符号(例如“café”中的“é”)。
通过SwiftUI的环境共享@Observable对象
作者:Paul Hudson 2024年3月17日
Swift的@Observable宏结合@State,让我们可以轻松地在应用中创建和使用数据。之前我们已经学习过如何在不同视图之间传递值,但有时你需要在应用的多个地方共享同一个对象,这时就需要用到SwiftUI的“环境”(environment)。
为了理解它的用法,我们先从一段你可能已经熟悉的代码开始。下面的代码创建了一个小型的Player类,SwiftUI可以观察该类的变化:
@Observable
class Player {
var name = "Anonymous"
var highScore = 0
}然后,我们可以在一个小型视图中显示该类的最高分属性,例如:
struct HighScoreView: View {
var player: Player
var body: some View {
Text("Your high score: \(player.highScore)")
}
}这个视图需要接收一个Player对象,因此我们可能会编写如下代码:
struct ContentView: View {
@State private var player = Player()
var body: some View {
VStack {
Text("Welcome!")
HighScoreView(player: player)
}
}
}这都是我们已经学过的内容:直接将值传递给子视图,供子视图使用。
但通常情况下,我们的需求会更复杂:如果这个对象需要在很多地方共享怎么办?如果视图A需要将它传递给视图B,视图B再传递给视图C,视图C还要传递给视图D怎么办?很明显,这样的代码会非常繁琐。
SwiftUI为这些问题提供了更好的解决方案:我们可以将对象放入环境中,然后使用@Environment属性包装器从环境中读取该对象。
实现这一功能只需对代码做两处小修改。首先,我们不再直接向HighScoreView传递值,而是使用environment()修饰符将对象放入环境中:
VStack {
Text("Welcome!")
HighScoreView()
}
.environment(player)重要提示:该修饰符专门用于使用@Observable宏的类。在底层,@Observable宏会做很多工作,其中之一就是让类遵循Observable协议(注意没有@符号!),而environment()修饰符正是寻找遵循该协议的类。
将对象放入环境后,任何子视图都可以从环境中读取它。对于我们的HighScoreView,需要将其player属性修改为:
@Environment(Player.self) var player与其他可观察状态类似,当player的属性发生变化时,HighScoreView会自动重新加载。但要注意:如果你的代码声明某个环境对象会存在于环境中,但实际环境中并没有该对象,应用就会崩溃。
尽管这种方式在大多数情况下都很好用,但有一个场景你几乎肯定会遇到问题:尝试将@Environment值用作绑定属性时。
注意:如果你是在iOS 18发布后阅读本文,我真心希望苹果已经解决了这个问题,但目前我使用的是iOS 17,这个问题仍然存在。
通过以下代码可以看到这个问题:
struct HighScoreView: View {
@Environment(Player.self) var player
var body: some View {
Stepper("High score: \(player.highScore)", value: $player.highScore)
}
}这段代码尝试将player的highScore属性与步进器(Stepper)绑定。如果我们用@State创建player实例,这种写法完全没问题,但对于@Environment来说,它无法正常工作。
目前(至少在我撰写本文时),苹果给出的解决方案是在body属性内部直接使用@Bindable,代码如下:
@Bindable var player = player这实际上相当于在说:“在本地创建一个player属性的副本,然后用我可以使用的绑定包装它。”说实话,这种写法有点别扭,我真心希望你阅读本文时,这种写法已经不再需要了!