Hacking with iOS: SwiftUI Edition

Hacking with iOS: SwiftUI Editio

2021-02-10  本文已影响0人  韦弦Zhy

\mathbf{Hacking \quad with \quad iOS: SwiftUI \quad Edition - proj.19}
\mathbf{Proj.19 \ 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<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小于我们的国旗源图像,并且宽高比也不同,但是我们可以使用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的响应方式:

前两个可能看起来是倒退的,但这是由于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. 度假村的大小存储为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

上一篇 下一篇

猜你喜欢

热点阅读