第25章:实现过滤器选项¶
到目前为止,在这一部分,你已经创建了一个快速原型,实现了Figma
设计,探索了raywenderlich.com
的REST API
,并制定了发送REST
请求和解码其响应的代码。在本章中,你将复制并改编Playground
的代码到你的应用程序中。然后,你将在此基础上实现所有的过滤器和选项,让你的用户自定义他们获取的剧集。你的最终结果将是一个功能齐全的应用程序,你可以用它来试听我们所有的视频课程。
开始¶
打开RWFreeView
的启动项目。它包含了你要用来保持FilterOptionsView
和HeaderView
之间的过滤器按钮同步的代码。而EpisodeStore
现在是一个EnvironmentObject
,被ContentView
、FilterOptionsView
、HeaderView
和SearchField
使用。
Swift playground
¶
打开Starter
文件夹中的Networking playground
,或者继续上一章的playground
。你将把Episode Playground
的代码改编为EpisodeStore.swift
中的fetchContents()
方法,并用新的Episode
结构和扩展名替换旧的原型Episode
。而且,你将为VideoURL
类创建一个新的Swift
文件 - VideoURL.swift
,并使其符合ObservableObject
。
一些新的Episode
属性略有不同,所以你要修正EpisodeView.swift
和PlayerView.swift
中出现的一些错误。
启动项目已经包含FormatterExtension.swift
和URLComponentsExtension.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 }
}
- 你把
baseURLString
和baseParams
作为属性复制到EpisodeStore
。 - 你在
fetchContents()
中创建urlComponents
作为一个局部变量,然后调用urlComponents.setQueryItems(with:)
。现在你在一个方法中,你在guard
语句中创建了urlComponents
和contentsURL
,这样你就可以在其中一个失败时退出。
➤ 现在将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()
- 删除
PlaygroundPage
这行代码。反正它不会在这里工作。 - 你创建一个默认的
JSONDecoder
。你不需要配置它,因为你将提供一个自定义的init(from:)
。 - 在
DispatchQueue.main
闭包中,你从解码后的响应中设置episodes
。
Xcode
抱怨说EpisodeStore
不符合Decodable
,所以现在开始修复。
➤ 在EpisodeStore
的init()
方法下面添加这段代码:
// 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)
}
- 在下一步,你将声明
EpisodeStore
是Decodable
,所以你从Playground
复制CodingKeys
来满足该协议。 - 在你的应用程序中,
EpisodeStore
发布了一个数组和两个字典。Published
属性不是Decodable
,所以你必须明确解码其中的至少一个,以符合Decodable
。
➤ 向上滚动并将class EpisodeStore
行替换为这个:
final class EpisodeStore: ObservableObject, Decodable {
当你添加init(from:)
初始化器时,Xcode
的错误可能会告诉你把它标记为required
。这个关键字表示每个EpisodeStore
的子类都必须实现这个初始化器。你不会对EpisodeStore
进行子类化,所以你对该类应用final
关键字以明确这一事实。这就摆脱了错误信息。
在添加了CodingKeys
和init(from:)
之后,你现在可以声明EpisodeStore
符合Decodable
的规定。
复制Episode
的代码¶
现在,Decodable
的问题转移到Episode
,所以你接下来要解决这个问题。你需要的代码已经在Playground
上了。它有很多,所以你要把它放在自己的文件里。
➤ 删除EpisodeStore.swift
中的Episode
,然后创建一个新的Swift
文件,名为Episode.swift
,其中包含struct Episode
和extension Episode
的游戏场代码。
而现在你需要提供VideoURL
类。
复制VideoURL
和VideoURLString
代码¶
➤ 创建一个名为VideoURL.swift
的新的Swift
文件,并在其中添加这段代码:
class VideoURL: ObservableObject {
@Published var urlString = ""
}
在你的应用程序中,VideoURL
不是Playground
中简单的class
和var
,而是一个ObservableObject
。它发布的是urlString
,因为在初始化一个VideoURL
对象和给urlString
分配一个非空值之间有一个网络延迟。
➤ 在VideoURL
下面,复制VideoURLString
和它的扩展,以及VideoAttributes
从Playground
上。
➤ 现在,将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()
}
- 为了减少调试信息的数量,你只打印状态代码,如果它不是
200 OK
。如果一个项目没有视频URL
,状态代码是404 Not found
。由于没有数据可以解码,你退出,留下urlString
的值""
。 - 你不需要打印
urlString
。
使用改变了的Episode
属性¶
difficulty
属性现在是可选的,所以应用程序将不会被编译。如果Xcode
还没有抱怨,按Command-B
来构建应用程序,错误标志会出现。两个错误是关于这行代码的。
Text(String(episode.difficulty).capitalized)
它出现在EpisodeView.swift
和PlayerView.swift
中。
➤ 在EpisodeView.swift
中,单击红色的错误按钮以查看Xcode
的建议,并选择第一个修正"使用??
凝聚..."。
➤ 使用""
作为默认值:
Text(String(episode.difficulty ?? "").capitalized)
➤ 做同样的事情来修复PlayerView.swift
中的错误。
另一个错误出现在PlayerView.swift
中body
的第一行:
if let url = URL(string: episode.videoURLString) {
➤ 第22章"列表与导航"中简单的Episode
结构中的videoURLString
现在是videoURL?.urlString
。这是一个可选项,所以用同样的凝聚技巧替换这一行:
if let url = URL(string: episode.videoURL?.urlString ?? "") {
用断点进行调试¶
你的应用程序已经准备好了!
➤ 构建并运行。如果它在模拟器中运行得很慢,就把它安装在一个iOS
设备上。然后,向下滚动并检查"介绍"一节:
它们都是一样的! 点击它们来确定:是的,视频也都是一样的。
如果你在RESTed
中发送相同的请求,你会看到相同数量的介绍剧集,但它们都是不同的。那么,你怎样才能看到你的应用程序中发生了什么?
分割点来拯救你! 在第9章"保存历史数据"中,你学会了如何在你想暂停执行的代码行上插入一个断点,同时检查当前的数值。这一次,你只需在该行每次执行时打印出数值,而无需暂停应用程序。
在这种情况下,看到每个解码的介绍情节的videoIdentifier
和description
值很有用。
➤ 在Episode.swift
中,在extension Episode
中,给init(from:)
中的self.id = id
一行添加一个断点,然后右击蓝色断点箭头,选择编辑断点....
➤ 在断点窗口中,单击"添加动作"并将动作设置为"记录消息"。在条件字段中,输入name == "Introduction"
,在消息字段中,输入@videoIdentifier@ @description@
。最后,勾选评估动作后自动继续的方框。
➤ 建立和运行。
调试控制台显示几集的videoIdentifier
和description
,它们都不同。所以,服务器的响应或你的应用程序的解码没有问题。
请注意,第一个介绍情节是在运行的应用程序中被重复的情节。
➤ 单击中断点箭头以禁用它。
你的应用程序将几个不同的介绍情节解码到你的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
属性,ForEach
和List
默认使用这个属性,除非你为id
参数指定其他值。这个id
属性对每个情节都是不同的,即使它们有相同的name
。
➤ 构建和运行。
好多了!
改善用户体验¶
祝贺你,你的应用程序正在运行! 现在你可以寻找机会来改善你的用户体验。你的应用程序应该使他们能够完成任务和实现目标,而不会出现混乱或中断。你不希望用户抓耳挠腮,不知道发生了什么或下一步该怎么做。
挑战:显示parentName
。¶
➤ 再看一下那些介绍集。即使用户阅读了描述,它也不一定能告诉他们足够的信息来决定是否播放视频。有时,也会有几个结论集。你能为这些情节添加更多信息吗?
在raywenderlich.com
的API
中,attributes
键parent_name
告诉你一集所处的课程。你可以通过给Episode
添加parentName
属性来改善你的用户体验,然后在name
为"Introduction"
或"Conclusion"
时显示它。
在阅读我下面的步骤列表或看最终的项目之前,自己先试试这个练习。
➤ 在Episode.swift
中,在Episode
中,添加parentName: String?
到属性列表中。parent_name
是null
,这很罕见,但也是可能的。
➤ 将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)
}
负的填充物把它挤得更靠近剧集名称。
➤ 构建并运行,然后向下滚动,看到介绍剧集现在显示更多信息:
显示活动¶
当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() }
➤ 建立并运行,以看到你的旋转器。
它看起来相当酷! 在你实现了所有的查询选项后,你会让列表在加载新剧集时做一些更酷的事情。
如果没有视频怎么办?¶
在写这一章时,有时在内容查询结果中会出现一个或多个占位符剧集。这些没有视频,所以PlayerView
是空白的--不是一个好的用户体验。我创建了一个PlaceholderView
,在没有视频URL时显示。
事实证明,这些占位符不应该包括在结果中,现在已经被删除。但是你仍然应该检查视频的URL
。例如,你可能决定允许没有视频的非剧集内容类型(但忘记提供一个适当的查看器)。
在PlayerView.swift
中,如果没有视频URL
,就会显示一个"没有视频"的消息。
➤ 在PlayerView.swift
中,点击GeometryReader
旁边的沟槽来折叠它,这样你就可以看到if let url
闭合的地方了:
➤ 用这个else
闭包替换if
闭包的闭包:
} else {
PlaceholderView()
}
为了测试这一点,你需要临时改变content_types
的值。
➤ 在EpisodeStore.swift
中,在baseParams
中,将"episode"
改为"article"
:
"filter[content_types][]": "article"
➤ 建立和运行,然后点选任何项目:
➤ 在EpisodeStore.swift
中,在baseParams
中,将"article"
改回"episode"
:
"filter[content_types][]": "episode"
好了,你的应用程序的基本下载功能运行良好,提供了良好的用户体验。现在,是时候实现所有这些选项和过滤器了,所以你的用户可以自定义他们的查询结果。
实现HeaderView
选项¶
HeaderView
为用户提供了这些选项来定制下载的内容:
- 清除一些或所有的过滤选项。
- 输入一个搜索词。
- 改变页面大小。
- 在热门和最新之间切换排序。
你将在接下来的两节中管理这些过滤选项。
在本节中,你将实现最后三个动作,它们对应于EpisodeStore
中baseParams
字典中的最后三个键:
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()
。
➤ 构建并运行,然后输入一个搜索词,如地图:
你可以看出它的作用。剧集名称已经改变,一些描述提到了MapKit
或map
,而且只有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
个结果:
我在菜单中包括了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月,然后在选取器中选择新:
现在这些项目都有最近的发布日期。
你已经实现了所有的HeaderView
选项,除了清除查询过滤器。在你清除查询过滤器之前,你需要一种方法将它们添加到HeaderView
中。所以首先,你要在FilterOptionsView
中实现查询过滤器。
在FilterOptionsView
中实现过滤器¶
在FilterOptionsView
中,用户可以选择或取消选择过滤器选项,然后点击Apply
或X
,将选择的选项合并到一个新的请求中。
有两种类型的查询过滤器。平台(在API中称为域)和难度。用户可以在每种类型中选择一个或多个--Android & Kotlin
和Flutter
,初级和中级--所以你不能把他们的选择存储在像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
中,在xmark
和Apply
按钮的动作中添加这一行,在驳回此表的那一行之前:
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
中调用它。
➤ 建立并运行,显示过滤器选项视图,然后选择一些查询过滤器。这些按钮变成绿色。现在点清除全部,看到它们变成灰色。
筛选和映射查询过滤器¶
点击应用还不会改变你的结果。你必须将相应的查询项目添加到你的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。
➤ 建立并运行。注意所有的结果都是iOS
和Swift
初学者的,尽管标题视图没有显示这些按钮。
➤ 显示过滤器选项表,选择或取消选择一些查询过滤器。点应用或xmark
:
这些过滤按钮已经工作了。现在要让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()
}
- 一个
LazyVGrid
在水平方向上逐行填入项目。第一个按钮总是清除所有。 - 字典方法
merging
将两个查询过滤器的字典合并成一个新的、临时的字典。你指定_, second in second
来解决任何键的冲突,有利于第二个字典。(你知道不会有任何键的冲突,但是Xcode
不知道。)为了使用ForEach
,你从产生的键的集合中创建一个Array
。 - 你用
true
的值来过滤查询过滤器的键,就像在fetchContents()
中一样。 - 对于每个选定的键,你创建一个
Button
。为了显示正确的标签,filtersDictionary
是Episode.domainDictionary
加上困难项目。 - 你可以从
domainFilters
键创建一个Int
,但不能从difficultyFilters
键创建,所以这个测试告诉你要更新哪个查询过滤器字典。 - 每个按钮都调用
fetchContents()
来发送新的请求。
➤ 建立并运行。现在,标题视图显示初学者和iOS & Swift
的按钮,这些与FilterOptionsView
中的绿色按钮相匹配:
➤ 关闭过滤器选项表。在标题中,点击iOS
和Swift
:
现在你也可以得到Android
和Kotlin
的初级剧集。而且FilterOptionsView
中的iOS
和Swift
按钮现在是灰色的。
最后一件事...¶
每当用户改变一个查询过滤器或选项时,你的活动旋钮就会出现。之前的列表会一直存在,直到旋转器停止。相反,为什么不显示编辑过的项目呢?
➤ 在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()
➤ 构建并运行,等待列表加载。然后,改变任何查询选项,以看到你的编辑过的占位符:
它看起来很专业!
你的RWFreeView
现在是一个全功能的真实的应用程序。把它安装在你的iOS
设备上,享受探索我们所有的免费剧集。写这一章的乐趣之一是多次观看Ray
的"SwiftUI vs. UIKit"
视频--当然,只是为了确保应用程序仍在运行。 ;] 再看看下一章(最后一章),创建一个RWFreeView
小部件。
关键点¶
Published
属性不是Decodable
,所以你必须明确地解码其中的至少一个,以使ObservableObject
符合Decodable
。- 在给一行代码添加断点后,你可以编辑它以打印出数值,而不需要在该行每次执行时暂停应用程序。
- 记住要让
ForEach
和List
使用Identifiable
类型的id
属性。 - 寻找机会来改善你的用户体验,并阻止"啊?"的时刻。