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


根据 size classes 更改视图的布局

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

例如,在当前布局中,我们在HStack中显示度假村详细信息和降雪详细信息,如下所示:

HStack {
    Spacer()
    ResortDetailsView(resort: resort)
    SkiDetailsView(resort: resort)
    Spacer()
}

这些子视图中的每一个都在内部使用VStack,因此最终得到的是一个2 x 2的网格:两行,每行有两个视图。当空间受到限制时,这看起来很棒,但是当我们有更多空间时,将它们全部排成一行看起来会更好。

为了做到这一点,我们可以创建ResortDetailsViewSkiDetailsView的副本来处理替代布局,但是更聪明的解决方案是使这两个视图在布局上都是中立的——使它们自动适应放置在HStackVStack中,具体取决于父视图放置它们。

首先,将此新的@Environment属性添加到ResortView

@Environment(\.horizontalSizeClass) var sizeClass

这将告诉我们是否有常规或紧凑的size class。简单的说:

  • 所有纵向iPhone均具有紧凑的宽度和常规的高度。
  • 横屏的大多数iPhone具有紧凑的宽度和紧凑的高度。
  • 横向的大型iPhone(Plus和Max设备)具有常规的宽度和紧凑的高度。
  • 两种方向上的所有iPad的宽度和高度均常规。

对于iPad来说,分割视图模式会使事情变得更加复杂,这就是当您同时运行两个应用程序时– iOS会根据确切的iPad型号,在各个点上自动将我们的应用程序降级为紧凑的尺寸级别。

幸运的是,我们只关心这两个水平选项:我们是否有很多空间(常规)或受空间限制(紧凑)。如果空间不足,我们将保留当前的嵌套VStack方法,这样就不会尝试将所有内容压缩到一行上,但是如果有更多空间,我们将放弃它并将视图直接放置到父HStack中。

因此,找到包含ResortDetailsViewSkiDetailsViewHStack并将其替换为:

HStack {
    if sizeClass == .compact {
        Spacer()
        VStack { ResortDetailsView(resort: resort) }
        VStack { SkiDetailsView(resort: resort) }
        Spacer()
    } else {
        ResortDetailsView(resort: resort)
        Spacer()
        SkiDetailsView(resort: resort)
    }
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)

如您所见,这会将VStack移至父视图,而不是将其保留在ResortDetailsViewSkiDetailsView中。

这实际上并没有太大变化,实际上情况变得有些糟,因为如果您在横向(常规尺寸级别)的iPhone 11 Pro Max中运行,则两个子视图的间距会奇怪,因为我们从两个间隔降到了一个。

解决该问题很容易,但是同时会产生其他问题。幸运的是,这些也很容易修复,所以请坚持我——我们终将实现我们想要的效果!

为了使我们的两个子视图布局保持中立(使它们没有自己的特定布局方向,而是由其父级进行定向),我们需要使它们使用Group而不是VStack

因此,将SkiDetailsView更新为:

var body: some View {
    Group {
        Text("Elevation: \(resort.elevation)m")
        Text("Snow: \(resort.snowDepth)cm")
    }
}

并将ResortDetailsView更新为:

var body: some View {
    Group {
        Text("Size: \(size)")
        Text("Price: \(price)")
    }
}

在纵向模式下的iPhone上,它们看起来是相同的,因为我们已经从将VStack嵌套在另一个VStack中改成了将Group嵌套在VStack内——没有布局差异。但是在横向环境中,情况看起来要好一些,因为所有四个文本视图现在都放在一行中。它们在单行上的布局很差,但至少它们在单行上!

下一步是在我们的两个子视图中添加一些分隔符,以确保它们在文本视图之间放置了空格。

因此,将SkiDetailsView更新为:

var body: some View {
    Group {
        Text("Elevation: \(resort.elevation)m")
        Spacer()
        Text("Snow: \(resort.snowDepth)cm")
    }
}

并将ResortDetailsView更新为:

var body: some View {
    Group {
        Text("Size: \(size)")
        Spacer()
        Text("Price: \(price)")
    }
}

如果再次运行该应用程序,您会发现情况同时变得越来越糟:横向更好,因为所有四个文本在视图中的排列整齐,而纵向则更糟,因为这些新的间隔物造成了破坏我们的垂直堆栈——我们的顶部是“尺寸”和“海拔”,然后是一个较大的缺口,然后是“价格”和“降雪”。

要解决此问题,我们需要告诉间隔视图Spacer()我们只希望它们在横向模式下工作——不应尝试垂直增加空间。因此,将ResortDetailViewSkiDetailsView内部的间隔符修改为零高度,如下所示:

Spacer().frame(height: 0)

再一次,这是前进的步伐与后退的步伐相结合:我们的垂直间距现在已经按预期消失了,但是现在两个子视图之间没有间距——我们在它们之间放置的间隔很小。

发生这种情况的原因是,使用Spacer().frame(height: 0)创建的框架具有灵活的宽度,从而导致子视图占据所有可用空间,这又意味着我们在这两个子之间放置的间隔物已剩无几。

因此,我们也需要给外间隔视图一个灵活的宽度——任何大小都很好,因为它将设置为一个灵活的大小。试试这个,例如:

ResortDetailsView(resort: resort)
Spacer().frame(height: 0)
SkiDetailsView(resort: resort)

现在我们快到了:纵向布局看起来不错,横向布局中的四段文字均等分布。但是,即使有很多可用空间,您也可能会注意到海拔跨越了两行。我认为这是SwiftUI出现问题的另一个地方,因为我认为文本应始终比分隔符具有更高的布局优先级——希望在以后的SwiftUI更新中可以解决此问题。

同时,如果这个问题影响到您,那么我们需要告诉SwiftUI我们的文字比分隔符更重要。这可以通过在四个文本视图中的每个视图中添加layoutPriority(1)修饰符来完成。

因此,所有这些更改的结果是SkiDetailsView应该看起来像这样:

var body: some View {
    Group {
        Text("Elevation: \(resort.elevation)m").layoutPriority(1)
        Spacer().frame(height: 0)
        Text("Snow: \(resort.snowDepth)cm").layoutPriority(1)
    }
}

ResortDetailsView应该看起来像这样:

var body: some View {
    Group {
        Text("Size: \(size)").layoutPriority(1)
        Spacer().frame(height: 0)
        Text("Price: \(price)").layoutPriority(1)
    }
}

而且ResortView中的HStack应该如下所示:

HStack {
    if sizeClass == .compact {
        Spacer()
        VStack { ResortDetailsView(resort: resort) }
        VStack { SkiDetailsView(resort: resort) }
        Spacer()
    } else {
        ResortDetailsView(resort: resort)
        Spacer().frame(height: 0)
        SkiDetailsView(resort: resort)
    }
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)

现在,最终我们的布局在两个方向上都应该看起来很棒:常规尺寸级别的一行文本,而紧凑尺寸级别的两行垂直堆栈。花了点功夫,但我们最终实现了!

我们的解决方案不会导致代码重复,这是一个巨大的胜利,同时它也使我们的两个子视图处于更好的位置——现在,它们仅用于提供其内容而无需指定布局。因此,父视图可以随时在HStackVStack之间动态切换,而SwiftUI将为我们处理布局。我们唯一编码的规则是有意义的规则:我们的文本很重要,在布局时甚至应该提高优先级。

将Alert 绑定到可选字符串

SwiftUI允许我们在可选值更改时显示Alert,但是使用可选字符串时,这并不是那么简单。

为了证明这一点,我们将重写度假村设施的显示方式。现在,我们生成了一个纯文本视图,如下所示:

Text(ListFormatter.localizedString(byJoining: resort.facilities))
    .padding(.vertical)

我们将用代表每个设施的图标来代替它,当用户点击一个设施时,我们将显示一个Alert,其中包含对该设施的描述。

和往常一样,我们将从小处着手,然后逐步提高。首先,我们需要一种将设施名称(如“住宿”)转换为可以显示的图标的方法。尽管这仅在ResortView中现在会发生,但此功能恰恰是应该在我们项目的其他地方使用的功能。因此,我们将创建一个新结构体来为我们保存所有这些信息。

创建一个名为 Facility.swift 的新Swift文件,用SwiftUI替换其Foundation 导入,并提供以下代码:

struct Facility {
    static func icon(for facility: String) -> some View {
        let icons = [
            "Accommodation": "house",
            "Beginners": "1.circle",
            "Cross-country": "map",
            "Eco-friendly": "leaf.arrow.circlepath",
            "Family": "person.3"
        ]

        if let iconName = icons[facility] {
            let image = Image(systemName: iconName)
                            .accessibility(label: Text(facility))
                            .foregroundColor(.secondary)
            return image
        } else {
            fatalError("Unknown facility type: \(facility)")
        }
    }
}

如您所见,它具有一个静态方法,该方法接受设施名称,在字典中查找它,并返回一个可用于该设施的新图像。我选择了各种SF符号图标,这些图标非常适合我们现有的设施,并且我还对图像使用了accessibility(label :)修饰符,以确保它在VoiceOver中可以正常使用。

现在,我们可以通过替换以下代码将该设施视图放入ResortView

Text(ListFormatter.localizedString(byJoining: resort.facilities))
    .padding(.vertical)

----> 

HStack {
    ForEach(resort.facilities, id: \.self) { facility in
        Facility.icon(for: facility)
            .font(.title)
    }
}
.padding(.vertical)

这会遍历设施数组中的每个项目,将其转换为图标并将其放入HStack。我使用.font(.title)修饰符使图像变大——如果要在其他地方使用这些图标,则在此处而不是在内部使用该修饰符可以使我们更具灵活性。

那是容易的部分。接下来是最难的部分:我们想向这些设施图像添加onTapGesture()修饰符,以便在点击它们时显示警报Alert

使用alert()的可选形式,可以很容易地开始——向ResortView添加一个新属性以存储当前选择的设施名称:

@State private var selectedFacility: String?

现在,将此修饰符添加到.font(.title)下方的工具图标中:

.onTapGesture {
    self.selectedFacility = facility
}

我们可以通过与创建图标非常相似的方式来创建Alert——通过向Facility结构体添加静态方法以在字典中查找名称,即可:

static func alert(for facility: String) -> Alert {
    let messages = [
        "Accommodation": "This resort has popular on-site accommodation.",
        "Beginners": "This resort has lots of ski schools.",
        "Cross-country": "This resort has many cross-country ski routes.",
        "Eco-friendly": "This resort has won an award for environmental friendliness.",
        "Family": "This resort is popular with families."
    ]

    if let message = messages[facility] {
        return Alert(title: Text(facility), message: Text(message))
    } else {
        fatalError("Unknown facility type: \(facility)")
    }
}

现在,我们可以在ResortView的滚动视图中添加一个alert(item :)修饰符,只要selectedFacility具有值即可显示正确的警报:

.alert(item: $selectedFacility) { facility in
    Facility.alert(for: facility)
}

如果您尝试构建该代码,则会感到失望,因为它不起作用。您会看到,我们不能只将任何@State属性绑定到alert(item :)修饰符——它必须符合Identifiable协议。原因很微妙,但很重要:如果将selectedFacility设置为某个字符串,则会出现警报,但是如果将其更改为其他字符串,SwiftUI需要能够看到该值已更改。

字符串不符合Identifiable,这就是为什么我们在遍历设施时需要使用ForEach(resort.facilities,id:\ .self){

有两种方法可以解决此问题:一种方法乍一看似乎令人怀疑,但考虑到反思实际上很有道理,另一种方法工作量大但侵入性小。

第一种解决方案是使字符串符合Identifiable,以便我们不再需要在任何地方使用id:\.self。这可以通过在项目中的任何文件中添加微小扩展名来完成:

extension String: Identifiable {
    public var id: String { self }
}

有了我们的代码,现在就可以构建了,实际上您可以将前面提到的ForEach更改为:

ForEach(resort.facilities) { facility in

如果您现在运行该应用程序,您会看到它全部正常运行,并且点按任何图标也会显示警报Alert

更好的是,我们现在可以在以前要求我们使用id:\ .self的任何地方使用本地字符串。这大大简化了SwiftUI,并且如果您打算在这些情况下使用字符串,则别无选择,只能使用id:\.self,因此此解决方案正是您想要的。

但是,一件小事确实让我感到不舒服,这就是:当通常使用UUID或整数之类的方法可能会更好时,使用字符串作为标识符有点太容易了。

如果这也让您感到不舒服,我想探索一种解决方案。另一方面,如果您非常习惯在这里使用字符串——坦白地说,,那我认为这是一件非常合理的事情! ——然后,请务必跳至下一章。

还在?好的。要解决此问题,我们需要升级代码的一些部分。

首先,我们要使Facility本身可识别,这意味着给它一个唯一的id属性,并将该设施的名称存储在其中。这意味着我们将创建Facility的实例并使用其数据,而不是依赖静态方法。

因此,将Facility结构更改为此:

struct Facility: Identifiable {
    let id = UUID()
    var name: String

    var icon: some View {
        let icons = [
            "Accommodation": "house",
            "Beginners": "1.circle",
            "Cross-country": "map",
            "Eco-friendly": "leaf.arrow.circlepath",
            "Family": "person.3"
        ]

        if let iconName = icons[name] {
            let image = Image(systemName: iconName)
                            .accessibility(label: Text(name))
                            .foregroundColor(.secondary)
            return image
        } else {
            fatalError("Unknown facility type: \(name)")
        }
    }

    var alert: Alert {
        let messages = [
            "Accommodation": "This resort has popular on-site accommodation.",
            "Beginners": "This resort has lots of ski schools.",
            "Cross-country": "This resort has many cross-country ski routes.",
            "Eco-friendly": "This resort has won an award for environmental friendliness.",
            "Family": "This resort is popular with families."
        ]

        if let message = messages[name] {
            return Alert(title: Text(name), message: Text(message))
        } else {
            fatalError("Unknown facility type: \(name)")
        }
    }
}

接下来,我们将更新Resort结构体,以使其具有一个计算属性,该属性将其设施包含为一组Facility而不是String。这意味着我们仍然从JSON加载原始的字符串数组,但是已经准备好使用[Facility]替代方法。理想情况下,这将使用自定义的Codable初始值设定项来完成,但我不想再重复一遍!

因此,现在将此属性添加到Resort

var facilityTypes: [Facility] {
    facilities.map(Facility.init)
}

现在,在ResortView中,我们可以更新代码以使用Facility?而不是String?

首先,更改属性:

@State private var selectedFacility: Facility?

接下来,将ForEach更改为使用新的facilityTypes属性而不是facilities,这又意味着我们可以直接访问该图标,因为现在我们有了真正的Facility实例:

HStack {
    ForEach(resort.facilityTypes) { facility in
        facility.icon
            .font(.title)
            .onTapGesture {
                self.selectedFacility = facility
            }
    }
}
.padding(.vertical)

最后,我们可以替换alert()修饰符以使用设施警报,如下所示:

.alert(item: $selectedFacility) { facility in
    facility.alert
}

这项工作相当繁琐,但现在确实意味着我们可以删除自定义的String扩展——这是一项颇具侵略性的更改,如果Apple这么做就那么就会变得简单,我相信他们一定会使alert(item:)使用更为通用的协议,例如String已经遵守的Equatable

让用户添加收藏

该项目的最终任务是让用户使用收藏夹来保存给他们喜欢的度假村。这很简单,使用我们已经介绍的技术:

  • 创建一个具有用户喜欢的度假胜地IDSet的新的Favorites类。
  • 给它添加操作数据的add()remove()contains()方法,向SwiftUI发送更新通知,同时还将所有的更改使用UserDefaults保存。
  • 添加一些新的UI来调用适当的方法。

Swift的集合已经包含用于添加,删除和检查元素的方法,但是我们将添加自己的方法,因此我们可以使用objectWillChange通知SwiftUI发生了更改,还可以调用save()方法,使用户的更改保留。反过来,这意味着我们可以使用private访问控制来标记收藏夹集,这样我们就不会无意间绕过我们的方法而错过保存。

创建一个新的Swift文件,命名为 Favorites.swift,将其 Foundation 导入替换为SwiftUI,然后为其提供以下代码:

class Favorites: ObservableObject {
    // 用户喜欢的度假胜地集合
    private var resorts: Set

    // 我们用来在UserDefaults中读取/写入的key
    private let saveKey = "Favorites"

    init() {
        // 加载我们保存的内容

        // 暂时使用空数组
        self.resorts = []
    }

    // 判断是否包含一个度假地
    func contains(_ resort: Resort) -> Bool {
        resorts.contains(resort.id)
    }

    // 将度假地添加到我们的集合中,更新所有视图,并保存更改
    func add(_ resort: Resort) {
        objectWillChange.send()
        resorts.insert(resort.id)
        save()
    }

    // 从我们的集合中删除该度假地,更新所有视图,并保存更改
    func remove(_ resort: Resort) {
        objectWillChange.send()
        resorts.remove(resort.id)
        save()
    }

    func save() {
        // 保存数据
    }
}

您会发现我没写加载和保存收藏夹的实际功能,这是您很快就会完成的工作。

我们需要在ContentView中创建一个收藏夹实例并将其注入到环境中,以便所有视图都可以共享它。因此,将此新属性添加到ContentView

@ObservedObject var favorites = Favorites()

现在,通过将此修饰符添加到NavigationView中,将其注入到环境中:

.environmentObject(favorites)

由于该视图已附加到导航视图中,因此导航视图显示的每个视图也将获得该Favorites实例使用。因此,我们可以通过添加以下新属性从ResortView内部加载它:

@EnvironmentObject var favorites: Favorites

所有这些工作尚未真正完成——确保应用程序启动时会加载Favorites类,尽管具有存储属性,但它实际上并没有在任何地方使用。

这很容易解决:我们将在ResortView的滚动视图的末尾添加一个按钮,以便用户可以从其收藏夹中添加或删除该度假村,然后在ContentView中显示一个心形图标以显示最喜欢的度假村。

首先,将其添加到ResortView 的滚动视图末尾:

Button(favorites.contains(resort) ? "Remove from Favorites" : "Add to Favorites") {
    if self.favorites.contains(self.resort) {
        self.favorites.remove(self.resort)
    } else {
        self.favorites.add(self.resort)
    }
}
.padding()

现在,可以通过将其添加到NavigationLink的末尾,在ContentView中最喜欢的度假村旁边显示一个彩色的心形图标:

if self.favorites.contains(resort) {
    Spacer()
    Image(systemName: "heart.fill")
    .accessibility(label: Text("This is a favorite resort"))
        .foregroundColor(.red)
}

提示:正如您所看到的,因为我们的图像使用SF符号,所以foregroundColor()修饰符在这里可以很好地工作。

多数情况下这种方法可行,但是您可能会注意到一个小问题:如果您喜欢名称较长的度假村,则即使名称空间很大,它们的名称也会分成两行。这又是一个示例,其中SwiftUI的布局系统为分隔符分配了过多的优先级,而为文本分配的优先级却不够,所以现在解决此问题——希望直到苹果很快解决! ——是在我们刚刚添加条件之前直接调整VStack的布局优先级:

VStack(alignment: .leading) {
    Text(resort.name)
        .font(.headline)
    Text("\(resort.runs) runs")
        .foregroundColor(.secondary)
}
.layoutPriority(1)

即使使用间隔符和心形图标,也应该可以使文本布局正确。

这也就完成了我们的项目,因此请最后尝试一下,看看您的想法。

Good job!

译自
Changing a view’s layout in response to size classes
Binding an alert to an optional string
Letting the user mark favorites

你可能感兴趣的:(Hacking with iOS: SwiftUI Edition - SnowSeeker 项目(二))