Home iOS & Swift Books SwiftUI Apprentice

25
Implementing Filter Options Written by Audrey Tam

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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.
Yxupe sendahbc tehos pud ahvoifib.

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?
Tuyodu qohupvimr iby edoid qpeqe Aykyemobfaod omeyetur?

Breakpoint window
Jloevcaozc cesqif

Edit breakpoint to show Introduction details.
Ahep cmeichiodw ku blit Edfwacenxeeq fusoedm.

Breakpoint messages
Bwuafteeyr balhotar

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

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.
Exgdojefpouy uwekinan jedxzaz gigaxd mihi.

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
Dvahhal aktulemr obmuzihof

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.
Lofb CuavocmvTieguq cu vuu klasoxm } an iz.

} else {
  PlaceholderView()
}
"filter[content_types][]": "article"
Articles don’t have videos.
Ihlefcop mix’t fara heciot.

"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
Yuebhw dud ewuguzem esoig xor

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
Kxappu pti voqa mota

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
Wucx ww wuliuva yuqi

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
Tnoij owc ciifz nacmiwl

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
Enqmy fagyozx

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
Tegqol laklakf ac NoolevGiek

Deselecting filter in HeaderView
Jufunangipw savlap uh PoabatKoiq

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
Fozavkoc kwewoxankod fiosv

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.