Hacking|Hacking with iOS: SwiftUI Edition - SnowSeeker 项目(一)



在该项目中,我们将创建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(_ file: String) -> T { guard let url = self.url(forResource: file, withExtension: nil) else { fatalError("Failed to locate \(file) in bundle.") }guard let data = https://www.it610.com/article/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) } } }

【Hacking|Hacking with iOS: SwiftUI Edition - SnowSeeker 项目(一)】这些全都是静态文字;它只会在应用程序首次启动时显示,因为一旦用户点击我们的任何导航链接,它将被替换为他们导航到的任何内容。
要将其放入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: Resortvar 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: Resortvar 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. 度假村的大小存储为1到3之间的值,但实际上我们希望使用“Small”、“Average”和“Large”。
  2. 价格存储为1到3之间的值,但我们将用$、$$或$$$替换它。
和往常一样,从SwiftUI布局中获得计算结果是一个好主意,这样既美观又清晰,所以我们将创建两个计算属性:sizeprice
首先创建一个名为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

    推荐阅读