Hacking with iOS: SwiftUI Editio
在该项目中,我们将创建SnowSeeker:一款可让用户浏览世界各地滑雪胜地的应用程序,以帮助他们找到适合下一个假期的滑雪胜地。
这将是第一个我们专门旨在通过并排显示两个视图来使某些功能在iPad上发挥出色的应用程序,但您还将深入研究解决有问题的布局,学习显示工作表和警报的新方法,以及更多。
建立项目的主要清单
在此应用中,我们将同时显示两个视图,就像 Apple 的 Mail 和 Notes 应用一样。在 SwiftUI 中,这是通过将两个视图放入NavigationView
中,然后在主视图中使用NavigationLink
来控制在辅助视图中可见的内容来完成的。
因此,我们将通过为应用程序构建主视图来开始我们的项目,该视图将显示所有滑雪胜地的列表,它们来自哪个国家/地区以及拥有多少个滑雪道——您可以从多少个滑雪道滑下,有时称为“小径”或仅称为“斜坡”。
我已经在本书的GitHub存储库中为该项目提供了一些资源,因此,如果您尚未下载它们,请立即下载(下载地址见开篇Hacking with iOS: SwiftUI Edition文末)。您应该将 resorts.json 拖到项目导航器中,然后将所有图片复制到资源目录中。您可能会注意到,我为这些国家/地区添加了 2x 和 3x 图像,但为度假胜地仅添加了 2x 图片。这是故意的:这些标志将同时用于视网膜和Super Retina设备,但是度假村图片旨在填充iPad Pro的所有空间——即使在2倍分辨率下,它们也足以容纳Super Retina iPhone 。
为了快速启动并运行我们的列表,我们需要定义一个简单的Resort
结构,该结构可以从JSON加载。这意味着它需要符合Codable
,但是为了使其更易于在SwiftUI中使用,我们还将使其符合Identifiable
。实际数据本身主要是字符串和整数,但是还有一个称为设施的字符串数组,它描述了度假村中还有什么——我应该补充一点,该数据主要是虚构的,所以不要尝试在真实环境中使用它!
创建一个名为 Resort.swift 的新Swift文件,然后为其提供以下代码:
struct Resort: Codable, Identifiable {
let id: String
let name: String
let country: String
let description: String
let imageCredit: String
let price: Int
let size: Int
let snowDepth: Int
let elevation: Int
let runs: Int
let facilities: [String]
}
像往常一样,最好在模型中添加一个示例值,以便更轻松地在设计中显示工作数据。不过,这次有很多字段可以使用,如果它们具有真实数据会很有用,所以我真的不想手工创建一个。
相反,我们有两个选择。第一个选项是添加两个静态属性:一个将所有度假地加载到数组中,一个将第一个项目存储在该数组中,如下所示:
static let allResorts: [Resort] = Bundle.main.decode("resorts.json")
static let example = allResorts[0]
第二种是将所有内容折叠成一行代码。这需要进行一些温和的类型转换,因为我们的decode()
扩展方法需要知道其要解码的数据类型:
static let example = (Bundle.main.decode("resorts.json") as [Resort])[0]
在这两种方法中,我更喜欢第一种方法,因为它更简单,并且如果我们想展示随机示例,而不是一次又一次地展示相同的示例,那么它的用途会更多。如果您很好奇,当我们对属性使用static let
时,Swift会自动使它们变得懒惰——除非使用它们,否则它们不会被创建。这意味着当我们尝试阅读Resort.example
时,Swift将被迫首先创建Resort.allResorts
,然后将该数组中的第一项发送回给Resort.example
。这意味着我们始终可以确保这两个属性将以正确的顺序运行——由于还没有调用allResorts
,因此不会丢失示例。
我们想从存储在应用程序捆绑包中的JSON加载一组度假胜地,这意味着我们可以重复使用为项目8编写的相同代码——Bundle-Decodable.swift扩展名。如果您有需要,可以将其放入新项目中,如果没有,则创建一个名为 Bundle-Decodable.swift 的新Swift文件,并提供以下代码:
extension Bundle {
func decode<T: Decodable>(_ file: String) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
guard let loaded = try? decoder.decode(T.self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
return loaded
}
}
通过该扩展,我们现在可以向 ContentView
添加一个属性,该属性将我们的所有度假村加载到单个数组中:
let resorts: [Resort] = Bundle.main.decode("resorts.json")
对于我们的视图主体,我们将使用其中带有列表的NavigationView
,以显示我们的所有度假胜地。在每一行中,我们将显示:
- 度假村所在国家/地区的 40x25 国旗。
- 度假村的名称。
- 它有多少条跑道。
40x25小于我们的国旗源图像,并且宽高比也不同,但是我们可以使用resizable()
,scaledToFit()
和自定义框架来解决此问题。为了使它在屏幕上看起来更好一点,我们将使用自定义剪辑形状和描边叠加层。
点击该行后,我们将进入一个详细视图,以显示有关度假村的更多信息,但我们尚未构建该视图,因此,我们将其作为占位符推送到一个临时文本视图。
将如下代码替换为当前的body
属性:
NavigationView {
List(resorts) { resort in
NavigationLink(destination: Text(resort.name)) {
Image(resort.country)
.resizable()
.scaledToFill()
.frame(width: 40, height: 25)
.clipShape(
RoundedRectangle(cornerRadius: 5)
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.black, lineWidth: 1)
)
VStack(alignment: .leading) {
Text(resort.name)
.font(.headline)
Text("\(resort.runs) runs")
.foregroundColor(.secondary)
}
}
}
.navigationBarTitle("Resorts")
}
继续并立即运行该应用程序,您应该会看到它看起来不错,但是如果将iPhone旋转到横向,则会看到屏幕变黑。发生这种情况是因为SwiftUI希望在此处显示详细视图,但我们还没有创建一个详细视图——接下来请修复该问题。
使 NavigationView 在横屏中工作
当我们使用NavigationView
时,默认情况下,SwiftUI希望我们提供可以并排显示的主视图和辅助详细视图,主视图显示在左侧,辅助视图显示在右侧。以前,我们通过将StackNavigationViewStyle()
用作NavigationView
的导航样式来解决此问题,它告诉SwiftUI我们只想显示一个视图,但是在这里我们实际上想要的是两个视图的行为,因此我们将不使用它。
在足够大的横向iPhone(例如iPhone 11 Pro Max)上,SwiftUI的默认行为是显示辅助视图,并提供主视图作为滑动视图。它一直都在那里,但是直到现在您可能还没有意识到:尝试从屏幕的左边缘滑动以显示我们刚刚制作的ContentView
。如果您点击其中的行,您将看到由于我们的NavigationLink
而导致ContentView
后面的文本发生了变化;如果您点击了后面的文本,则可以关闭ContentView
的视图。
现在,这里有一个问题,也是您一直遇到的问题:用户并不需要立即从左侧滑动以显示选项列表,这对用户而言并不立即显而易见。在 UIKit 中,可以很容易地修复它,但是SwiftUI现在没有给我们替代方法,因此我们将解决该问题:默认情况下,我们将创建第二个视图以在右侧显示,并使用该视图来提供帮助用户发现左侧列表。
首先,创建一个名为WelcomeView
的新SwiftUI视图,然后为其提供以下代码:
struct WelcomeView: View {
var body: some View {
VStack {
Text("Welcome to SnowSeeker!")
.font(.largeTitle)
Text("Please select a resort from the left-hand menu; swipe from the left edge to show it.")
.foregroundColor(.secondary)
}
}
}
这些全都是静态文字;它只会在应用程序首次启动时显示,因为一旦用户点击我们的任何导航链接,它将被替换为他们导航到的任何内容。
要将其放入ContentView
中,以便可以并排使用UI的两个部分,我们要做的就是向NavigationView
中添加第二个视图,如下所示:
NavigationView {
List(resorts) { resort in
// all the previous list code
}
.navigationBarTitle("Resorts")
WelcomeView()
}
这足以让SwiftUI准确了解我们想要的内容。尝试在纵向和横向的几种不同设备上运行该应用程序,以了解SwiftUI的响应方式:
- 在iPhone 11 Pro上,您会同时看到纵向和横向的
ContentView
。 - 在iPhone 11 上,您会看到纵向的
ContentView
和横向的WelcomeView
。 - 在iPad上,您也将看到纵向的
ContentView
和横向的WelcomeView
。
前两个可能看起来是倒退的,但这是由于Apple的硬件选择有些奇怪:尽管iPhone 11 Pro使用3倍分辨率的Super Retina显示屏,但实际上比iPhone 11的2x显示屏小,因此苹果认为它太小了。
尽管UIKit允许我们控制是否应在iPad纵向上显示主视图,但在SwiftUI中尚无法实现。但是,如果您要这么做,我们可以阻止iPhone 11使用滑动显示——先尝试一下,然后看看您的想法。如果您希望它消失,则将此扩展名添加到您的项目中:
extension View {
func phoneOnlyStackNavigationView() -> some View {
if UIDevice.current.userInterfaceIdiom == .phone {
return AnyView(self.navigationViewStyle(StackNavigationViewStyle()))
} else {
return AnyView(self)
}
}
}
它使用 Apple 的UIDevice
类来检测我们当前是在手机还是平板电脑上运行,如果是手机,则可以启用更简单的StackNavigationViewStyle
方法。我们这里需要使用类型擦除,因为返回的两种视图类型不同。
有了该扩展后,只需将.phoneOnlyStackNavigationView()
修饰符添加到NavigationView
中,以便iPad保留其默认行为,而iPhone始终使用堆栈导航。
再次尝试一下,看看您的想法——这是您的应用,重要的是您喜欢它的工作方式。
提示:我不会在自己的项目中使用此修饰符,因为我更愿意在可能的情况下使用Apple的默认行为,但不要因此而阻止您做出自己的选择!
为NavigationView创建辅助视图
现在,我们的NavigationLink
将用户引导到一些示例文本,这对于原型设计很好,但是对于我们的实际项目来说显然不够好。我们将用一个新的ResortView
来替换它,该视图显示度假胜地的图片、一些描述文本和设施列表。
重要提示:如前所述,我的示例JSON中的内容大部分是虚构的,其中包括照片——这些只是从Unsplash中拍摄的普通滑雪照片。Unsplash照片可以在商业上使用,也可以在非商业上使用,但我已经在JSON中包含了照片信息,因此您可以稍后添加它。至于文本,这是取自维基百科。如果您打算在自己的项目中使用该文本,请务必赞扬Wikipedia及其作者,并明确说明该作品已获得CC-BY-SA许可,可从以下网址获得:https://creativecommons.org/licenses/by-sa/3.0。
首先,我们的restorview
布局将非常简单——只不过是一个滚动视图、一个VStack
、一个Image
和一些Text
。唯一有趣的部分是,我们将使用resort.facilities.joined(separator: ", ")
以获取单个字符串。
将默认ResortView
视图替换为:
struct ResortView: View {
let resort: Resort
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Image(decorative: resort.id)
.resizable()
.scaledToFit()
Group {
Text(resort.description)
.padding(.vertical)
Text("Facilities")
.font(.headline)
Text(resort.facilities.joined(separator: ", "))
.padding(.vertical)
}
.padding(.horizontal)
}
}
.navigationBarTitle(Text("\(resort.name), \(resort.country)"), displayMode: .inline)
}
}
您还需要更新ResortView_Previews
,以便传入Xcode预览窗口的示例旅游地:
struct ResortView_Previews: PreviewProvider {
static var previews: some View {
ResortView(resort: Resort.example)
}
}
现在我们可以更新ContentView
中的导航链接,以指向实际视图,如下所示:
NavigationLink(destination: ResortView(resort: resort)) {
到目前为止,我们的代码中没有什么特别有趣的地方,但是现在会有所改变,因为我想在这个屏幕上添加更多的细节——度假村有多大,大概多少钱,有多高,雪有多深。
我们可以把所有这些放在一个单一的HStack
中,但是这限制了我们将来可以做什么。因此,我们将把它们分为两个视图:一个用于度假村信息(价格和大小),另一个用于滑雪信息(海拔和积雪深度)。
度假村信息视图是这两个视图中比较容易实现的一个,因此我们将从这里开始:创建一个名为SkiDetailsView
的新SwiftUI视图,并给出以下代码:
struct SkiDetailsView: View {
let resort: Resort
var body: some View {
VStack {
Text("Elevation: \(resort.elevation)m")
Text("Snow: \(resort.snowDepth)cm")
}
}
}
struct SkiDetailsView_Previews: PreviewProvider {
static var previews: some View {
SkiDetailsView(resort: Resort.example)
}
}
至于度假胜地的细节,这有点棘手,因为有如下两个方面需要考虑:
- 度假村的大小存储为1到3之间的值,但实际上我们希望使用“Small”、“Average”和“Large”。
- 价格存储为1到3之间的值,但我们将用$、$$或$$$替换它。
和往常一样,从SwiftUI布局中获得计算结果是一个好主意,这样既美观又清晰,所以我们将创建两个计算属性:size
和price
。
首先创建一个名为ResortDetailsView
的新SwiftUI视图,并为其指定以下属性:
let resort: Resort
与RestorView
一样,您需要更新preview
结构体以使用一些示例数据:
struct ResortDetailsView_Previews: PreviewProvider {
static var previews: some View {
ResortDetailsView(resort: Resort.example)
}
}
当涉及到度假村的规模时,我们可以将此属性添加到ResortDetailsView
:
var size: String {
["Small", "Average", "Large"][resort.size - 1]
}
这是可行的,但如果使用了无效的值,它会导致崩溃,而且对我来说这也有点太神秘了。相反,使用这样的switch
代码块更安全、更清晰:
var size: String {
switch resort.size {
case 1:
return "Small"
case 2:
return "Average"
default:
return "Large"
}
}
至于price
属性,我们可以利用与在project17中创建示例卡片时使用的String(repeating:count:)
通过将子字符串重复一定次数来创建新字符串。
因此,请将第二个计算属性添加到ResortDetailsView
:
var price: String {
String(repeating: "$", count: resort.price)
}
现在body
属性中剩下的内容很简单,因为我们只使用我们编写的两个计算属性:
var body: some View {
VStack {
Text("Size: \(size)")
Text("Price: \(price)")
}
}
这就完成了我们的两个小视图,所以我们现在可以将它们放到ResortView
中,两边都有间隔符,以确保它们居中——将其放入ResortView
中的组中,就在度假胜地描述之前:
HStack {
Spacer()
ResortDetailsView(resort: resort)
SkiDetailsView(resort: resort)
Spacer()
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)
我们将在稍后添加更多内容,但首先我想做一个小调整:使用joined(separator:)
可以将字符串数组转换为单个字符串,但我们不是来编写一般可用代码的——我们是来编写出色的代码的。
苹果的基础库提供了一个更好的解决方案,名为ListFormatter
,它只有一项工作:将字符串数组转换为字符串。不同的是,我们没有像现在那样返回“A,B,C”,而是返回“A,B 和 C”——阅读起来更自然。
要使用ListFormatter
,请将当前设施文本视图替换为:
Text(ListFormatter.localizedString(byJoining: resort.facilities))
.padding(.vertical)
好多了!
译自
Building a primary list of items
Making NavigationView work in landscape
Creating a secondary view for NavigationView