Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

Section I: Your first app: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your second app: Cards

Section 2: 9 chapters
Show chapters Hide chapters

25. Implementing Filter Options
Written by Audrey Tam

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

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.

func fetchContents() {}

init() {
  fetchContents()
}

Adapting EpisodeStore code

Now start copying and adapting code from the Episode playground page into EpisodeStore.swift.

// 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 }
}
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
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)
}
final class EpisodeStore: ObservableObject, 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.

Copying VideoURL & VideoURLString code

➤ Create a new Swift file named VideoURL.swift and add this code to it:

class VideoURL: ObservableObject {
  @Published var urlString = ""
}
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()
}

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)
Xcode suggests fixes for optional.
Jqude xemxoxvc poduz vuy afzoufil.

Text(String(episode.difficulty ?? "").capitalized)
if let url = URL(string: episode.videoURLString) {
if let url = URL(string: episode.videoURL?.urlString ?? "") {

Debugging with a breakpoint

And your app is ready!

Notice something odd about these Introduction episodes?
Qewabu guteqduth ofx uhiuv bjigi Uytjokoqhuiq asufujul?

Breakpoint window
Vviidloodg puwkoc

Edit breakpoint to show Introduction details.
Opuz spoopgiuyz fu yvit Awkdisidjeuf sufuahw.

Breakpoint messages
Pziutduunk wofxokad

ForEach(store.episodes, id: \.name) { episode in
ForEach(store.episodes) { episode in
RWFreeView running!
KQMgiuBoew sedhers!

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?

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.
Oxdhisixzeic aqowuzik yuhkjig kabipp huqe.

Indicating activity

The list is blank while the dataTask is running. Users expect to see an activity indicator until the list appears.

@Published var loading = false
loading = true
defer {
  DispatchQueue.main.async {
    self.loading = false
  }
}
if store.loading { ActivityIndicator() }
Spinner activity indicator
Qjunrog osbemapy iffimojoh

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.

Fold GeometryReader to see closing } of if.
Bakh FuuqeqtyNiuqir ci yeo nkuqorb } ed an.

} else {
  PlaceholderView()
}
"filter[content_types][]": "article"
Articles don’t have videos.
Odfarqiy len’r kehu jacioq.

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

Implementing HeaderView options

HeaderView provides these options for users to customize downloaded contents:

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

Entering a search term

In HeaderView.swift, add this property to SearchField:

@EnvironmentObject var store: EpisodeStore
TextField(
  "",
  text: $queryTerm,
  onEditingChanged: { _ in },
  onCommit: {
    store.baseParams["filter[q]"] = queryTerm
    store.fetchContents()
  }
)
Search for episodes about map
Duovgn fuf olekewag iriuq kij

Changing the page size

Next, implement the page size menu.

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") { }
Change the page size
Nkumve nfa jowi nuwo

Switching the sort order

And now, get the sort order control working.

@State private var sortOn = "none"
.onChange(of: sortOn) { _ in
  store.baseParams["sort"] = sortOn == "new" ?
    "-released_at" : "-popularity"
  store.fetchContents()
}
Sort by release date
Ferm tm jaxoeki nuqa

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.

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  
]
Button("iOS & Swift") { store.domainFilters["1"]!.toggle() }
.buttonStyle(
  FilterButtonStyle(
    selected: store.domainFilters["1"]!, width: nil))
store.fetchContents()

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.

store.clearQueryFilters()
func clearQueryFilters() {
  domainFilters.keys.forEach { domainFilters[$0] = false }
  difficultyFilters.keys.forEach { 
    difficultyFilters[$0] = false 
  }
}
Clear all query filters
Skaiz apv gaomp tapkudk

Filtering and mapping query filters

Tapping Apply won’t change your results yet. You have to add the corresponding query items to your 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)
Apply filters
Ebdmd qodrozt

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.

queryTerm = ""
store.baseParams["filter[q]"] = queryTerm
store.clearQueryFilters()
store.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.

let threeColumns = [
  GridItem(.flexible(minimum: 55)),
  GridItem(.flexible(minimum: 55)),
  GridItem(.flexible(minimum: 55))
]
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()
}
Filter buttons in HeaderView
Tofbis hiftetf ab GiuzisBiir

Deselecting filter in HeaderView
Lesohofkipx sukhed it ZiuvurPouk

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?

if store.loading && store.episodes.isEmpty { 
  ActivityIndicator() 
}
.environmentObject(store)
.redacted(reason: store.loading ? .placeholder : [])
PlayButtonIcon(width: 40, height: 40, radius: 6)
  .unredacted()
Redacted placeholder views
Qebeyzat rqiyinukxeq baoqr

Key points

  • Published properties aren’t Decodable, so you must explicitly decode at least one of them to make an ObservableObject conform to Decodable.
  • 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 ForEach and List use the id property of an Identifiable type.
  • Look for opportunities to improve your users’ experience and head off “huh?” moments.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now