专栏首页韦弦的偶尔分享Hacking with iOS: SwiftUI Edition - SnowSeeker 项目(一)

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

建立项目的主要清单

在此应用中,我们将同时显示两个视图,就像 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. 度假村的大小存储为1到3之间的值,但实际上我们希望使用“Small”、“Average”和“Large”。

和往常一样,从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

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Hacking with iOS: SwiftUI Edition - SnowSeeker 项目(二)

    SwiftUI为我们提供了两个环境值来监视应用程序的当前size class,这实际上意味着在空间有限时可以显示一种布局,在空间足够时可以显示另一种布局。

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - SnowSeeker 项目——挑战

    这不是一个特别复杂的项目,但它仍然教会了新技能,例如拆分视图布局,可选警报Alert,具有透明组的布局,甚至是ListFormatter。它还使您有机会练习许多...

    韦弦zhy
  • 100 Days of SwiftUI —— Day 97:SnowSeeker (二)

    今天,我们将实施程序的前半部分,这意味着我们将获得滑雪胜地的列表,显示更多信息的详细视图以及可以并排显示它们的NavigationView。就其本身而言,这不会...

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - Flashzilla 项目(一)

    在这个项目中,我们希望用户看到一张带有提示文本的卡片,以显示他们想要学习的内容,例如“苏格兰的首都是什么?”,当他们点击它时,我们将给出答案,在这种情况下,答案...

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - Moonshot 项目(一)

    在这个项目中,我们将构建一个应用程序,让用户了解组成美国宇航局阿波罗太空计划的任务和宇航员。您将获得更多的使用Codable的经验,但更重要的是,您还将使用滚动...

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - iExpense 项目

    我们接下来的两个项目将开始把你的SwiftUI技能推向基础之外,因为我们将探索具有多个屏幕、加载和保存用户数据以及具有更复杂用户界面的应用程序。

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - 滤镜项目(一)

    现在,我要重点关注以下注释:// display the image。如果我们有一个图像,则需要在此处显示所选图像,否则,我们应该显示一个提示,告诉用户点击该区...

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - Hot Prospects项目(一)

    在此项目中,我们将构建 Hot Prospects,该应用程序可跟踪您在会议上遇到的人。您可能以前曾经看过类似的应用程序:它将显示一个QR码,用于存储您的与会者...

    韦弦zhy
  • Hacking with iOS: SwiftUI Edition - 书虫项目(一)

    在这个项目中,我们将构建一个应用程序来跟踪您阅读过哪些书以及您对它们的看法,并且它将遵循与项目10类似的主题:让我们利用您已经掌握的所有技能,然后添加一些额外的...

    韦弦zhy

扫码关注云+社区

领取腾讯云代金券