15.
Advanced Lists
Written by Bill Morefield
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 contained inside this view. You will pass a binding to this array into the FlightList view for each tab. Find the three calls to FlightList in the view. Add a comma after the existing flights parameter. On the following line, add a new second parameter to the call to the FlightList view:
highlightedIds: $highlightedIds
For example, the first call will now look like this:
FlightList(
flights: shownFlights.filter { $0.direction == .arrival },
highlightedIds: $highlightedIds
)
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 method 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:
- This code gets the index in the array to the first element that matches the
flightIdpassed into the view. If the array contains the flight id, thenflightIdxwill now have the index of that element. If the array does not include the id, then it will benil. - You attempt to unwrap
flightIdx. - If that succeeds, then
indexcontains 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. - If the unwrapping of
flightIdxfailed, you add theflightIdto 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.
- 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.
- 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.
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.
Open FlightStatusBoard.swift. First, you’ll add an indicator to let the user know when SwiftUI last updated the list. Add the following code before the body of the view:
func lastUpdateString(_ date: Date) -> String {
let dateF = DateFormatter()
dateF.timeStyle = .short
dateF.dateFormat = .none
return "Last updated: \(dateF.string(from: Date()))"
}
This method formats a string with a short description of the time from the passed date. Now you’ll use it to show the user the last update time for the list. Embed the current TabView inside a new VStack. Now add the following code to the top of the VStack:
Text(lastUpdateString)
.font(.footnote)
This new Text view shows the date of the last update of the view above the tabs. Now run the app and go to the Flight Status view. You’ll see the new last updated time above the lists.
To help point the new update a bit more clearly, you’ll also update each flight shown with the difference between the current time and the time the flight lands or departs. Open FlightRow.swift and add a new property after the existing timeFormatter:
var relativeTimeFormatter: RelativeDateTimeFormatter {
let rdf = RelativeDateTimeFormatter()
rdf.unitsStyle = .abbreviated
return rdf
}
Now add three new lines of code to the HStack that show the flight status and time to read:
Text(flight.flightStatus)
Text(flight.localTime, formatter: timeFormatter)
Text("(") +
Text(flight.localTime, formatter: relativeTimeFormatter) +
Text(")")
Run the app. Each row shows the relative time between now and the landing or departure of the flight. It automatically uses the correct language for future and past events.
Go back to FlightStatusBoard.swift and add the @State modifier to the flights property so it reads:
@State var flights: [FlightInformation]
This change lets you modify the flights property within the view and let SwiftUI know to update the view when the flights property change. Now you can use the refreshable(action:) to do that. Before the navigationTitle(_:) modifier, add the following code:
// 1
.refreshable {
// 2
await flights = FlightData.refreshFlights()
}
That’s all that you need to do. Here’s what each line does in more detail:
- Adding the
refreshable(action:)method to a view marks it as refreshable for SwiftUI. The modified control will provide the UI for a user-requested refresh. In this case, you’ve added the standard pull-down action for lists, and the list will display a progress indicator during the refresh. When the user requests a refresh, SwiftUI executes the action provided in the closure. - Note the
awaitkeyword. FlightData.refreshFlights() simulates an API call that takes time (in this case, three seconds) to complete. Using the new async/await support in Swift 5.5 lets this take place without freezing your app. SwiftUI shows the progress indicator during the duration of the awaited action. In this case, the information about the flights will not change since it’s still test data, but it will refresh the views you changedflightsto a@Stateproperty.
Run the app and navigate to the Flight Status view. Note the current time and wait until the time changes to a minute. Go to the top of the list and then pull down and release. You’ll see the progress indicator appear for three seconds, and then the view updates to reflect the new time you requested a refresh.
While manually updating views is helpful, there are times you want to refresh a view automatically. Previously SwiftUI provided updates based on data changes, but there’s a new view that lets you update a view on a time-based schedule. You’ll explore it in the next section.
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.
In the last section, you added a relative time to each flight. As the clock moves forward, these times should change, but right now, that does not happen unless the user requests a refresh. It would be better to have a way to tell a SwiftUI view to update on a regular schedule. SwiftUI 3.0 added the new TimelineView that will update according to a schedule that you provide. In this section, you’ll use the TimelineView to ensure you always show up-to-date information.
To begin, wrap the VStack inside a new TimelineView. Use the following definition for the view:
TimelineView(.periodic(from: .now, by: 60.0)) { context in
You’ve now wrapped the existing VStack inside a TimelineView.
You provide a type that implements the TimelineSchedule protocol to the TimelineView to tell SwiftUI when to update the view. This one uses the periodic(from:by:) static type that begins at a specified time and repeats after a given number of seconds. Here, you start now and repeat every sixty seconds. SwiftUI passes a TimelineView.Context property to the closure that contains a date property with the date from the schedule that triggered the update. It also contains a cadence property that provides guidelines on how often the view updates occur.
Update the text showing the last update to:
Text(lastUpdateString(context.date))
The app now shows the date property of the context as the last update. In this case, that’s the same as the current date when the view updates, meaning you would still get the correct results without this change. Run the app and navigate to the Flight Status view. Wait one minute, and you’ll see the times update automatically when the minute changes.
Notice that if you run the app just before the minute changes, it will be inaccurate until that sixty seconds pass. You could adjust your start time to the zero-second point of the next minute, but since the need to change at the start of each minute is so common, SwiftUI provides another static type just for it. Change the TimelineView to:
TimelineView(.everyMinute) { context in
Run the app, and you’ll see the view updates as soon as the minute changes instead of waiting for sixty seconds to pass. The app will continue to update at the start of each minute.
There’s also an explicit(_:) type to specify exact times to update the view. In this app, you could pass a list of the times for each flight if you did not want to show the relative time for each arrival and departure to update after a flight arrives or departs. You can use .animation to update the view at a specified frequency. As the name implies, this type will be helpful for animations. It also allows easy pause of updates. For more complex scenarios, you can implement a custom type that implements the TimelineSchedule protocol.
A TimelineView adds the ability to update a view based on the current state. If the state changes, then the view will still update to reflect the change.
Now that you’ve looked at time-based updates, you’ll examine what you may find the most helpful new feature of lists in SwiftUI 3.0 — better search support.
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.
In the previous section, you saw that adding search uses a new searchable(text:placement:prompt:) modifier. Open SearchFlights.swift, and you’ll see the line of code .searchable(text: $city) near the end of the view. This code ties the city property of the view to the search text box SwiftUI shows at the top. The matchingFlights computed property used for the list contents filters the list of cities whenever the city property is not empty.
Currently, you need to know the cities that are options and spell out the city when searching. Using the more advanced SwiftUI search features, you can provide suggestions for search terms. You’ll use the citiesContaining(_:) static method on the FlightData class that provides an alphabetized list of all the cities. If you pass an empty string to the method, you will get a list of all cities. If you provide text, the list will only show cities that include the passed text in the city name. Replace the current searchable(text:placement:prompt:) modifier with the following:
// 1
.searchable(text: $city) {
// 2
ForEach(FlightData.citiesContaining(city), id: \.self) { city in
// 3
Text(city).searchCompletion(city)
}
}
You provide search suggestions in the closure to the searchable(text:placement:prompt:) modifier. Defining the search suggestions requires two steps.
- You use the
citiesContaining(_:)method onFlightDatato get an array of cities that contain the current text of thecityproperty. You iterate through the results using aForEachloop. - The contents of the closure of the
ForEachloop provide two things. First, you state the text to show the user. In this case, you show only the city name, but you could provide more text to help the user better understand the suggestion. You add thesearchCompletion(_:)modifier to theTextto indicate the search text when the user chooses this suggestion.
Run the app. As soon as you tap in the search field, you’ll see an alphabetical list of all cities appear. If you tap one, then it will immediately fill the search field with that city. If you type a few letters, then the list of suggestions reduces to only the cities containing the text. Again tapping a suggestion fills the search field with the complete text.
Search suggestions mainly help the user when there are many options, such as in a real airport app with hundreds of possible destinations. It can also reduce frustration due to misspellings or not knowing the complete name for search terms. Suggestions can also help the situations where your search relies on an external API or data source. In the next section, you will look at more ways to deal with searches outside the phone.
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]
Adding the @State property wrapper makes this value changeable within the view. You still can pass in an initial value, but now can change the property when simulating API calls. Now change the matchingFlights computed property to:
var matchingFlights: [FlightInformation] {
var matchingFlights = flightData
if directionFilter != .none {
matchingFlights = matchingFlights.filter {
$0.direction == directionFilter
}
}
return matchingFlights
}
This change removes the search filter this view previously provided. You’ll replace this by calling a simulated API call when the user submits the search. Add the following code after the searchable(text:placement:prompt:) method:
.onSubmit(of: .search) {
// 2
Task {
// 3
await flightData = FlightData.searchFlightsForCity(city)
}
}
Here’s how this code implements submission for the search field:
- The
onSubmit(of:_:)modifier tells SwiftUI you want to do something after the user submits a view inside the view it modifies. Passing the.searchidentifier to theofparameter tells SwiftUI to respond only when the user submits a search field. - The closure for the
onSubmit(of:_:)modifier is not asynchronous. In most cases, you’ll use an async call for search when going to an external source since you will wait for a reply and have no control over how long the response may take. To use an async method from a synchronous method, you wrap the async inside aTaskstructure. - The
searchFlightsForCity(_:)method simulates calling an external API and will take three seconds to complete.
Run the app. You’ll see the suggestions still appear, but the search doesn’t execute until you tap a search suggestion or tap enter on the keyboard.
You’ll notice there’s no indication that the search takes place. You’ll add an indicator that shows while the search runs to let the user know something is going on.
Add a new boolean property at the end of the existing properties:
@State private var runningSearch = false
Now update the onSubmit(of:_:) method to:
.onSubmit(of: .search) {
Task {
runningSearch = true
await flightData = FlightData.searchFlightsForCity(city)
runningSearch = false
}
}
You’ve added code to set the runningSearch property to true before starting the search and then to false when the search completes. Now you’ll add a progress indicator when runningSearch is true. Add the following code to the end of the list (before the listStyle(_:) modifier):
.overlay(
Group {
if runningSearch {
VStack {
Text("Searching...")
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.tint(.black)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white)
.opacity(0.8)
}
}
)
Rerun the app, and you’ll now see the overlay shows during the search, letting the user know the app is working.
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
}
}
}
This code tells SwiftUI you want to execute a code block when the city property changes. The new value of city will be passed into the block. You check this value, and if empty, you execute the search passing the empty string, which returns all flights from the API.
You’ve also been using the generic prompt that just reads Search. You can provide an optional prompt that tells the user more about the search. Change the searchable(text:placement:prompt:suggestions:) modifier to:
.searchable(text: $city, prompt: "City Name") {
Run the app, and you’ll now see the new prompt text.
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
TimelineViewprovides 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?
- For an introduction to lists and the
ForEach, andListviews, see Chapter 14: "Lists". - For more about
async/await, see the WWDC 2021 video Meet async/await in Swift and async/await in SwiftUI. - For more about allowing the user to modify and change lists and implementing drag and drop, see Drag and Drop Editable Lists: Tutorial for SwiftUI.
- To learn more about the new SwiftUI 3.0 features, view the WWDC 2021 video What’s new in SwiftUI.