Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

15. Advanced Lists
Written by Bill Morefield

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

The previous chapter introduced the common task of iterating over a set of data and displaying it to the user using the ForEach and List views. This chapter will build on that chapter and give you more ways to work with lists and improve the user’s experience working with lists in your apps.

Adding Swipe Actions

Perhaps the most glaring omission related to lists in the initial versions of SwiftUI came in the lack of native swipe action support. A swipe action provides the user quick access to a few commonly used tasks. SwiftUI 3.0 addresses this omission with new modifiers that simplify adding swipe actions to your lists. In this section, you’ll add a swipe action to the Flight Status Board that will let the user highlight a flight, making it stand out on the long list. You’ll use two types of actions, one that produces a small menu of options and a second that can perform a single action on the swipe.

Open the starter project for this chapter. You’ll see it continues the app from the end of Chapter 14: "Lists". You should be familiar with lists and the content introduced in the previous chapter before continuing this chapter. Open FlightStatusBoard.swift and add the following code after the selectedTab property.

@State var highlightedIds: [Int] = []

This property will store an array with the id of each flight the user highlights. You place the property in this view to reference it on the tabs the view contains. You will pass a binding to this array into the FlightList view on each tab. Find the three calls to FlightList in the view. Add a comma after the existing flightToShow parameter. On the following line, add a new second parameter to the calls to FlightList:

highlightedIds: $highlightedIds

For example, the first call will now look like this:

FlightList(
  flights: shownFlights.filter { $0.direction == .arrival },
  highlightedIds: $highlightedIds
)

Note you do not need to add the flightToShow parameter since it will remain nil for the first and last tabs. Once you’ve updated all three views, open FlightList.swift and add the following property after the flightId property:

@Binding var highlightedIds: [Int]

You use a binding to modify the contents of the array from within the FlightList view. Adding the property also means you need to update the preview to contain this new property. Update the FlightList view in the preview to:

FlightList(
  flights: FlightData.generateTestFlights(date: Date()),
  highlightedIds: .constant([15])
)

Now add the following method to the view, just before the body declaration:

func rowHighlighted(_ flightId: Int) -> Bool {
  return highlightedIds.contains { $0 == flightId }
}

This new method searches the array for the passed integer and returns true if the array contains it. You will use this new modifier to determine which list rows to highlight. Add the following code after the closing brace of the NavigationLink that forms the body of the list:

.listRowBackground(
  rowHighlighted(flight.id) ? Color.yellow.opacity(0.6) : Color.clear
)

Here, you use the listRowBackground(_:) modifier to set a background color for each row in the list. If the user chose to highlight the row, you set the background color to yellow with reduced opacity so the highlight doesn’t overwhelm the row’s content. Otherwise, you leave the background clear, leaving no visual effect.

With the code to manage and highlight rows in place, you can implement the swipe action that lets the user toggle highlighting for each row. To make the view management easier, you’ll create a new view that encapsulates the view and actions contained in the swipe action. Create a new SwiftUI view in the FlightStatusBoard group named HighlightActionView. At the top of the HighlightActionView struct, add the following two properties:

var flightId: Int
@Binding var highlightedIds: [Int]

These properties hold the flight id for the current row along with a binding to the array. Replace the contents of the preview to provide values for these properties:

HighlightActionView(
  flightId: 1,
  highlightedIds: .constant([1])
)

Next, add the following method after the properties for the view:

func toggleHighlight() {
  // 1
  let flightIdx = highlightedIds.firstIndex { $0 == flightId
  }
  // 2
  if let index = flightIdx {
    // 3
    highlightedIds.remove(at: index)
  } else {
    // 4
    highlightedIds.append(flightId)
  }
}

This method will toggle the current highlight state for the row by adding or removing the flight identifier to or from the highlightedIds array. Here’s how it works:

  1. This code gets the index in the array to the first element that matches the flightId passed into the view. If the array contains the flight id, then flightIdx will now have the index of that element. If the array does not include the id, then it will be nil.
  2. You attempt to unwrap flightIdx.
  3. If that succeeds, then index contains the index in the array of the id. You then remove that element of the array and therefore remove the flight id from the array.
  4. If the unwrapping of flightIdx failed, you add the flightId to the array.

Now change the body of the view to:

Button {
  toggleHighlight()
} label: {
  Image(systemName: "highlighter")
}
.tint(Color.yellow)

You create a button showing the highlighter symbol. The button’s action calls the toggleHighlight method to add or remove the flight id from the array as appropriate. You apply the tint(_:) modifier to change the button away from the default swipe action gray color.

With the new view complete, return to FlightList.swift. Add the following code after the listRowBackground(_:) modifier on the List.

// 1
.swipeActions(edge: .leading) {
  // 2
  HighlightActionView(flightId: flight.id, highlightedIds: $highlightedIds)
}

The .swipeActions(edge:allowsFullSwipe:content:) modifier tells SwiftUI to attach a swipe action to the row.

  1. The edge parameter tells SwiftUI where to place the swipe actions. You can specify separate additional actions for the other edge by adding multiple modifiers or multiple views within one modifier limited only by the available space in the row. Here you attach to the leading edge.
  2. The closure provides the view to display when the user performs the swipe action. You use the new view you created earlier in this section.

Run the app and navigate to the Flight Status view. Now drag your finger across a row, starting at the leading edge and continuing across the row. You’ll see the action triggers. This action occurs because the allowsFullSwipe property we didn’t specify defaults to true. When true, this property states the first action will be triggered when the user does a full swipe. The user can also swipe to reveal the actions and then tap it. Also, note the swipe action does not interfere with the navigation link if you tap on the row.

Showing swipe action on a list row
Showing swipe action on a list row

Swipe actions provide a way to give the user faster access to a few common or essential actions related to items in the list. Next, you’ll let the user request a manual refresh of the items in the list.

Pull to Refresh

You’ve probably noticed the static nature of this app. When the user displays a view, the contents never change. Some of that comes from using static test data in the app instead of a web service that would provide updates and changes as flight conditions change. Even when updates are automatic, it’s common to provide a way for the user to request a data refresh in an app. The most common of these methods comes to SwiftUI 3.0 with the refreshable(action:) view modifier. In this section, you’ll add refresh support to the app.

func lastUpdateString(_ date: Date) -> String {
  let dateF = DateFormatter()
  dateF.timeStyle = .short
  dateF.dateFormat = .none
  return "Last updated: \(dateF.string(from: Date()))"
}
Text(lastUpdateString(Date()))
  .font(.footnote)
Adding last update time to Flight Status
Ipmuhm yerk uznoqo xunu fu Zlijkv Ffeduq

var relativeTimeFormatter: RelativeDateTimeFormatter {
  let rdf = RelativeDateTimeFormatter()
  rdf.unitsStyle = .abbreviated
  return rdf
}
Text(flight.flightStatus)
Text(flight.localTime, formatter: timeFormatter)
Text("(") +
Text(flight.localTime, formatter: relativeTimeFormatter) +
Text(")")
Relative time
Wegujara lehu

@State var flights: [FlightInformation]
// 1
.refreshable {
  // 2
  await flights = FlightData.refreshFlights()
}
Refreshing a view
Pehjikxeqg o huuw

Updating Views for Time

SwiftUI views usually update in response to changes in state. That state change can be driven by user action, such as tapping a button, or through external changes powered buy notifications, Combine or async events. In most cases, you don’t need to change a view unless the underlying data changes. Sometimes you’ll want to update a view due to the passage of time to provide a better user experience.

TimelineView(.periodic(from: .now, by: 60.0)) { context in
Text(lastUpdateString(context.date))
Updating view with TimelineView
Unvixagz gaos vify XuxayewaKaud

TimelineView(.everyMinute) { context in

Searchable Lists

In Chapter 14: “Lists”, you briefly used the new search abilities added in SwiftUI 3.0 to add a search field when creating the Search Flights view. In this section, you’ll explore the search abilities in greater depth.

// 1
.searchable(text: $city) {
  // 2
  ForEach(FlightData.citiesContaining(city), id: \.self) { city in
    // 3
    Text(city).searchCompletion(city)
  }
}
Added search suggestions
Avdef zourkc pibsiwwiidc

Submitting Searches

For searches that have a high cost — whether in terms of time, fees, or limitations — you may only want to search when the user finishes entering their search parameter. SwiftUI supports this process using the onSubmit(of:_:) method. You’ll make changes to the search view that better works with an API call. First, change the definition of the flightData property in the view to:

@State var flightData: [FlightInformation]
var matchingFlights: [FlightInformation] {
  var matchingFlights = flightData

  if directionFilter != .none {
    matchingFlights = matchingFlights.filter {
      $0.direction == directionFilter
    }
  }
  return matchingFlights
}
// 1
.onSubmit(of: .search) {
  // 2
  Task {
    // 3
    await flightData = FlightData.searchFlightsForCity(city)
  }
}
Asynchronous search
Utjnkyjuqaox juakmj

@State private var runningSearch = false
.onSubmit(of: .search) {
  Task {
    runningSearch = true
    await flightData = FlightData.searchFlightsForCity(city)
    runningSearch = false
  }
}
.overlay(
  Group {
    if runningSearch {
      VStack {
        Text("Searching...")
        ProgressView()
          .progressViewStyle(CircularProgressViewStyle())
          .tint(.black)
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .background(.gray)
      .opacity(0.8)
    }
  }
)
Search in progress overlay
Miiwtx al jgopxogs oletkeq

Adding Final Search Touches

You’ve probably noticed when you dismiss the search that the results still reflect the last completed search. There’s no current method you can use to know when the search cancels, but you can get the same effect by monitoring the city property that holds the search text. Add the following code after the onSubmit(of:_:) modifier:

.onChange(of: city) { newText in
  if newText.isEmpty {
    Task {
      runningSearch = true
      await flightData = FlightData.searchFlightsForCity(city)
      runningSearch = false
    }
  }
}
.searchable(text: $city, prompt: "City Name") {
New search prompt
Feq naoqsb xdunld

Key Points

  • Swipe actions allow the user quick access to a few common or important actions on items in a list. You can place them at either the leading or trailing edge or both.
  • The refreshable(action:) modifier provides a way to support user initialed data refreshes. It uses the Swift 5.5 async/await framework.
  • A TimelineView provides a way to update a few on a defined schedule.
  • The searchable(text:placement:prompt:) modifier provides a framework to support search.
  • You can provide suggestions for search terms in the closure of the searchable(text:placement:prompt:).
  • You can either update search results immediately or update them when submitted using the onSubmit(of:_:) modifier.
  • The onChange(of:) modifier lets you act when the value of a property changes. Here you used it to refresh the list to the full results when the search term cleared.

Where to Go From Here?

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 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 Kodeco Personal Plan.

Unlock now