美文网首页Hacking with iOS: SwiftUI Edition
Hacking with iOS: SwiftUI Editio

Hacking with iOS: SwiftUI Editio

作者: 韦弦Zhy | 来源:发表于2021-02-10 22:13 被阅读0次

    \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 国旗。
    • 度假村的名称。
    • 它有多少条跑道。

    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”。
    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

    相关文章

      网友评论

        本文标题:Hacking with iOS: SwiftUI Editio

        本文链接:https://www.haomeiwen.com/subject/kmhnaktx.html