跳转至

第25章:实现过滤器选项

到目前为止,在这一部分,你已经创建了一个快速原型,实现了Figma设计,探索了raywenderlich.comREST API,并制定了发送REST请求和解码其响应的代码。在本章中,你将复制并改编Playground的代码到你的应用程序中。然后,你将在此基础上实现所有的过滤器和选项,让你的用户自定义他们获取的剧集。你的最终结果将是一个功能齐全的应用程序,你可以用它来试听我们所有的视频课程。

开始

打开RWFreeView的启动项目。它包含了你要用来保持FilterOptionsViewHeaderView之间的过滤器按钮同步的代码。而EpisodeStore现在是一个EnvironmentObject,被ContentViewFilterOptionsViewHeaderViewSearchField使用。

Swift playground

打开Starter文件夹中的Networking playground,或者继续上一章的playground。你将把Episode Playground的代码改编为EpisodeStore.swift中的fetchContents()方法,并用新的Episode结构和扩展名替换旧的原型Episode。而且,你将为VideoURL类创建一个新的Swift文件 - VideoURL.swift,并使其符合ObservableObject

一些新的Episode属性略有不同,所以你要修正EpisodeView.swiftPlayerView.swift中出现的一些错误。

启动项目已经包含FormatterExtension.swiftURLComponentsExtension.swift

Playground到应用程序

Playground的代码足以让你的应用程序下载流行的免费剧集。你将在本章的后半部分实现查询选项和过滤器。

➤ 在项目导航器中,在预览内容组中,删除EpisodeStoreDevData.swift。你将改变Episode结构,你将从一个URLSession响应中初始化episodes数组。

➤ 在EpisodeStore.swift中,用这段代码替换init()

func fetchContents() {}

init() {
  fetchContents()
}

你将复制Playground的代码来实现fetchContents(),而init()只是调用fetchContents()

改编EpisodeStore代码

现在开始复制和改编Episode playground页面的代码到EpisodeStore.swift中。

➤ 用以下代码替换func fetchContents() {}

// 1
let baseURLString = "https://api.raywenderlich.com/api/contents"
var baseParams = [
  "filter[subscription_types][]": "free",
  "filter[content_types][]": "episode",
  "sort": "-popularity",
  "page[size]": "20",
  "filter[q]": ""
]
// 2
func fetchContents() {
  guard var urlComponents = URLComponents(string: baseURLString) 
  else { return }
  urlComponents.setQueryItems(with: baseParams)
  guard let contentsURL = urlComponents.url else { return }
}
  1. 你把baseURLStringbaseParams作为属性复制到EpisodeStore
  2. 你在fetchContents()中创建urlComponents作为一个局部变量,然后调用urlComponents.setQueryItems(with:)。现在你在一个方法中,你在guard语句中创建了urlComponentscontentsURL,这样你就可以在其中一个失败时退出。

➤ 现在将URLSession代码复制到fetchContents()中,并修改主队列上发生的事情:

URLSession.shared
  .dataTask(with: contentsURL) { data, response, error in
    // defer { PlaygroundPage.current.finishExecution() }  // 1
    if let data = data, 
      let response = response as? HTTPURLResponse {
      print(response.statusCode)
      if let decodedResponse = try? JSONDecoder().decode(  // 2
        EpisodeStore.self, from: data) {
        DispatchQueue.main.async {
          self.episodes = decodedResponse.episodes  // 3
        }
        return
      }
    }
    print(
      "Contents fetch failed: " +
        "\(error?.localizedDescription ?? "Unknown error")")
  }
  .resume()
  1. 删除PlaygroundPage这行代码。反正它不会在这里工作。
  2. 你创建一个默认的JSONDecoder。你不需要配置它,因为你将提供一个自定义的init(from:)
  3. DispatchQueue.main闭包中,你从解码后的响应中设置episodes

Xcode抱怨说EpisodeStore不符合Decodable,所以现在开始修复。

➤ 在EpisodeStoreinit()方法下面添加这段代码:

// 1
enum CodingKeys: String, CodingKey {
  case episodes = "data"   // array of dictionary
}
// 2
init(from decoder: Decoder) throws {
  let container = try decoder.container(
    keyedBy: CodingKeys.self)
  episodes = try container.decode(
    [Episode].self, forKey: .episodes)
}
  1. 在下一步,你将声明EpisodeStoreDecodable,所以你从Playground复制CodingKeys来满足该协议。
  2. 在你的应用程序中,EpisodeStore发布了一个数组和两个字典。Published属性不是Decodable,所以你必须明确解码其中的至少一个,以符合Decodable

➤ 向上滚动并将class EpisodeStore行替换为这个:

final class EpisodeStore: ObservableObject, Decodable {

当你添加init(from:)初始化器时,Xcode的错误可能会告诉你把它标记为required。这个关键字表示每个EpisodeStore的子类都必须实现这个初始化器。你不会对EpisodeStore进行子类化,所以你对该类应用final关键字以明确这一事实。这就摆脱了错误信息。

在添加了CodingKeysinit(from:)之后,你现在可以声明EpisodeStore符合Decodable的规定。

复制Episode的代码

现在,Decodable的问题转移到Episode,所以你接下来要解决这个问题。你需要的代码已经在Playground上了。它有很多,所以你要把它放在自己的文件里。

➤ 删除EpisodeStore.swift中的Episode,然后创建一个新的Swift文件,名为Episode.swift,其中包含struct Episodeextension Episode的游戏场代码。

而现在你需要提供VideoURL类。

复制VideoURLVideoURLString代码

➤ 创建一个名为VideoURL.swift的新的Swift文件,并在其中添加这段代码:

class VideoURL: ObservableObject {
  @Published var urlString = ""
}

在你的应用程序中,VideoURL不是Playground中简单的classvar,而是一个ObservableObject。它发布的是urlString,因为在初始化一个VideoURL对象和给urlString分配一个非空值之间有一个网络延迟。

➤ 在VideoURL下面,复制VideoURLString和它的扩展,以及VideoAttributesPlayground上。

➤ 现在,将playground中的init(videoId:)方法复制到VideoURL中,并修改dataTask的完成处理:

init(videoId: Int) {
  let baseURLString = 
    "https://api.raywenderlich.com/api/videos/"
  let queryURLString = 
    baseURLString + String(videoId) + "/stream"
  guard let queryURL = URL(string: queryURLString) 
  else { return }
  URLSession.shared
    .dataTask(with: queryURL) { data, response, error in
      if let data = data, 
        let response = response as? HTTPURLResponse {
        // 1
        if response.statusCode != 200 {
          print("\(videoId) \(response.statusCode)")
          return
        }
        if let decodedResponse = try? JSONDecoder().decode(
          VideoURLString.self, from: data) {
          // 2
          self.urlString = decodedResponse.urlString
        }
      } else {
        print(
          "Videos fetch failed: " +
            "\(error?.localizedDescription ?? "Unknown error")")
      }
    }
    .resume()
}
  1. 为了减少调试信息的数量,你只打印状态代码,如果它不是200 OK。如果一个项目没有视频URL,状态代码是404 Not found。由于没有数据可以解码,你退出,留下urlString的值""
  2. 你不需要打印urlString

使用改变了的Episode属性

difficulty属性现在是可选的,所以应用程序将不会被编译。如果Xcode还没有抱怨,按Command-B来构建应用程序,错误标志会出现。两个错误是关于这行代码的。

Text(String(episode.difficulty).capitalized)

它出现在EpisodeView.swiftPlayerView.swift中。

➤ 在EpisodeView.swift中,单击红色的错误按钮以查看Xcode的建议,并选择第一个修正"使用??凝聚..."。

Xcode suggests fixes for optional.

➤ 使用""作为默认值:

Text(String(episode.difficulty ?? "").capitalized)

➤ 做同样的事情来修复PlayerView.swift中的错误。

另一个错误出现在PlayerView.swiftbody的第一行:

if let url = URL(string: episode.videoURLString) {

➤ 第22章"列表与导航"中简单的Episode结构中的videoURLString现在是videoURL?.urlString。这是一个可选项,所以用同样的凝聚技巧替换这一行:

if let url = URL(string: episode.videoURL?.urlString ?? "") {

用断点进行调试

你的应用程序已经准备好了!

➤ 构建并运行。如果它在模拟器中运行得很慢,就把它安装在一个iOS设备上。然后,向下滚动并检查"介绍"一节:

Notice something odd about these Introduction episodes?

它们都是一样的! 点击它们来确定:是的,视频也都是一样的。

如果你在RESTed中发送相同的请求,你会看到相同数量的介绍剧集,但它们都是不同的。那么,你怎样才能看到你的应用程序中发生了什么?

分割点来拯救你! 在第9章"保存历史数据"中,你学会了如何在你想暂停执行的代码行上插入一个断点,同时检查当前的数值。这一次,你只需在该行每次执行时打印出数值,而无需暂停应用程序。

在这种情况下,看到每个解码的介绍情节的videoIdentifierdescription值很有用。

➤ 在Episode.swift中,在extension Episode中,给init(from:)中的self.id = id一行添加一个断点,然后右击蓝色断点箭头,选择编辑断点....

Breakpoint window

➤ 在断点窗口中,单击"添加动作"并将动作设置为"记录消息"。在条件字段中,输入name == "Introduction",在消息字段中,输入@videoIdentifier@ @description@。最后,勾选评估动作后自动继续的方框。

Edit breakpoint to show Introduction details.

➤ 建立和运行。

Breakpoint messages

调试控制台显示几集的videoIdentifierdescription,它们都不同。所以,服务器的响应或你的应用程序的解码没有问题。

请注意,第一个介绍情节是在运行的应用程序中被重复的情节。

➤ 单击中断点箭头以禁用它。

你的应用程序将几个不同的介绍情节解码到你的episodes数组中,但只显示第一个情节,而且一次又一次。这是ContentView.swift中循环的工作,所以这是寻找问题的下一个地方。

ForEach(store.episodes, id: \.name) { episode in

哦! id: \.name意味着每一集有相同的name就是同一集,所以第一个介绍集就是这集。

很容易忘记,但也很容易解决。 :]

➤ 在ContentView.swift中,删除id: \.name参数从ForEach中删除。

ForEach(store.episodes) { episode in

Episode现在有一个id属性,ForEachList默认使用这个属性,除非你为id参数指定其他值。这个id属性对每个情节都是不同的,即使它们有相同的name

➤ 构建和运行。

RWFreeView running!

好多了!

改善用户体验

祝贺你,你的应用程序正在运行! 现在你可以寻找机会来改善你的用户体验。你的应用程序应该使他们能够完成任务和实现目标,而不会出现混乱或中断。你不希望用户抓耳挠腮,不知道发生了什么或下一步该怎么做。

挑战:显示parentName

➤ 再看一下那些介绍集。即使用户阅读了描述,它也不一定能告诉他们足够的信息来决定是否播放视频。有时,也会有几个结论集。你能为这些情节添加更多信息吗?

raywenderlich.comAPI中,attributesparent_name告诉你一集所处的课程。你可以通过给Episode添加parentName属性来改善你的用户体验,然后在name"Introduction""Conclusion"时显示它。

在阅读我下面的步骤列表或看最终的项目之前,自己先试试这个练习。

➤ 在Episode.swift中,在Episode中,添加parentName: String?到属性列表中。parent_namenull,这很罕见,但也是可能的。

➤ 将case parentName = "parent_name"添加到AttrsKeys

➤ 用let parentName = try attrs.decode(String?.self, forKey: .parentName)对它进行解码。

➤ 还是在init(from:)中,用解码后的值设置属性self.parentName = parentName

➤ 在EpisodeView.swift中,将parentName显示在name下面:

if episode.name == "Introduction" ||
  episode.name == "Conclusion" {
  Text(episode.parentName ?? "")
    .font(.subheadline)
    .foregroundColor(Color(UIColor.label))
    .padding(.top, -5.0)
}

负的填充物把它挤得更靠近剧集名称。

➤ 构建并运行,然后向下滚动,看到介绍剧集现在显示更多信息:

Introduction episodes display parent name.

显示活动

dataTask运行时,列表是空白的。用户希望看到一个活动指示器,直到列表出现。

ActivityIndicator.swift 包含Sarah文章bit.ly/3cVlzif中的旋转式活动指示器,经过修改后,可以使用你的应用程序的渐变颜色。

➤ 在EpisodeStore.swift中,添加此属性以控制旋转器是否出现:

@Published var loading = false

➤ 在fetchContents()中,在URLSession代码前添加这一行:

loading = true

➤ 在dataTask处理程序中,在开头添加这个,在那里你有defer闭包来完成游戏的执行:

defer {
  DispatchQueue.main.async {
    self.loading = false
  }
}

你在启动dataTask之前将loading设置为true,在接收和解码网络响应后将其设置为false。使用defer块可以确保你在成功和失败的情况下都隐藏活动指示器。

现在你已经在所有必要的地方设置了loading的值,你将使用其值来显示或隐藏ActivityIndicator()

➤ 在ContentView.swift中,在HeaderView(count:)后面添加这一行:

if store.loading { ActivityIndicator() }

➤ 建立并运行,以看到你的旋转器。

Spinner activity indicator

它看起来相当酷! 在你实现了所有的查询选项后,你会让列表在加载新剧集时做一些更酷的事情。

如果没有视频怎么办?

在写这一章时,有时在内容查询结果中会出现一个或多个占位符剧集。这些没有视频,所以PlayerView是空白的--不是一个好的用户体验。我创建了一个PlaceholderView,在没有视频URL时显示。

事实证明,这些占位符不应该包括在结果中,现在已经被删除。但是你仍然应该检查视频的URL。例如,你可能决定允许没有视频的非剧集内容类型(但忘记提供一个适当的查看器)。

PlayerView.swift中,如果没有视频URL,就会显示一个"没有视频"的消息。

➤ 在PlayerView.swift中,点击GeometryReader旁边的沟槽来折叠它,这样你就可以看到if let url闭合的地方了:

Fold GeometryReader to see closing } of if.

➤ 用这个else闭包替换if闭包的闭包:

} else {
  PlaceholderView()
}

为了测试这一点,你需要临时改变content_types的值。

➤ 在EpisodeStore.swift中,在baseParams中,将"episode"改为"article"

"filter[content_types][]": "article"

➤ 建立和运行,然后点选任何项目:

Articles don’t have videos.

➤ 在EpisodeStore.swift中,在baseParams中,将"article"改回"episode"

"filter[content_types][]": "episode"

好了,你的应用程序的基本下载功能运行良好,提供了良好的用户体验。现在,是时候实现所有这些选项和过滤器了,所以你的用户可以自定义他们的查询结果。

实现HeaderView选项

HeaderView为用户提供了这些选项来定制下载的内容:

  • 清除一些或所有的过滤选项。
  • 输入一个搜索词。
  • 改变页面大小。
  • 在热门和最新之间切换排序。

你将在接下来的两节中管理这些过滤选项。

在本节中,你将实现最后三个动作,它们对应于EpisodeStorebaseParams字典中的最后三个键:

var baseParams = [
  "filter[subscription_types][]": "free",
  "filter[content_types][]": "episode",
  "sort": "-popularity",
  "page[size]": "20",
  "filter[q]": ""
]

对于这三个用户动作中的每一个,你都要写代码来改变相应的值并发送一个新的请求。

输入一个搜索词

HeaderView.swift中,将此属性添加到SearchField中:

@EnvironmentObject var store: EpisodeStore

你将把用户的搜索词传递给EpisodeStore中的baseParams字典。

➤ 在body中,将TextField("", text: $queryTerm)替换为以下内容:

TextField(
  "",
  text: $queryTerm,
  onEditingChanged: { _ in },
  onCommit: {
    store.baseParams["filter[q]"] = queryTerm
    store.fetchContents()
  }
)

当用户点击键盘上的返回键时,onCommit代码就会运行。您将查询过滤器的值设置为用户的搜索词,然后调用 fetchContents()

➤ 构建并运行,然后输入一个搜索词,如地图:

Search for episodes about map

你可以看出它的作用。剧集名称已经改变,一些描述提到了MapKitmap,而且只有8集,而不是20集。

改变页面大小

接下来,实现页面大小菜单。

➤ 向上滚动到页面大小的菜单按钮并添加动作:

Button("10 results/page") {
  store.baseParams["page[size]"] = "10"
  store.fetchContents()
}
Button("20 results/page") {
  store.baseParams["page[size]"] = "20"
  store.fetchContents()
}
Button("30 results/page") {
  store.baseParams["page[size]"] = "30"
  store.fetchContents()
}
Button("No change") { }

根据选定的按钮,设置页面大小键的值,然后调用fetchContents)

➤ 构建和运行,然后选择每页10个或30个结果:

Change the page size

我在菜单中包括了10个结果/页,因为很容易数到10,看它是否工作。]

切换排序顺序

现在,让排序顺序控制工作起来。

➤ 在HeaderView.swift中,改变sortOn的初始值:

@State private var sortOn = "none"

这个值与两个段的tag不匹配,所以在用户点击一个段之前,两个段都不会显示为选中。

➤ 将此修改器添加到 Picker("", selection: $sortOn)

.onChange(of: sortOn) { _ in
  store.baseParams["sort"] = sortOn == "new" ?
    "-released_at" : "-popularity"
  store.fetchContents()
}

sortOn值发生变化时,你要为"sort"键设置baseParams值,然后调用fetchContents()

➤ 建立并运行。注意第一个项目的日期是2019年9月,然后在选取器中选择新:

Sort by release date

现在这些项目都有最近的发布日期。

你已经实现了所有的HeaderView选项,除了清除查询过滤器。在你清除查询过滤器之前,你需要一种方法将它们添加到HeaderView中。所以首先,你要在FilterOptionsView中实现查询过滤器。

FilterOptionsView中实现过滤器

FilterOptionsView中,用户可以选择或取消选择过滤器选项,然后点击ApplyX,将选择的选项合并到一个新的请求中。

有两种类型的查询过滤器。平台(在API中称为域)和难度。用户可以在每种类型中选择一个或多个--Android & KotlinFlutter,初级和中级--所以你不能把他们的选择存储在像baseParams那样的字典里,其中每个键是唯一的查询参数名称。

查询过滤器字典

为了跟踪选定的查询过滤器选项,启动项目在EpisodeStore.swift中包含两个查询过滤器字典,其中的键是查询参数名称filter[domain_ids][]filter[difficulty][]的可能值。

@Published var domainFilters: [String: Bool] = [
  "1": true,  
  "2": false,  
  "3": false,  
  "5": false,  
  "8": false,  
  "9": false  
]
@Published var difficultyFilters: [String: Bool] = [
  "advanced": false,  
  "beginner": true,  
  "intermediate": false  
]

如果用户选择了与该键匹配的查询过滤器,则查询过滤器的字典值为true。在启动项目中,iOS & Swift领域和初学者难度已经被选中,但尚未实施。

点击FilterOptionsView中的一个查询过滤器按钮,可以切换其在这些查询过滤器字典中的值。比如说:

Button("iOS & Swift") { store.domainFilters["1"]!.toggle() }

这个值也可以切换FilterOptionsView中查询过滤器按钮的颜色:true时为绿色,false时为灰色。

.buttonStyle(
  FilterButtonStyle(
    selected: store.domainFilters["1"]!, width: nil))

当你的用户点击查询过滤器按钮进行选择,然后点击X或应用按钮时,你的代码需要这样做。

➤ 在FilterOptionsView.swift中,在xmarkApply按钮的动作中添加这一行,在驳回此表的那一行之前:

store.fetchContents()

这就是全部! 你很快就会更新fetchContents(),把用户的所有选择合并到一个查询网址中。

清除所有的查询过滤器

FilterOptionsView中,用户可能会点击清除所有。这个动作不应该解散表单或调用fetchContents(),以防用户只是想开始一个新的选择。

➤ 在FilterOptionsView.swift中,设置Clear All按钮的动作:

store.clearQueryFilters()

➤ 而在EpisodeStore.swift中,将此方法添加到EpisodeStore中:

func clearQueryFilters() {
  domainFilters.keys.forEach { domainFilters[$0] = false }
  difficultyFilters.keys.forEach { 
    difficultyFilters[$0] = false 
  }
}

你只需要在两个查询过滤器字典中把所有的值设置为false。你创建了一个方法来做这件事,因为你也会在HeaderView中调用它。

➤ 建立并运行,显示过滤器选项视图,然后选择一些查询过滤器。这些按钮变成绿色。现在点清除全部,看到它们变成灰色。

Clear all query filters

筛选和映射查询过滤器

点击应用还不会改变你的结果。你必须将相应的查询项目添加到你的contentsURL中。

➤ 还是在EpisodeStore.swift中,用这段代码替换fetchContents()中的guard let contentsURL行:

let selectedDomains = domainFilters.filter {
  $0.value
}
.keys
let domainQueryItems = selectedDomains.map {
  queryDomain($0)
}

let selectedDifficulties = difficultyFilters.filter {
  $0.value
}
.keys
let difficultyQueryItems = selectedDifficulties.map {
  queryDifficulty($0)
}

urlComponents.queryItems! += domainQueryItems
urlComponents.queryItems! += difficultyQueryItems
guard let contentsURL = urlComponents.url else { return }
print(contentsURL)

你对domainFilters键的过滤值为true,产生一个域名键的集合。然后你对每个键调用queryDomain(_:)方法,产生一个URLQueryItem数组。你对difficultyFilters做同样的事情,也产生一个URLQueryItem的数组。然后将每个数组追加到urlComponents.queryItems中,以创建你的内容查询URL。

➤ 建立并运行。注意所有的结果都是iOSSwift初学者的,尽管标题视图没有显示这些按钮。

➤ 显示过滤器选项表,选择或取消选择一些查询过滤器。点应用或xmark

Apply filters

这些过滤按钮已经工作了。现在要让HeaderView的按钮同步起来。

HeaderView中实现查询过滤器

当用户在FilterOptionsView中选择查询过滤器时,它们的按钮应该出现在HeaderView中。如果用户点击HeaderView中的一个按钮,它应该取消选择该查询过滤器并发送一个新的请求。

清除HeaderView中的所有

在设置这些查询过滤器按钮之前,实现清除所有按钮,以清除查询过滤器和搜索词。

➤ 在HeaderView.swift中,添加此代码作为清除所有按钮的动作:

queryTerm = ""
store.baseParams["filter[q]"] = queryTerm
store.clearQueryFilters()
store.fetchContents()

你清空搜索的TextField值,并将查询参数的值设置为这个空字符串。然后,你清除域和难度查询过滤器,并调用fetchContents()

显示查询过滤器的按钮

这个显示比FilterOptionsView更棘手,因为按钮的数量是可变的。幸运的是,正如你在第16章"向你的应用程序添加资产"中所学到的,SwiftUI现在有懒惰的网格。

➤ 首先,设置一个三栏式布局。将此属性添加到 HeaderView中:

let threeColumns = [
  GridItem(.flexible(minimum: 55)),
  GridItem(.flexible(minimum: 55)),
  GridItem(.flexible(minimum: 55))
]

➤ 清除所有是这个网格中的一个按钮,所以用下面的内容替换其包围的HStack

HStack {
  LazyVGrid(columns: threeColumns) {  // 1
    Button("Clear all") {
      queryTerm = ""
      store.baseParams["filter[q]"] = queryTerm
      store.clearQueryFilters()
      store.fetchContents()
    }
    .buttonStyle(HeaderButtonStyle())
    ForEach(
      Array(
        store.domainFilters.merging(  // 2
          store.difficultyFilters) { _, second in second
        }
        .filter {  // 3
          $0.value
        }
        .keys), id: \.self) { key in
      Button(store.filtersDictionary[key]!) {  // 4
        if Int(key) == nil {  // 5
          store.difficultyFilters[key]!.toggle()
        } else {
          store.domainFilters[key]!.toggle()
        }
        store.fetchContents()  // 6
      }
      .buttonStyle(HeaderButtonStyle())
    }
  }
  Spacer()
}
  1. 一个LazyVGrid在水平方向上逐行填入项目。第一个按钮总是清除所有。
  2. 字典方法merging将两个查询过滤器的字典合并成一个新的、临时的字典。你指定_, second in second来解决任何键的冲突,有利于第二个字典。(你知道不会有任何键的冲突,但是Xcode不知道。)为了使用ForEach,你从产生的键的集合中创建一个Array
  3. 你用true的值来过滤查询过滤器的键,就像在fetchContents()中一样。
  4. 对于每个选定的键,你创建一个Button。为了显示正确的标签,filtersDictionaryEpisode.domainDictionary加上困难项目。
  5. 你可以从domainFilters键创建一个Int,但不能从difficultyFilters键创建,所以这个测试告诉你要更新哪个查询过滤器字典。
  6. 每个按钮都调用fetchContents()来发送新的请求。

➤ 建立并运行。现在,标题视图显示初学者和iOS & Swift的按钮,这些与FilterOptionsView中的绿色按钮相匹配:

Filter buttons in HeaderView

➤ 关闭过滤器选项表。在标题中,点击iOSSwift

Deselecting filter in HeaderView

现在你也可以得到AndroidKotlin的初级剧集。而且FilterOptionsView中的iOSSwift按钮现在是灰色的。

最后一件事...

每当用户改变一个查询过滤器或选项时,你的活动旋钮就会出现。之前的列表会一直存在,直到旋转器停止。相反,为什么不显示编辑过的项目呢?

➤ 在ContentView.swift中,编辑显示ActivityIndicator()的条件:

if store.loading && store.episodes.isEmpty { 
  ActivityIndicator() 
}

当应用程序启动时,store.episodes是空的,同时应用程序对初始请求数据进行解码。在初始下载后,有可能选择返回0集的过滤器选项,所以你在条件中保留store.loading,即使没有任何集数显示,也要停止旋转。

当你呈现FilterOptionsView()时,也要添加这个修改器:

.environmentObject(store)

你明确地将环境对象传递给模态表。当你把一个视图作为模态表呈现时,它实际上不在ContentView的视图树中。尽管这样,FilterOptionsView工作得很好,直到它不工作。有可能为运行时错误创造条件,抱怨模态视图没有来自祖先视图的环境对象。应用程序就会崩溃。明确地传递 store 可以防止这个问题。

➤ 然后用这个来修改ForEach闭包:

.redacted(reason: store.loading ? .placeholder : [])

当你的应用程序将响应数据解码到episodes数组时,你为每个项目显示一个占位符视图。这将文本替换为相同大小和颜色的圆角矩形。当加载完成后,你将删除编辑的reason,所以你的项目会正常出现。

作为最后一步,确保每个图标的PlayButtonIcon没有被编辑。

➤ 在EpisodeView.swift中,向PlayButtonIcon添加unredacted修改器:

PlayButtonIcon(width: 40, height: 40, radius: 6)
  .unredacted()

➤ 构建并运行,等待列表加载。然后,改变任何查询选项,以看到你的编辑过的占位符:

Redacted placeholder views

它看起来很专业!

你的RWFreeView现在是一个全功能的真实的应用程序。把它安装在你的iOS设备上,享受探索我们所有的免费剧集。写这一章的乐趣之一是多次观看Ray"SwiftUI vs. UIKit"视频--当然,只是为了确保应用程序仍在运行。 ;] 再看看下一章(最后一章),创建一个RWFreeView小部件。

关键点

  • Published属性不是Decodable,所以你必须明确地解码其中的至少一个,以使ObservableObject符合Decodable
  • 在给一行代码添加断点后,你可以编辑它以打印出数值,而不需要在该行每次执行时暂停应用程序。
  • 记住要让ForEachList使用Identifiable类型的id属性。
  • 寻找机会来改善你的用户体验,并阻止"啊?"的时刻。