25.
Implementing Filter Options
Written by Audrey Tam
So far in this part, you’ve created a quick prototype, implemented the Figma design, explored the raywenderlich.com REST API and worked out the code to send a REST request and decode its response. In this chapter, you’ll copy and adapt the playground code into your app. Then, you’ll build on this to implement all the filters and options that let your users customize which episodes they fetch. Your final result will be a fully-functioning app you can use to sample all our video courses.
Getting started
Open the RWFreeView starter project. It contains code you’ll use to keep the filter buttons synchronized between FilterOptionsView and HeaderView. And EpisodeStore is now an EnvironmentObject, used by ContentView, FilterOptionsView, HeaderView and SearchField.
Swift playground
Open the Networking playground in the starter folder or continue with your playground from the previous chapter. You’ll adapt code from the Episode playground into a fetchContents() method in EpisodeStore.swift and replace the old prototype Episode with the new Episode structure and extension. And, you’ll create a new Swift file — VideoURL.swift — for the VideoURL class and make it conform to ObservableObject.
Some of the new Episode properties are slightly different, so you’ll fix a few errors that appear in EpisodeView.swift and PlayerView.swift.
The starter project already contains FormatterExtension.swift and URLComponentsExtension.swift.
From playground to app
The playground code is enough to get your app downloading popular free episodes. You’ll implement query options and filters in the second half of this chapter.
➤ In Project navigator, in the Preview Content group, delete EpisodeStoreDevData.swift. You’re about to change the Episode structure, and you’ll initialize the episodes array from a URLSession response.
➤ In EpisodeStore.swift, replace init() with this code:
func fetchContents() {}
init() {
fetchContents()
}
You’ll copy playground code to implement fetchContents(), and init() simply calls fetchContents().
Adapting EpisodeStore code
Now start copying and adapting code from the Episode playground page into EpisodeStore.swift.
➤ Replace func fetchContents() {} with the following code:
// 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 }
}
- You copy
baseURLStringandbaseParamsintoEpisodeStoreas properties. - You create
urlComponentsas a local variable infetchContents(), then callurlComponents.setQueryItems(with:). Now that you’re in a method, you createurlComponentsandcontentsURLinguardstatements, so you can exit if one fails.
➤ Now copy the URLSession code into fetchContents() and modify what happens on the main queue:
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()
- Delete the
PlaygroundPageline of code. It won’t work here anyway. - You create a default
JSONDecoder. You don’t need to configure it because you’ll be providing a custominit(from:). - In the
DispatchQueue.mainclosure, you setepisodesfrom the decoded response.
Xcode complains that EpisodeStore doesn’t conform to Decodable, so start fixing that now.
➤ Add this code to EpisodeStore below the init() method:
// 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)
}
- In the next step, you’ll declare
EpisodeStoreto beDecodable, so you copyCodingKeysfrom the playground to satisfy that protocol. - In your app,
EpisodeStorepublishes an array and two dictionaries.Publishedproperties aren’tDecodable, so you must explicitly decode at least one of them to conform toDecodable.
➤ Scroll up and replace the class EpisodeStore line with this:
final class EpisodeStore: ObservableObject, Decodable {
When you add the init(from:) initializer, an Xcode error might tell you to mark it as required. This keyword indicates that every subclass of EpisodeStore must implement this initializer. You won’t be subclassing EpisodeStore, so you apply the final keyword to the class to make this fact explicit. This gets rid of the error message.
Having added CodingKeys and init(from:), you can now declare that EpisodeStore conforms to Decodable.
Copying Episode code
Now the Decodable issue moves to Episode, so you’ll fix that next. The code you need is already in the playground. There’s a lot of it, so you’ll put it in its own file.
➤ Delete Episode in EpisodeStore.swift, then create a new Swift file named Episode.swift with the playground code for struct Episode and extension Episode.
And now you need to supply the VideoURL class.
Copying VideoURL & VideoURLString code
➤ Create a new Swift file named VideoURL.swift and add this code to it:
class VideoURL: ObservableObject {
@Published var urlString = ""
}
Instead of the simple class and var in the playground, VideoURL in your app is an ObservableObject. It publishes urlString because there’s a network delay between initializing a VideoURL object and assigning a non-empty value to urlString.
➤ Below VideoURL, copy VideoURLString and its extension, and VideoAttributes from the playground.
➤ Now copy the init(videoId:) method from the playground into VideoURL and modify the dataTask completion handler:
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()
}
- To reduce the number of debug messages, you only print the status code if it’s not 200 OK. The status code is 404 Not found if an item doesn’t have a video URL. As there’s no data to decode, you exit, leaving
urlStringwith the value"". - You don’t need to print the
urlString.
Using changed Episode properties
The difficulty property is now optional, so the app won’t compile. If Xcode hasn’t already complained, press Command-B to build the app, and error flags will appear. Two errors are about this line of code:
Text(String(episode.difficulty).capitalized)
It appears in EpisodeView.swift and in PlayerView.swift.
➤ In EpisodeView.swift, click the red error button to see Xcode’s suggestions and select the first fix “Coalesce using ‘??’…”.
➤ Use "" for the default value:
Text(String(episode.difficulty ?? "").capitalized)
➤ Do the same to fix the error in PlayerView.swift.
Another error appears in the first line of body in PlayerView.swift:
if let url = URL(string: episode.videoURLString) {
➤ The videoURLString in the simple Episode structure of Chapter 22, “Lists & Navigation”, is now videoURL?.urlString. It’s an optional, so replace this line using the same coalescing trick:
if let url = URL(string: episode.videoURL?.urlString ?? "") {
Debugging with a breakpoint
And your app is ready!
➤ Build and run. If it runs very slowly in a simulator, install it on an iOS device. Then, scroll down and examine the Introduction episodes:
They’re all the same! Tap them to make sure: Yes, the videos are all the same too.
If you send the same request in RESTed, you’ll see the same number of Introduction episodes, but they’re all different. So how can you see what’s happening in your app?
Breakpoints to the rescue! In Chapter 9, “Saving History Data”, you learned how to insert a breakpoint on a line of code where you want execution to pause while you inspect the current values. This time, you’ll just print out values every time that line executes without pausing the app.
In this case, it’s useful to see the videoIdentifier and description values of each decoded Introduction episode.
➤ In Episode.swift, in extension Episode, add a breakpoint to the line self.id = id in init(from:), then right-click the blue breakpoint arrow and select Edit Breakpoint….
➤ In the breakpoint window, click Add Action and set the Action to Log Message. In the Condition field, type name == “Introduction” and, in the message field, type @videoIdentifier@ @description@. Finally, check the box to Automatically continue after evaluating actions.
➤ Build and run.
The debug console displays videoIdentifier and description for several episodes, and they’re all different. So, there’s nothing wrong with the server response or with your app’s decoding.
Notice the first Introduction episode is the one that gets repeated in the running app.
➤ Click the breakpoint arrow to disable it.
Your app decodes several different Introduction episodes into your episodes array, but displays only the first one, again and again. This is the work of the loop in ContentView.swift, so this is the next place to look for the problem.
ForEach(store.episodes, id: \.name) { episode in
Oh! id: \.name means every episode with the same name is the same episode, so the first Introduction episode is the episode.
Easy to forget but also easy to fix. :]
➤ In ContentView.swift, delete the id: \.name parameter from ForEach.
ForEach(store.episodes) { episode in
Episode now has an id property, which ForEach and List use by default, unless you specify some other value for the id argument. This id property is different for each episode, even if they have the same name.
➤ Build and run.
Much better!
Improving the user experience
Congratulations, your app is working! Now you can look for opportunities to improve your users’ experience. Your app should enable them to complete tasks and achieve goals without confusion or interruptions. You don’t want users scratching their heads wondering what’s happening or what to do next.
Exercise: Display parentName
➤ Take another look at those Introduction episodes. Even if a user reads the description, it doesn’t always tell them enough to decide whether to play the video. Sometimes, there are several Conclusion episodes, too. Can you add more information to these episodes?
In the raywenderlich.com API, the attributes key parent_name tells you the course an episode is in. You can improve your users’ experience by adding a parentName property to Episode, then display it when name is "Introduction" or "Conclusion".
Try this exercise on your own before reading my list of steps below or looking at the final project.
➤ In Episode.swift, in Episode, add parentName: String? to the list of properties. It’s rare, but possible, for parent_name to be null.
➤ Add case parentName = "parent_name" to AttrsKeys.
➤ Decode it with let parentName = try attrs.decode(String?.self, forKey: .parentName).
➤ Still in init(from:), set the property with the decoded value self.parentName = parentName.
➤ In EpisodeView.swift, display parentName below name:
if episode.name == "Introduction" ||
episode.name == "Conclusion" {
Text(episode.parentName ?? "")
.font(.subheadline)
.foregroundColor(Color(UIColor.label))
.padding(.top, -5.0)
}
Negative padding squeezes it up closer to the episode name.
➤ Build and run, then scroll down to see the Introduction episodes now display more information:
Indicating activity
The list is blank while the dataTask is running. Users expect to see an activity indicator until the list appears.
ActivityIndicator.swift contains the spinner activity indicator from Sarah’s article bit.ly/3cVlzif, modified to use your app’s gradient colors.
➤ In EpisodeStore.swift, add this property to control whether the spinner appears:
@Published var loading = false
➤ In fetchContents(), add this line before the URLSession code:
loading = true
➤ And in the dataTask handler, add this at the beginning, where you had the defer closure to finish playground execution:
defer {
DispatchQueue.main.async {
self.loading = false
}
}
You set loading to true before starting dataTask and set it to false after receiving and decoding the network response. Using the defer block ensures you hide the activity indicator in both success and failure cases.
Now you’ve set the value of loading in all the necessary places, you’ll use its value to show or hide ActivityIndicator().
➤ In ContentView.swift, add this line after HeaderView(count:)
if store.loading { ActivityIndicator() }
➤ Build and run to see your spinner.
It looks pretty cool! After you’ve implemented all the query options, you’ll make the list do something even cooler while it loads the new episodes.
What if there’s no video?
While writing this chapter, sometimes one or more placeholder episodes appeared in the contents query results. These don’t have a video, so PlayerView is blank — not a good user experience. I created a PlaceholderView to display when there’s no video URL.
It turns out these placeholders shouldn’t be included in results, and they’ve been deleted now. But you should still check for a video URL. You might, for example, decide to allow non-episode content types, which don’t have videos (but forget to provide an appropriate viewer).
In PlayerView.swift, you’ll display a “No video” message when there’s no video URL.
➤ In PlayerView.swift, click the gutter next to GeometryReader to fold it so you can see where the if let url closure ends:
➤ Replace the if closure’s closing brace with this else closure:
} else {
PlaceholderView()
}
To test this, you need to temporarily change the content_types value.
➤ In EpisodeStore.swift, in baseParams, change "episode" to "article":
"filter[content_types][]": "article"
➤ Build and run, then tap any item:
➤ In EpisodeStore.swift, in baseParams, change "article" back to "episode":
"filter[content_types][]": "episode"
OK, your app’s basic download function is working well, delivering a great user experience. Now, it’s time to implement all those options and filters, so your users can customize their query results.
Implementing HeaderView options
HeaderView provides these options for users to customize downloaded contents:
- Clear some or all filter options.
- Enter a search term.
- Change the page size.
- Switch sorting between Popular and New.
You’ll manage the filter options in the next two sections.
In this section, you’ll implement the last three actions, which correspond to the last three keys in the baseParams dictionary in EpisodeStore:
var baseParams = [
"filter[subscription_types][]": "free",
"filter[content_types][]": "episode",
"sort": "-popularity",
"page[size]": "20",
"filter[q]": ""
]
For each of these three user actions, you’ll write code to change the appropriate value and send a new request.
Entering a search term
In HeaderView.swift, add this property to SearchField:
@EnvironmentObject var store: EpisodeStore
You’ll pass the user’s search term to the baseParams dictionary in EpisodeStore.
➤ In body, replace TextField("", text: $queryTerm) with the following:
TextField(
"",
text: $queryTerm,
onEditingChanged: { _ in },
onCommit: {
store.baseParams["filter[q]"] = queryTerm
store.fetchContents()
}
)
When the user taps the keyboard’s return key, the onCommit code runs. You set the value of the query filter to the user’s search term, then call fetchContents().
➤ Build and run, then enter a search term like map:
You can tell it worked. The episode names have changed, some of the descriptions mention MapKit or “map”, and there are only 8 episodes instead of 20.
Changing the page size
Next, implement the page size menu.
➤ Scroll up to the page size menu buttons and add actions:
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") { }
Depending on the selected button, you set the value of the page size key, then call fetchContents().
➤ Build and run, then select 10 or 30 results per page:
I included 10 results/page in the menu because it’s easy to count to 10 to see if it’s working. ;]
Switching the sort order
And now, get the sort order control working.
➤ In HeaderView.swift, change the initial value of sortOn:
@State private var sortOn = "none"
This value doesn’t match either segment tag, so neither segment shows as selected until the user taps one.
➤ Add this modifier to Picker("", selection: $sortOn):
.onChange(of: sortOn) { _ in
store.baseParams["sort"] = sortOn == "new" ?
"-released_at" : "-popularity"
store.fetchContents()
}
When the sortOn value changes, you set the baseParams value for the "sort" key, then call fetchContents().
➤ Build and run. Notice the date of the first item is Sep 2019, then select New in the picker:
Now the items all have recent release dates.
You’ve implemented every HeaderView option except clearing query filters. Before you can clear a query filter, you need a way to add them to HeaderView. So first, you’ll implement query filters in FilterOptionsView.
Implementing filters in FilterOptionsView
In FilterOptionsView, users can select or deselect filter options then tap Apply or X to combine the selected options into a new request.
There are two types of query filters: Platforms (called domains in the API) and Difficulty. Users can select one or more of each type — Android & Kotlin and Flutter, Beginner and Intermediate — so you can’t store their selections in a dictionary like baseParams, where each key is a unique query parameter name.
Query filter dictionaries
To keep track of selected query filter options, the starter project contains two query filter dictionaries in EpisodeStore.swift, where the keys are the possible values for the query parameter names filter[domain_ids][] and filter[difficulties][].
@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
]
A query filter dictionary value is true if the user has selected the query filter matching that key. In the starter project, the iOS & Swift domain and beginner difficulty are already selected but not yet implemented.
Tapping a query filter button in FilterOptionsView toggles its value in one of these query filter dictionaries. For example:
Button("iOS & Swift") { store.domainFilters["1"]!.toggle() }
This value also switches the color of the query filter button in FilterOptionsView: green when true, gray when false.
.buttonStyle(
FilterButtonStyle(
selected: store.domainFilters["1"]!, width: nil))
When your users tap query filter buttons to make their selections, then tap the X or Apply button, here’s what your code needs to do.
➤ In FilterOptionsView.swift, add this line to the actions of the xmark and Apply buttons, before the line that dismisses this sheet:
store.fetchContents()
That’s all! You’ll soon update fetchContents() to combine all the user’s selections into a single query URL.
Clearing all query filters
In FilterOptionsView, the user might tap Clear All. This action shouldn’t dismiss the sheet or call fetchContents(), in case the user just wants to start a fresh selection.
➤ In FilterOptionsView.swift, set the Clear All button’s action:
store.clearQueryFilters()
➤ And in EpisodeStore.swift, add this method to EpisodeStore:
func clearQueryFilters() {
domainFilters.keys.forEach { domainFilters[$0] = false }
difficultyFilters.keys.forEach {
difficultyFilters[$0] = false
}
}
You only need to set all the values to false in both query filter dictionaries. You created a method to do this because you’ll also call it in HeaderView.
➤ Build and run, show the filter options view, then select some query filters. The buttons turn green. Now tap Clear All to see them turn gray.
Filtering and mapping query filters
Tapping Apply won’t change your results yet. You have to add the corresponding query items to your contentsURL.
➤ Still in EpisodeStore.swift, replace the guard let contentsURL line in fetchContents() with this code:
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)
You filter for domainFilters keys with value true, producing a collection of domain keys. Then you call the queryDomain(_:) method on each key, producing an array of URLQueryItem. You do the same for difficultyFilters, also producing an array of URLQueryItem. Then you append each array to urlComponents.queryItems to create your contents query URL.
➤ Build and run. Notice all the results are for iOS & Swift Beginner, even though the header view doesn’t display these buttons.
➤ Show the filter options sheet and select or deselect some query filters. Tap Apply or the xmark:
These filter buttons work. Now to get the HeaderView buttons in sync.
Implementing query filters in HeaderView
When the user selects query filters in FilterOptionsView, their buttons should appear in HeaderView. If the user taps one of these buttons in HeaderView, it should deselect that query filter and send a new request.
Clearing all in HeaderView
Before you set up these query filter buttons, implement the Clear all button to clear the query filters and the search term.
➤ In HeaderView.swift, add this code as the action for the Clear all button:
queryTerm = ""
store.baseParams["filter[q]"] = queryTerm
store.clearQueryFilters()
store.fetchContents()
You empty the search TextField value and set the value of the query parameter to this empty string. Then, you clear the domain and difficulty query filters and call fetchContents().
Showing the query filter buttons
This display is trickier than FilterOptionsView because the number of buttons is variable. Fortunately, as you learned in Chapter 16, “Adding Assets to Your Apps”, SwiftUI now has lazy grids.
➤ First, set up a three-column layout. Add this property to HeaderView:
let threeColumns = [
GridItem(.flexible(minimum: 55)),
GridItem(.flexible(minimum: 55)),
GridItem(.flexible(minimum: 55))
]
➤ Clear all is one of the buttons in this grid, so replace its enclosing HStack with the following:
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()
}
- A
LazyVGridfills in items horizontally, row by row. The first button is always Clear all. - The dictionary method
mergingmerges the two query filter dictionaries into a new, temporary dictionary. You specify_, second in secondto resolve any key clashes in favor of the second dictionary. (You know there won’t be any key clashes, but Xcode doesn’t.) To useForEach, you create anArrayfrom the resulting collection of keys. - You filter for query filter keys with value
true, just like infetchContents(). - For each selected key, you create a
Button. To display the correct label,filtersDictionaryisEpisode.domainDictionaryplus difficulty items. - You can create an
Intfrom adomainFilterskey but not from adifficultyFilterskey, so this test tells you which query filter dictionary to update. - Every button calls
fetchContents()to send the new request.
➤ Build and run. Now the header view displays buttons for Beginner and iOS & Swift, and these match the green buttons in FilterOptionsView:
➤ Close the filter options sheet. In the header, tap iOS & Swift:
And now you get Beginner episodes for Android & Kotlin too. And the iOS & Swift button in FilterOptionsView is now gray.
One last thing…
Your activity spinner appears whenever the user changes a query filter or option. The previous list persists until the spinner stops. Instead, why not show redacted items?
➤ In ContentView.swift, edit the condition for showing ActivityIndicator():
if store.loading && store.episodes.isEmpty {
ActivityIndicator()
}
When the app launches, store.episodes is empty while the app decodes the initial request data. After the initial download, it’s possible to select filter options that return 0 episodes, so you keep store.loading in the condition to stop the spinner even when there aren’t any episodes to show.
Also add this modifier to FilterOptionsView() when you present it:
.environmentObject(store)
You pass the environment object to the modal sheet explicitly. When you present a view as a modal sheet, it isn’t actually in the view tree of ContentView. In spite of this, FilterOptionsView works pretty well, until it doesn’t. It’s possible to create conditions for a run-time error, complaining the modal view doesn’t have the environment object from an ancestor view. The app crashes. Passing store explicitly prevents this problem.
➤ Then modify the ForEach closure with this:
.redacted(reason: store.loading ? .placeholder : [])
While your app is decoding the response data into the episodes array, you display a placeholder view for each item. This replaces text with rounded rectangles of the same size and color. When loading finishes, you remove the reason for redaction, so your items appear as normal.
As a final touch, make sure the PlayButtonIcon for each icon isn’t redacted.
➤ In EpisodeView.swift, add unredacted modifier to PlayButtonIcon:
PlayButtonIcon(width: 40, height: 40, radius: 6)
.unredacted()
➤ Build and run and wait for the list to load. Then, change any query option to see your redacted placeholders:
It looks so professional!
Your RWFreeView is now a fully-functional real live app. Install it on your iOS device and enjoy exploring all our free episodes. One of the joys of writing this chapter was watching Ray’s “SwiftUI vs. UIKit” video multiple times — just to make sure the app was still working, of course. ;] And check out the next (final) chapter to create an RWFreeView widget.
Key points
-
Publishedproperties aren’tDecodable, so you must explicitly decode at least one of them to make anObservableObjectconform toDecodable. - After adding a breakpoint to a line of code, you can edit it to print out values without pausing the app every time that line executes.
- Remember to let
ForEachandListuse theidproperty of anIdentifiabletype. - Look for opportunities to improve your users’ experience and head off “huh?” moments.