Hacking with iOS: SwiftUI Editio
根据 size classes 更改视图的布局
SwiftUI为我们提供了两个环境值来监视应用程序的当前size class,这实际上意味着在空间有限时可以显示一种布局,在空间足够时可以显示另一种布局。
例如,在当前布局中,我们在HStack
中显示度假村详细信息和降雪详细信息,如下所示:
HStack {
Spacer()
ResortDetailsView(resort: resort)
SkiDetailsView(resort: resort)
Spacer()
}
这些子视图中的每一个都在内部使用VStack
,因此最终得到的是一个2 x 2的网格:两行,每行有两个视图。当空间受到限制时,这看起来很棒,但是当我们有更多空间时,将它们全部排成一行看起来会更好。
为了做到这一点,我们可以创建ResortDetailsView
和SkiDetailsView
的副本来处理替代布局,但是更聪明的解决方案是使这两个视图在布局上都是中立的——使它们自动适应放置在HStack
或VStack
中,具体取决于父视图放置它们。
首先,将此新的@Environment
属性添加到ResortView
:
@Environment(\.horizontalSizeClass) var sizeClass
这将告诉我们是否有常规或紧凑的size class。简单的说:
- 所有纵向iPhone均具有紧凑的宽度和常规的高度。
- 横屏的大多数iPhone具有紧凑的宽度和紧凑的高度。
- 横向的大型iPhone(Plus和Max设备)具有常规的宽度和紧凑的高度。
- 两种方向上的所有iPad的宽度和高度均常规。
对于iPad来说,分割视图模式会使事情变得更加复杂,这就是当您同时运行两个应用程序时– iOS会根据确切的iPad型号,在各个点上自动将我们的应用程序降级为紧凑的尺寸级别。
幸运的是,我们只关心这两个水平选项:我们是否有很多空间(常规)或受空间限制(紧凑)。如果空间不足,我们将保留当前的嵌套VStack
方法,这样就不会尝试将所有内容压缩到一行上,但是如果有更多空间,我们将放弃它并将视图直接放置到父HStack
中。
因此,找到包含ResortDetailsView
和SkiDetailsView
的HStack
并将其替换为:
HStack {
if sizeClass == .compact {
Spacer()
VStack { ResortDetailsView(resort: resort) }
VStack { SkiDetailsView(resort: resort) }
Spacer()
} else {
ResortDetailsView(resort: resort)
Spacer()
SkiDetailsView(resort: resort)
}
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)
如您所见,这会将VStack
移至父视图,而不是将其保留在ResortDetailsView
和SkiDetailsView
中。
这实际上并没有太大变化,实际上情况变得有些糟,因为如果您在横向(常规尺寸级别)的iPhone 11 Pro Max中运行,则两个子视图的间距会奇怪,因为我们从两个间隔降到了一个。
解决该问题很容易,但是同时会产生其他问题。幸运的是,这些也很容易修复,所以请坚持我——我们终将实现我们想要的效果!
为了使我们的两个子视图布局保持中立(使它们没有自己的特定布局方向,而是由其父级进行定向),我们需要使它们使用Group
而不是VStack
。
因此,将SkiDetailsView
更新为:
var body: some View {
Group {
Text("Elevation: \(resort.elevation)m")
Text("Snow: \(resort.snowDepth)cm")
}
}
并将ResortDetailsView
更新为:
var body: some View {
Group {
Text("Size: \(size)")
Text("Price: \(price)")
}
}
在纵向模式下的iPhone上,它们看起来是相同的,因为我们已经从将VStack
嵌套在另一个VStack
中改成了将Group
嵌套在VStack
内——没有布局差异。但是在横向环境中,情况看起来要好一些,因为所有四个文本视图现在都放在一行中。它们在单行上的布局很差,但至少它们在单行上!
下一步是在我们的两个子视图中添加一些分隔符,以确保它们在文本视图之间放置了空格。
因此,将SkiDetailsView
更新为:
var body: some View {
Group {
Text("Elevation: \(resort.elevation)m")
Spacer()
Text("Snow: \(resort.snowDepth)cm")
}
}
并将ResortDetailsView
更新为:
var body: some View {
Group {
Text("Size: \(size)")
Spacer()
Text("Price: \(price)")
}
}
如果再次运行该应用程序,您会发现情况同时变得越来越糟:横向更好,因为所有四个文本在视图中的排列整齐,而纵向则更糟,因为这些新的间隔物造成了破坏我们的垂直堆栈——我们的顶部是“尺寸”和“海拔”,然后是一个较大的缺口,然后是“价格”和“降雪”。
要解决此问题,我们需要告诉间隔视图Spacer()
我们只希望它们在横向模式下工作——不应尝试垂直增加空间。因此,将ResortDetailView
和SkiDetailsView
内部的间隔符修改为零高度,如下所示:
Spacer().frame(height: 0)
再一次,这是前进的步伐与后退的步伐相结合:我们的垂直间距现在已经按预期消失了,但是现在两个子视图之间没有间距——我们在它们之间放置的间隔很小。
发生这种情况的原因是,使用Spacer().frame(height: 0)
创建的框架具有灵活的宽度,从而导致子视图占据所有可用空间,这又意味着我们在这两个子之间放置的间隔物已剩无几。
因此,我们也需要给外间隔视图一个灵活的宽度——任何大小都很好,因为它将设置为一个灵活的大小。试试这个,例如:
ResortDetailsView(resort: resort)
Spacer().frame(height: 0)
SkiDetailsView(resort: resort)
现在我们快到了:纵向布局看起来不错,横向布局中的四段文字均等分布。但是,即使有很多可用空间,您也可能会注意到海拔跨越了两行。我认为这是SwiftUI出现问题的另一个地方,因为我认为文本应始终比分隔符具有更高的布局优先级——希望在以后的SwiftUI更新中可以解决此问题。
同时,如果这个问题影响到您,那么我们需要告诉SwiftUI我们的文字比分隔符更重要。这可以通过在四个文本视图中的每个视图中添加layoutPriority(1)
修饰符来完成。
因此,所有这些更改的结果是SkiDetailsView
应该看起来像这样:
var body: some View {
Group {
Text("Elevation: \(resort.elevation)m").layoutPriority(1)
Spacer().frame(height: 0)
Text("Snow: \(resort.snowDepth)cm").layoutPriority(1)
}
}
ResortDetailsView
应该看起来像这样:
var body: some View {
Group {
Text("Size: \(size)").layoutPriority(1)
Spacer().frame(height: 0)
Text("Price: \(price)").layoutPriority(1)
}
}
而且ResortView
中的HStack
应该如下所示:
HStack {
if sizeClass == .compact {
Spacer()
VStack { ResortDetailsView(resort: resort) }
VStack { SkiDetailsView(resort: resort) }
Spacer()
} else {
ResortDetailsView(resort: resort)
Spacer().frame(height: 0)
SkiDetailsView(resort: resort)
}
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)
现在,最终我们的布局在两个方向上都应该看起来很棒:常规尺寸级别的一行文本,而紧凑尺寸级别的两行垂直堆栈。花了点功夫,但我们最终实现了!
我们的解决方案不会导致代码重复,这是一个巨大的胜利,同时它也使我们的两个子视图处于更好的位置——现在,它们仅用于提供其内容而无需指定布局。因此,父视图可以随时在HStack
和VStack
之间动态切换,而SwiftUI将为我们处理布局。我们唯一编码的规则是有意义的规则:我们的文本很重要,在布局时甚至应该提高优先级。
将Alert 绑定到可选字符串
SwiftUI允许我们在可选值更改时显示Alert
,但是使用可选字符串时,这并不是那么简单。
为了证明这一点,我们将重写度假村设施的显示方式。现在,我们生成了一个纯文本视图,如下所示:
Text(ListFormatter.localizedString(byJoining: resort.facilities))
.padding(.vertical)
我们将用代表每个设施的图标来代替它,当用户点击一个设施时,我们将显示一个Alert
,其中包含对该设施的描述。
和往常一样,我们将从小处着手,然后逐步提高。首先,我们需要一种将设施名称(如“住宿”)转换为可以显示的图标的方法。尽管这仅在ResortView
中现在会发生,但此功能恰恰是应该在我们项目的其他地方使用的功能。因此,我们将创建一个新结构体来为我们保存所有这些信息。
创建一个名为 Facility.swift 的新Swift文件,用SwiftUI替换其Foundation 导入,并提供以下代码:
struct Facility {
static func icon(for facility: String) -> some View {
let icons = [
"Accommodation": "house",
"Beginners": "1.circle",
"Cross-country": "map",
"Eco-friendly": "leaf.arrow.circlepath",
"Family": "person.3"
]
if let iconName = icons[facility] {
let image = Image(systemName: iconName)
.accessibility(label: Text(facility))
.foregroundColor(.secondary)
return image
} else {
fatalError("Unknown facility type: \(facility)")
}
}
}
如您所见,它具有一个静态方法,该方法接受设施名称,在字典中查找它,并返回一个可用于该设施的新图像。我选择了各种SF符号图标,这些图标非常适合我们现有的设施,并且我还对图像使用了accessibility(label :)
修饰符,以确保它在VoiceOver中可以正常使用。
现在,我们可以通过替换以下代码将该设施视图放入ResortView
:
Text(ListFormatter.localizedString(byJoining: resort.facilities))
.padding(.vertical)
---->
HStack {
ForEach(resort.facilities, id: \.self) { facility in
Facility.icon(for: facility)
.font(.title)
}
}
.padding(.vertical)
这会遍历设施数组中的每个项目,将其转换为图标并将其放入HStack
。我使用.font(.title)
修饰符使图像变大——如果要在其他地方使用这些图标,则在此处而不是在内部使用该修饰符可以使我们更具灵活性。
那是容易的部分。接下来是最难的部分:我们想向这些设施图像添加onTapGesture()
修饰符,以便在点击它们时显示警报Alert
。
使用alert()
的可选形式,可以很容易地开始——向ResortView
添加一个新属性以存储当前选择的设施名称:
@State private var selectedFacility: String?
现在,将此修饰符添加到.font(.title)
下方的工具图标中:
.onTapGesture {
self.selectedFacility = facility
}
我们可以通过与创建图标非常相似的方式来创建Alert
——通过向Facility
结构体添加静态方法以在字典中查找名称,即可:
static func alert(for facility: String) -> Alert {
let messages = [
"Accommodation": "This resort has popular on-site accommodation.",
"Beginners": "This resort has lots of ski schools.",
"Cross-country": "This resort has many cross-country ski routes.",
"Eco-friendly": "This resort has won an award for environmental friendliness.",
"Family": "This resort is popular with families."
]
if let message = messages[facility] {
return Alert(title: Text(facility), message: Text(message))
} else {
fatalError("Unknown facility type: \(facility)")
}
}
现在,我们可以在ResortView
的滚动视图中添加一个alert(item :)
修饰符,只要selectedFacility
具有值即可显示正确的警报:
.alert(item: $selectedFacility) { facility in
Facility.alert(for: facility)
}
如果您尝试构建该代码,则会感到失望,因为它不起作用。您会看到,我们不能只将任何@State
属性绑定到alert(item :)
修饰符——它必须符合Identifiable
协议。原因很微妙,但很重要:如果将selectedFacility
设置为某个字符串,则会出现警报,但是如果将其更改为其他字符串,SwiftUI需要能够看到该值已更改。
字符串不符合Identifiable
,这就是为什么我们在遍历设施时需要使用ForEach(resort.facilities,id:\ .self){
有两种方法可以解决此问题:一种方法乍一看似乎令人怀疑,但考虑到反思实际上很有道理,另一种方法工作量大但侵入性小。
第一种解决方案是使字符串符合Identifiable
,以便我们不再需要在任何地方使用id:\.self
。这可以通过在项目中的任何文件中添加微小扩展名来完成:
extension String: Identifiable {
public var id: String { self }
}
有了我们的代码,现在就可以构建了,实际上您可以将前面提到的ForEach
更改为:
ForEach(resort.facilities) { facility in
如果您现在运行该应用程序,您会看到它全部正常运行,并且点按任何图标也会显示警报Alert
。
更好的是,我们现在可以在以前要求我们使用id:\ .self
的任何地方使用本地字符串。这大大简化了SwiftUI,并且如果您打算在这些情况下使用字符串,则别无选择,只能使用id:\.self
,因此此解决方案正是您想要的。
但是,一件小事确实让我感到不舒服,这就是:当通常使用UUID
或整数之类的方法可能会更好时,使用字符串作为标识符有点太容易了。
如果这也让您感到不舒服,我想探索一种解决方案。另一方面,如果您非常习惯在这里使用字符串——坦白地说,,那我认为这是一件非常合理的事情! ——然后,请务必跳至下一章。
还在?好的。要解决此问题,我们需要升级代码的一些部分。
首先,我们要使Facility
本身可识别,这意味着给它一个唯一的id属性,并将该设施的名称存储在其中。这意味着我们将创建Facility
的实例并使用其数据,而不是依赖静态方法。
因此,将Facility
结构更改为此:
struct Facility: Identifiable {
let id = UUID()
var name: String
var icon: some View {
let icons = [
"Accommodation": "house",
"Beginners": "1.circle",
"Cross-country": "map",
"Eco-friendly": "leaf.arrow.circlepath",
"Family": "person.3"
]
if let iconName = icons[name] {
let image = Image(systemName: iconName)
.accessibility(label: Text(name))
.foregroundColor(.secondary)
return image
} else {
fatalError("Unknown facility type: \(name)")
}
}
var alert: Alert {
let messages = [
"Accommodation": "This resort has popular on-site accommodation.",
"Beginners": "This resort has lots of ski schools.",
"Cross-country": "This resort has many cross-country ski routes.",
"Eco-friendly": "This resort has won an award for environmental friendliness.",
"Family": "This resort is popular with families."
]
if let message = messages[name] {
return Alert(title: Text(name), message: Text(message))
} else {
fatalError("Unknown facility type: \(name)")
}
}
}
接下来,我们将更新Resort
结构体,以使其具有一个计算属性,该属性将其设施包含为一组Facility
而不是String
。这意味着我们仍然从JSON加载原始的字符串数组,但是已经准备好使用[Facility]
替代方法。理想情况下,这将使用自定义的Codable
初始值设定项来完成,但我不想再重复一遍!
因此,现在将此属性添加到Resort
:
var facilityTypes: [Facility] {
facilities.map(Facility.init)
}
现在,在ResortView
中,我们可以更新代码以使用Facility?
而不是String?
。
首先,更改属性:
@State private var selectedFacility: Facility?
接下来,将ForEach
更改为使用新的facilityTypes
属性而不是facilities
,这又意味着我们可以直接访问该图标,因为现在我们有了真正的Facility
实例:
HStack {
ForEach(resort.facilityTypes) { facility in
facility.icon
.font(.title)
.onTapGesture {
self.selectedFacility = facility
}
}
}
.padding(.vertical)
最后,我们可以替换alert()
修饰符以使用设施警报,如下所示:
.alert(item: $selectedFacility) { facility in
facility.alert
}
这项工作相当繁琐,但现在确实意味着我们可以删除自定义的String
扩展——这是一项颇具侵略性的更改,如果Apple这么做就那么就会变得简单,我相信他们一定会使alert(item:)
使用更为通用的协议,例如String
已经遵守的Equatable
。
让用户添加收藏
该项目的最终任务是让用户使用收藏夹来保存给他们喜欢的度假村。这很简单,使用我们已经介绍的技术:
- 创建一个具有用户喜欢的度假胜地ID
Set
的新的Favorites
类。 - 给它添加操作数据的
add()
,remove()
和contains()
方法,向SwiftUI发送更新通知,同时还将所有的更改使用UserDefaults
保存。 - 添加一些新的UI来调用适当的方法。
Swift的集合已经包含用于添加,删除和检查元素的方法,但是我们将添加自己的方法,因此我们可以使用objectWillChange
通知SwiftUI发生了更改,还可以调用save()
方法,使用户的更改保留。反过来,这意味着我们可以使用private
访问控制来标记收藏夹集,这样我们就不会无意间绕过我们的方法而错过保存。
创建一个新的Swift文件,命名为 Favorites.swift,将其 Foundation 导入替换为SwiftUI,然后为其提供以下代码:
class Favorites: ObservableObject {
// 用户喜欢的度假胜地集合
private var resorts: Set<String>
// 我们用来在UserDefaults中读取/写入的key
private let saveKey = "Favorites"
init() {
// 加载我们保存的内容
// 暂时使用空数组
self.resorts = []
}
// 判断是否包含一个度假地
func contains(_ resort: Resort) -> Bool {
resorts.contains(resort.id)
}
// 将度假地添加到我们的集合中,更新所有视图,并保存更改
func add(_ resort: Resort) {
objectWillChange.send()
resorts.insert(resort.id)
save()
}
// 从我们的集合中删除该度假地,更新所有视图,并保存更改
func remove(_ resort: Resort) {
objectWillChange.send()
resorts.remove(resort.id)
save()
}
func save() {
// 保存数据
}
}
您会发现我没写加载和保存收藏夹的实际功能,这是您很快就会完成的工作。
我们需要在ContentView
中创建一个收藏夹实例并将其注入到环境中,以便所有视图都可以共享它。因此,将此新属性添加到ContentView
:
@ObservedObject var favorites = Favorites()
现在,通过将此修饰符添加到NavigationView
中,将其注入到环境中:
.environmentObject(favorites)
由于该视图已附加到导航视图中,因此导航视图显示的每个视图也将获得该Favorites
实例使用。因此,我们可以通过添加以下新属性从ResortView
内部加载它:
@EnvironmentObject var favorites: Favorites
所有这些工作尚未真正完成——确保应用程序启动时会加载Favorites
类,尽管具有存储属性,但它实际上并没有在任何地方使用。
这很容易解决:我们将在ResortView
的滚动视图的末尾添加一个按钮,以便用户可以从其收藏夹中添加或删除该度假村,然后在ContentView
中显示一个心形图标以显示最喜欢的度假村。
首先,将其添加到ResortView
的滚动视图末尾:
Button(favorites.contains(resort) ? "Remove from Favorites" : "Add to Favorites") {
if self.favorites.contains(self.resort) {
self.favorites.remove(self.resort)
} else {
self.favorites.add(self.resort)
}
}
.padding()
现在,可以通过将其添加到NavigationLink
的末尾,在ContentView
中最喜欢的度假村旁边显示一个彩色的心形图标:
if self.favorites.contains(resort) {
Spacer()
Image(systemName: "heart.fill")
.accessibility(label: Text("This is a favorite resort"))
.foregroundColor(.red)
}
提示:正如您所看到的,因为我们的图像使用SF符号,所以foregroundColor()
修饰符在这里可以很好地工作。
多数情况下这种方法可行,但是您可能会注意到一个小问题:如果您喜欢名称较长的度假村,则即使名称空间很大,它们的名称也会分成两行。这又是一个示例,其中SwiftUI的布局系统为分隔符分配了过多的优先级,而为文本分配的优先级却不够,所以现在解决此问题——希望直到苹果很快解决! ——是在我们刚刚添加条件之前直接调整VStack
的布局优先级:
VStack(alignment: .leading) {
Text(resort.name)
.font(.headline)
Text("\(resort.runs) runs")
.foregroundColor(.secondary)
}
.layoutPriority(1)
即使使用间隔符和心形图标,也应该可以使文本布局正确。
这也就完成了我们的项目,因此请最后尝试一下,看看您的想法。
Good job!
译自
Changing a view’s layout in response to size classes
Binding an alert to an optional string
Letting the user mark favorites