14.
Lists
Written by Bill Morefield
Most apps focus on displaying some type of data to the user. Whether upcoming appointments, past orders or new products, you must clearly show the user the information they come to your app for.
In the previous chapter, you saw a preview of iterating through data when displaying the flights for a day and allowing the user to interact with this data. In this chapter, you’ll dive deeper into the ways SwiftUI provides you to show a list of items to the user of your app.
Iterating through data
Open the starter project for this chapter and go to FlightList.swift in the FlightStatusBoard group. You’ll see a slightly different view than the one you created in the previous chapter. In place of List, which you’ll work with later in this chapter, you’ll start by examining ForEach.
SwiftUI uses ForEach as a fundamental element to loop over data. When you pass it a collection of data, it then creates multiple sub-views using a provided closure, one for each data item. ForEach works with any type of collected data. You can think of ForEach as the SwiftUI version of the for/in loop in traditional Swift code.
Run the app, tap Flight Status — and you’ll notice a mess.
Remember that ForEach operates as an iterator. It doesn’t provide any structure. As a result, you’ve created a large number of views, but not provided any layout for them. They’re all at the top level, not contained in anything else. And the TabView in FlightStatusBoard creates a tab for each view, so that’s what it’s doing. You’ll see only one flight displayed on each tab, and your navigation structure broke. To fix both issues, add some structure to the view:
ScrollView {
VStack {
ForEach(flights, id:\.id) { flight in
NavigationLink(
destination: FlightDetails(flight: flight)) {
FlightRow(flight: flight)
}
}.navigationBarTitle("Flight Status")
}
}
You wrapped the ForEach loop inside a VStack — giving you a vertical stack of rows — and a ScrollView — that allows scrolling the rows since there’s more content than will fit onto the view. SwiftUI picks up that you’ve wrapped a VStack and applies vertical scrolling to match. If a line of text within the view became longer than the view’s width, SwiftUI wouldn’t automatically add horizontal scrolling.
You can override this default scrolling direction by passing in the desired scroll axes to ScrollView. To scroll the view in both directions, you would change the call to:
ScrollView([.horizontal, .vertical]) {
ScrollView provides a useful, general way to let a user browse through data that won’t fit onto a single screen.
Also note the id: parameter passed a keypath to a property of the type in the array. This parameter hints that SwiftUI has expectations for the data sent to an iteration. In the next section, you’ll explore these expectations and make your data work more smoothly with SwiftUI.
Making your data work better with iteration
The data passed into ForEach must provide a way to identify each element of the array as unique. In this loop, you use the id: parameter to tell SwiftUI to use the \.id property of FlightInformation as the unique identifier for each element in the array.
The only requirement for the unique identifier, other than being unique, is implementing the Hashable protocol. The native Swift String and Int types do. You can also use the Foundation UUID and URL types if that better fits your data. Since the .id property of FlightInformation object is an Int, it works perfectly as the unique identifier.
If your data object implements Hashable, you can also tell SwiftUI to use the entire object as the unique identifier. To do so, you would pass \.self to the id: parameter. Use this technique to iterate over a set of integers or other native objects that implement the Hashable protocol.
You can also remove the need to specify the unique identifier altogether by making your type conform to the Identifiable protocol. This protocol, new in Swift 5.1, provides a defined mechanism telling SwiftUI the unique identifier for a piece of data. This protocol’s only requirement is to have an id property that conforms to the Hashable protocol. Since FlightInformation already has such a property, you simply have to let SwiftUI know this.
Open FlightInformation.swift in the Models group. At the end of the file, add the following code:
extension FlightInformation: Identifiable {
}
The extension tells SwiftUI that FlightInformation implements Identifiable. Since FlightInformation already meets the protocol requirements with an id parameter, you don’t need to make any other changes to it.
Since you no longer need to specify the identifier to SwiftUI, open FlightList.swift and change the ForEach declaration for the FlightList view to:
ForEach(flights) { flight in
Run the app, tap Flight Status and you’ll see the list works as before:
Improving performance
When a VStack or HStack renders, SwiftUI creates all the cells at once. For a view such as this with only thirty rows, that probably doesn’t matter. For rows with hundreds of potential rows, that’s a waste of resources since most are not visible to the user. Using the Lazy versions of these stacks introduced in SwiftUI 2.0 (iOS 14, macOS 11, etc.) provides a quick performance improvement when iterating over large data sets.
You will see moving to the Lazy stack can introduce side-effects you should know. In the previous loop, change the VStack inside the ScrollView to LazyVStack. Run the app and go to the Flight Status view again.
Even with this small amount of data, you might notice an improvement in the initial rendering speed and performance when scrolling the view. Now each row renders only when it first appears on the screen. This change mainly saves resources when you have a lot of data, most of which the user will never see. Those unwanted flights will never be rendered or take up resources on the device. Once created, the view remains and SwiftUI will not remove it when it scrolls out of sight.
You will also see the view subtly changed. A VStack fills only the space needed for the contents. A LazyVStack uses a flexible width that will take up all available space. This change means the row in the LazyVStack will expand to take up the view’s entire width.
You can see this comparing the two views before and after the change. As a VStack the scrolling list only occupies the middle of the view, and you must be within that area to scroll. In the LazyVStack, the row takes up the entire space of the view, and you can scroll anywhere in it. Also, notice the different positions of the scroll bars between the views.
Setting the scroll position in code
A major weakness of the first version of SwiftUI was the lack of a way to set the scrolling position programmatically. The second version introduced with iOS 14 and macOS Big Sur added ScrollViewReader that allows setting the current position from code. You’ll use it to scroll the flight status list to the next flight automatically. Change the view to:
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack {
ForEach(flights) { flight in
NavigationLink(
destination: FlightDetails(flight: flight)) {
FlightRow(flight: flight)
}
}
}
} // onAppear
}
You’ve wrapped the ScrollView inside a ScrollViewReader. You’ll use the ScrollViewProxy passed to the closure as scrollProxy to set the position. Since you’ve made your data conform to Identifiable, each row already has a unique identifier you can use to identify it later. You could also use the id(_:) method on NavigationLink to tag each row in the list with a unique identifier.
Now, add a property to get the id for the next flight that occurs. Add the following code after the flights property:
var nextFlightId: Int {
guard let flight = flights.first(
where: {
$0.localTime >= Date()
}
) else {
return flights.last!.id
}
return flight.id
}
This property looks for the first flight whose local time is at or after the current time. If one doesn’t exist, it returns the id property of the day’s last flight. If there is a later flight, the method returns its id property.
Now, you can move the scroll position to the row with this id when the view appears. You must do this inside the ScrollViewReader structure to have access to the proxy. The ScrollView is the perfect place for this. Replace the // onAppear comment with the following code:
.onAppear {
// 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
// 2
scrollProxy.scrollTo(nextFlightId)
}
}
The onAppear(perform:) method executes when the ScrollView appears. Here’s what the code does:
- This code first delays for 0.05 seconds. When setting the position inside
onAppear(perform:), you must wait a short time, or the scrolling will often fail to reach the correct position. The delay should be as brief as possible, and often needs to be determined through trial and error. When called as a response to a user action, you can usually leave out the delay. - You call
scrollTo(_:)onScrollViewProxyto scroll to the next flight’s id.
Run the app. You’ll see the view moves so the next flight shows at the bottom of the view.
You can specify the anchor parameter to change this location. In this case, it makes sense to place the flight in the middle of the view so change the scrollTo(_:anchor:) call to:
scrollProxy.scrollTo(nextFlightId, anchor: .center)
In cases in which there is not enough data to place the requested row at the requested position, the view will scroll to either the first or last element as close to the desired position as it can. Note that the scrolling works even combined with a LazyVStack, meaning you can scroll to a view that SwiftUI hasn’t rendered yet.
ForEach provides a flexible way to iterate through data. Since iterating through data and displaying it to the user is such a common task, all platforms have a built-in control to accomplish it. SwiftUI allows you to use this platform-specific control using a List.
Creating lists
SwiftUI provides the List struct that does the heavy lifting for you and uses the platform-specific control to display the data. A list is a container much like a VStack or HStack that you can populate with static views, dynamic data or other iterative views.
A List provides some of the features you did manually when using ForEach. Go to FlightList.swift in the FlightStatusBoard group and remove the ScrollView and LazyVStack. Replace the ForEach with a List. Your view should now look like:
ScrollViewReader { scrollProxy in
List(flights) { flight in
NavigationLink(
destination: FlightDetails(flight: flight)) {
FlightRow(flight: flight)
}
}.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
scrollProxy.scrollTo(nextFlightId, anchor: .center)
}
}
}
List iterates over the passed data as ForEach did, calling the closure for each element and passing the current element to that closure. Inside the closure, you define the view that will display for each row in the list. In this view, you show a NavigationLink showing information about the flight, which links to more details.
Notice the similarity in the code with ForEach. ForEach allows you to iterate over almost any collection of data and create a view for each element. List acts much as a more specific case of ForEach to display rows of one-column data. Almost every framework and platform provides a version of this control, as it’s a pretty standard user interface element.
A List automatically provides a vertical stack of the rows and handles scrolling. The ScrollViewReader works as before. On iOS related platforms, you also get the small right-pointing disclosure arrow automatically when a row of the list contains a NavigationLink. The row for the closure also takes up the entire width of the view.
Now that you’ve explored List and ForEach, you’ll work with them to build an interface allowing the user to search flights.
Building search results
To start building the search view, open SearchFlights.swift under the SearchFlights group. You’ll see a user interface to allow the user to search for flights. However, the results aren’t displayed. You’re going to fix that. Look for the // Insert Results comment and replace it with the following:
List(matchingFlights) { flight in
SearchResultRow(flight: flight)
}
That’s all you need to display the search results. You pass the matchingFlights parameter that filters for only the passed flights that match the search parameters. Since you already made FlightInformation implement the Identifiable protocol, List knows how to manage it. As for performance, SwiftUI always renders a List lazily, requiring no special effort on your part.
Run the app and try a few search parameters such as part of a city name. You’ll see the search quickly update to match your query.
Building a hierarchical list
The second version of SwiftUI added support for displaying hierarchical data. Much as the NavigationLink gave you a structure to organize views from general to more specific, a hierarchical list gives you an excellent way to display data that moves from general to more specific. In this section, you will update the search results into a hierarchical list that displays dates and then displays the flights for that date under it.
The data for a hierarchical list requires a specific format in addition to the standard requirements. For each element in the list, you need to create an optional property that contains a list of children in the hierarchy for the current row. Those children must be of the same type as the current element.
First you’ll create this data structure. Open SearchFlights.swift and add the following code to the view after the matchingFlights property:
struct HierarchicalFlightRow: Identifiable {
var label: String
var flight: FlightInformation?
var children: [HierarchicalFlightRow]?
var id = UUID()
}
This struct contains a string for a label for the top-level rows showing the date. It also stores two optional properties: information about a flight and a list of child rows for this row. You will set the flight at the bottom node and the children for other rows.
The struct also provides the id property needed to fulfill the Identifiable protocol’s requirements by giving each record a new UUID when created. A UUID, by definition, will be a unique value. For this more complex structure, it’s a quick way to avoid duplicate values that could cause rows not to appear. Now add the following code below the new struct:
func hierarchicalFlightRowFromFlight(_ flight: FlightInformation)
-> HierarchicalFlightRow {
return HierarchicalFlightRow(
label: longDateFormatter.string(from: flight.localTime),
flight: flight,
children: nil
)
}
This method creates a HierarchicalFlightRow object from an existing FlightInformation object. You’ll use this to generate the leaf nodes of the hierarchy structure with flight information.
Another element that you’ll need is a list of dates that contain a flight. Add the following code after the hierarchicalFlightRowFromFlight(_:) method:
var flightDates: [Date] {
let allDates = matchingFlights.map { $0.localTime.dateOnly }
let uniqueDates = Array(Set(allDates))
return uniqueDates.sorted()
}
This computed property builds an array with the dates from all flights matching the current search parameters using a map. It gets only the date component of the time using the dateOnly extension defined in DateExtensions.swift. You convert the array to a set and back to remove duplicate values from the array. You return the sorted results.
You’ll also need to filter for flights that take place on a specified day. Add the following code after the flightDates property:
func flightsForDay(date: Date) -> [FlightInformation] {
matchingFlights.filter {
Calendar.current.isDate($0.localTime, inSameDayAs: date)
}
}
This function uses Calendar.isDate(_:inSameDayAs:) method to choose only flights matching the search parameters that occur on the passed date. You’re combining multiple filtering operations, the first filtering on the search parameters to get matchingFlights and then using it as a source to get the matching flights for the selected day. You could do these in either order, but since the search criteria will typically remove more elements, doing it first improves performance.
With those properties and methods created, you can build the hierarchical data structure you need to display a hierarchical list. Add the following property to the view.
var hierarchicalFlights: [HierarchicalFlightRow] {
// 1
var rows: [HierarchicalFlightRow] = []
// 2
for date in flightDates {
// 3
let newRow = HierarchicalFlightRow(
label: longDateFormatter.string(from: date),
// 4
children: flightsForDay(date: date).map {
hierarchicalFlightRowFromFlight($0)
}
)
rows.append(newRow)
}
return rows
}
Here’s how this builds the hierarchical data structure.
- You create an empty array that will be at the top level of the hierarchy.
- You next loop through each of the dates found in the
flightDatesproperty. - Next, create a new
HierarchicalFlightRowobject for the date. The label for the row will be the long name for the date. You can find the date formatter in DateFormatters.swift. - The
childrenproperty takes a bit more work. First, you useflightsForDay(date:)to get the flights that match the search parameters for this date. You thenmapeach flight into aHierarchicalFlightRowcontaining information on the flight using the previously defined method.
With the hierarchy of data set up, you can now set the list to use it. Change the list in the view to:
// 1
List(hierarchicalFlights, children: \.children) { row in
// 2
if let flight = row.flight {
SearchResultRow(flight: flight)
} else {
Text(row.label)
}
}
While that was a lot of setup work, the result makes the hierarchical list easy to implement:
- The list uses the
hierarchicalFlightscomputed property to get the hierarchical structure. You use thechildrenparameter on theListto pass a keypath to the property of theHierarchicalFlightRowobject that contains the child elements. - You use an
if/letto check if the row contains a flight. If theflightproperty is not null, you display the row for that flight. Otherwise, you show thelabeltext as the row’s contents.
Run the app to see your results. Note that to expand a date, you must tap the disclose arrow at the right of each row.
Note that this structure means it would be easy to add more layers to the hierarchy. For instance, adding the city under the date layer with the flights matching both the city and date as children.
While hierarchical data works well for some types, there’s another way to organize data in a list. In the next section, you’ll break the list into sections by date.
Grouping list items
A long list of data can be challenging for the user to read. Fortunately, the List view supports breaking a list into sections. Combining dynamic data and sections moves into some more complex aspects of displaying data in SwiftUI. In this section, you’ll separate flights into sections by date and add a header and footer to each section.
The good news is that you’ve done most of the needed work in the previous section. Open SearchFlights.swift and delete the HierarchicalFlightRow struct, along with hierarchicalFlightRowFromFlight(_:) and hierarchicalFlights.
Now change the List to the following:
// 1
List {
// 2
ForEach(flightDates, id: \.hashValue) { date in
// 3
Section(
// 4
header: Text(longDateFormatter.string(from: date)),
// 5
footer:
HStack {
Spacer()
Text("Matching flights " +
"\(flightsForDay(date: date).count)")
}
) {
// 6
ForEach(flightsForDay(date: date)) { flight in
SearchResultRow(flight: flight)
}
}
}
// 7
}.listStyle(InsetGroupedListStyle())
This view is more complicated than the layouts you’ve used to this point. Here’s what the code does:
-
You’re declaring a list, but not passing data in for it to iterate. For a more complex and dynamic layout such as this, you’ll often combine multiple
ListandForEachelements. -
You first will display sections for each date that has flights. You pass the list of unique dates using
flightDatesthat you created in the previous section. SinceDatedoesn’t implement theIdentifiableprotocol, you must also inform SwiftUI to use thehashValueproperty of the date as the unique identifier. -
For each date, you start with a
Section. This struct tells SwiftUI how to organize the data. It can contain optional headers and footer views for each section. -
The header will display text showing the date for flights in this section. You can find the date formatter in DateFormatters.swift.
-
The footer displays the number of matching flights in the section. You pass an
HStackso you can use aSpacerto align the information to the right of the footer. -
Inside each section, you use another
ForEachand theflightsForDay(date:)method to loop through only the flights on this section’s date. -
You apply a style to the list that fits the grouped data you’re displaying.
Run the app, and you’ll see the flights now cleanly grouped by date. Type in part of a city name, and you’ll see the view update to reflect the change while still grouping the flights.
Key points
- A
ScrollViewwraps a view within a scrollable region that doesn’t affect the rest of the view. - The
ScrollViewProxylets you change the current position of a list from code. - SwiftUI provides two ways to iterate over data. The
ForEachoption loops through the data allowing you to render a view for each element. - A
Listuses the platform’s list control to display the elements in the data. - Data used with
ForEachandListmust provide a way to identify each element uniquely. You can do this by specifying an attribute that implements theHashableprotocol, have the object implementHasbableand pass it to theidparameter or have your data implement theIdentifiableprotocol. - Building a hierarchical view requires a hierarchical data structure to describe how the view should appear.
- You can split a
ListinSections to organize the data and help the user understand what they see. - You can combine
ForEachandListto create more complex data layouts. This method works well when you want to group data into sections.
Where to go from here?
For more on integrating navigation and views, look at SwiftUI Tutorial: Navigation at https://www.raywenderlich.com/5824937-swiftui-tutorial-navigation.
The WWDC 2019 SwiftUI Essentials video provides an overview of Apple’s guidelines on how views, navigation and lists fit together:
To learn more about the changes in the second version, such as hierarchical lists, watch SwiftUI view Stacks, Grids, and Outlines in SwiftUI from WWDC 2020: