A SwiftUI List and Navigation Tutorial

The previous chapter introduced the List, NavigationView and NavigationLink views and explained how these can be used to present a navigable and editable list of items to the user. This chapter will work through the creation of a project intended to provide a practical example of these concepts.

About the ListNavDemo Project

When completed, the project will consist of a List view in which each row contains a cell displaying image and text information. Selecting a row within the list will navigate to a details screen containing more information about the selected item. In addition, the List view will include options to add and remove entries and to change the ordering of rows in the list.

The project will also make extensive use of state properties and observable objects to keep the user interface synchronized with the data model.

Creating the ListNavDemo Project

Launch Xcode and select the option to create a new Multiplatform App named ListNavDemo.

Preparing the Project

Before beginning development of the app project, some preparatory work needs to be performed involving the addition of image and data assets which will be needed later in the chapter.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

The assets to be used in the project are included in the source code sample download provided with the book available from the following URL:

https://www.ebookfrenzy.com/code/SwiftUI-iOS14-CodeSamples.zip

Once the code samples have been downloaded and unpacked, open a Finder window, locate the CarAssets. xcassets folder and drag and drop it into the Shared folder within the project navigator panel as illustrated in Figure 29-1:

Figure 29-1

When the options dialog appears, enable the Copy items if needed option so that the assets are included within the project folder before clicking on the Finish button. With the image assets added, find the carData.json file located in the CarData folder and drag and drop it onto the Shared folder in the Project navigator panel to also add it to the project.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

This JSON file contains entries for different hybrid and electric cars including a unique id, model, description, a Boolean property indicating whether or not it is a hybrid vehicle and the filename of the corresponding image of the car in the asset catalog. The following, for example, is the JSON entry for the Tesla Model 3:

{
    "id": "aa32jj887hhg55",
    "name": "Tesla Model 3",
    "description": "Luxury 4-door all-electric car. Range of 310 miles. 0-60mph in 3.2 seconds ",
    "isHybrid": false,
    "imageName": "tesla_model_3"
}Code language: JSON / JSON with Comments (json)

Adding the Car Structure

Now that the JSON file has been added to the project, a structure needs to be declared to represent each car model. Add a new Swift file to the project by selecting the File -> New -> File… menu option, selecting Swift File in the template dialog and clicking on the Next button. On the subsequent screen, name the file Car.swift before clicking on the Create button.

Once created, the new file will load into the code editor where it needs to be modified so that it reads as follows:

import SwiftUI
 
struct Car : Codable, Identifiable {
    var id: String
    var name: String
    
    var description: String
    var isHybrid: Bool
    
    var imageName: String
}Code language: Swift (swift)

As we can see, the structure contains a property for each field in the JSON file and is declared as conforming to the Identifiable protocol so that each instance can be uniquely identified within the List view.

Loading the JSON Data

The project is also going to need a way to load the carData.json file and translate the car entries into an array of Car objects. For this we will add another Swift file containing a convenience function that reads the JSON file and initializes an array which can be accessed elsewhere in the project.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

Using the steps outlined previously, add another Swift file named CarData.swift to the project and modify it as follows:

import UIKit
import SwiftUI
 
var carData: [Car] = loadJson("carData.json")
 
func loadJson<T: Decodable>(_ filename: String) -> T {
    let data: Data
    
    guard let file = Bundle.main.url(forResource: filename, 
                                  withExtension: nil)
    else {
        fatalError("\(filename) not found.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Could not load \(filename): \(error)")
    }
    
    do {
        return try JSONDecoder().decode(T.self, from: data)
    } catch {
        fatalError("Unable to parse \(filename): \(error)")
    }
}Code language: Swift (swift)

The file contains a variable referencing an array of Car objects which is initialized by a call to the loadJson() function. The loadJson() function is a standard example of how to load a JSON file and can be used in your own projects.

Adding the Data Store

When the user interface has been designed, the List view will rely on an observable object to ensure that the latest data is always displayed to the user. So far, we have a Car structure and an array of Car objects loaded from the JSON file to act as a data source for the project. The last step in getting the data ready for use in the app is to add a data store structure. This structure will need to contain a published property which can be observed by the user interface to keep the List view up to date. Add another Swift file to the project, this time named CarStore. swift, and implement the class as follows:

import SwiftUI
import Combine
 
class CarStore : ObservableObject {
    
    @Published var cars: [Car]
    
    init (cars: [Car] = []) {
        self.cars = cars
    }
}Code language: Swift (swift)

This file contains a published property in the form of an array of Car objects and an initializer which is passed the array to be published. With the data side of the project complete, it is now time to begin designing the user interface.

Designing the Content View

Select the ContentView.swift file and modify it as follows to add a state object binding to an instance of CarStore, passing through to its initializer the carData array created in the CarData.swift file:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var carStore : CarStore = CarStore(cars: carData)
.
.Code language: Swift (swift)

The content view is going to require a List view to display information about each car. Now that we have access to the array of cars via the carStore property, we can use a ForEach loop to display a row for each car model. The cell for each row will be implemented as an HStack containing an Image and a Text view, the content of which will be extracted from the carData array elements. Remaining in the ContentView.swift file, delete the existing “Hello, world” Text view and implement the list as follows:

.
.
var body: some View {
    
        List {
            ForEach (carStore.cars) { car in
            
                HStack {
                    Image(car.imageName)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 100, height: 60)
                    Text(car.name)
               }
           }
      }
    }
}
.
.Code language: Swift (swift)

With the change made to the view, use the preview canvas to verify that the list populates with content as shown in Figure 29-2:

Figure 29-2

Before moving to the next step in the tutorial, the cell declaration will be extracted to a subview to make the declaration tidier. Within the editor, hover the mouse pointer over the HStack declaration and hold down the keyboard Command key so that the declaration highlights. With the Command key still depressed, left-click and select the Extract Subview menu option:

Figure 29-3

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

Once the view has been extracted, change the name from the default ExtractedView to ListCell. Because the ListCell subview is used within a ForEach statement, the current car will need to be passed through when it is used. Modify both the ListCell declaration and the reference as follows to remove the syntax errors:

    var body: some View {
        
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
        }
    }
}
 
struct ListCell: View {
    
    var car: Car
    
    var body: some View {
        HStack {
            Image(car.imageName)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 100, height: 60)
            Text(car.name)
        }
    }
}Code language: Swift (swift)

Use the preview canvas to confirm that the extraction of the cell as a subview has worked successfully.

Designing the Detail View

When a user taps a row in the list, a detail screen will appear showing additional information about the selected car. The layout for this screen will be declared in a separate SwiftUI View file which now needs to be added to the project. Use the File -> New -> File… menu option once again, this time selecting the SwiftUI View template option and naming the file CarDetail.

When the user navigates to this view from within the List, it will need to be passed the Car instance for the selected car so that the correct details are displayed. Begin by adding a property to the structure and configuring the preview provider to display the details of the first car in the carData array within the preview canvas as follows:

import SwiftUI
 
struct CarDetail: View {
    
    let selectedCar: Car
    
    var body: some View {
        Text"Hello, world!")
    }
}
 
struct CarDetail_Previews: PreviewProvider {
    static var previews: some View {
        CarDetails(selectedCar: carData[0])
    }
}Code language: Swift (swift)

For this layout, a Form container will be used to organize the views. This is a container view that allows views to be grouped together and divided into different sections. The Form also places a line divider between each child view. Within the body of the CarDetail.swift file, implement the layout as follows:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

var body: some View {
        Form {
            Section(header: Text("Car Details")) {
                Image(selectedCar.imageName)
                    .resizable()
                    .cornerRadius(12.0)
                    .aspectRatio(contentMode: .fit)
                    .padding()
                    
                    Text(selectedCar.name)
                        .font(.headline)
            
                    Text(selectedCar.description)
                        .font(.body)
        
                    HStack {
                        Text("Hybrid").font(.headline)
                        Spacer()
                        Image(systemName: selectedCar.isHybrid ? 
                                "checkmark.circle" : "xmark.circle" )
                    }   
            }
     }
}Code language: Swift (swift)

Note that the Image view is configured to be resizable and scaled to fit the available space while retaining the aspect ratio. Rounded corners are also applied to make the image more visually appealing and either a circle or checkmark image is displayed in the HStack based on the setting of the isHybrid Boolean setting of the selected car.

When previewed, the screen should match that shown in Figure 29-4:

Figure 29-4

Adding Navigation to the List

The next step in this tutorial is to return to the List view in the ContentView.swift file and implement navigation so that selecting a row displays the detail screen populated with the corresponding car details.

With the ContentView.swift file loaded into the code editor, locate the ListCell subview declaration and embed the HStack in a NavigationLink with the CarDetail view configured as the destination, making sure to pass through the selected car object:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

struct ListCell: View {
    
    var car: Car
    
    var body: some View {
        
        NavigationLink(destination: CarDetail(selectedCar: car)) {
            HStack {
                Image(car.imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 100, height: 60)
                Text(car.name)
            }
        }
    }
}Code language: Swift (swift)

For this navigation link to function, the List view must also be embedded in a NavigationView as follows:

var body: some View {
    
    NavigationView {
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
        }
    }
}Code language: Swift (swift)

Test that the navigation works by clicking on the Live Preview button in the preview canvas and selecting different rows, confirming each time that the detail view appears containing information matching the selected car model.

Designing the Add Car View

The final view to be added to the project represents the screen to be displayed when the user is adding a new car to the list. Add a new SwiftUI View file to the project named AddNewCar.swift including some state properties and a declaration for storing a reference to the carStore binding (this reference will be passed to the view from the ContentView when the user taps an Add button). Also modify the preview provider to pass the carData array into the view for testing purposes:

import SwiftUI
 
struct AddNewCar: View {
    
    @StateObject var carStore : CarStore  
    @State private var isHybrid = false
    @State private var name: String = ""
    @State private var description: String = ""
.
.
struct AddNewCar_Previews: PreviewProvider {
    static var previews: some View {
        AddNewCar(carStore: CarStore(cars: carData))
    }
}Code language: Swift (swift)

Next, add a new subview to the declaration that can be used to display a Text and TextField view pair into which the user will enter details of the new car. This subview will be passed a String value for the text to appear on the Text view and a state property binding into which the user’s input is to be stored. As outlined in the chapter entitled “SwiftUI State Properties, Observable, State and Environment Objects”, a property must be declared using the @Binding property wrapper if the view is being passed a state property. Remaining in the AddNewCar.swift file, implement this subview as follows:

struct DataInput: View {
    
    var title: String
    @Binding var userInput: String
    
    var body: some View {
        VStack(alignment: HorizontalAlignment.leading) {
            Text(title)
                .font(.headline)
            TextField("Enter \(title)", text: $userInput)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
        }
        .padding()
    }
}Code language: Swift (swift)

With the subview added, declare the user interface layout for the main view as follows:

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

var body: some View {
    
    Form {
        Section(header: Text("Car Details")) {
            Image(systemName: "car.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .padding()
            
            DataInput(title: "Model", userInput: $name)
            DataInput(title: "Description", userInput: $description)
 
            Toggle(isOn: $isHybrid) {
                    Text("Hybrid").font(.headline)
            }.padding()        
        }
        
        Button(action: addNewCar) {
            Text("Add Car")
            }
        }
}Code language: Swift (swift)

Note that two instances of the DataInput subview are included in the layout together with an Image view, a Toggle and a Button. The Button view is configured to call an action method named addNewCar when clicked. Within the body of the ContentView declaration, add this function now so that it reads as follows:

.
.
Button(action: addNewCar) {
            Text("Add Car")
            }
        }
    }
 
    func addNewCar() {
        let newCar = Car(id: UUID().uuidString, 
                      name: name, description: description, 
                      isHybrid: isHybrid, imageName: "tesla_model_3" )
        
        carStore.cars.append(newCar)
    }
}Code language: Swift (swift)

The new car function creates a new Car instance using the Swift UUID() method to generate a unique identifier for the entry and the content entered by the user. For simplicity, rather than add code to select a photo from the photo library the function reuses the tesla_model_3 image for new car entries. Finally, the new Car instance is appended to the carStore car array.

When rendered in the preview canvas, the AddNewCar view should match Figure 29-5 below:

Figure 29-5

With this view completed, the next step is to modify the ContentView layout to include Add and Edit buttons.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

Implementing Add and Edit Buttons

The Add and Edit buttons will be added to a navigation bar applied to the List view in the ContentView layout. The Navigation bar will also be used to display a title at the top of the list. These changes require the use of the navigationBarTitle() and navigationBarItems() modifiers as follows:

var body: some View {
    
    NavigationView {
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
        }
        .navigationBarTitle(Text("EV Cars"))
        .navigationBarItems(leading: NavigationLink(destination: 
                      AddNewCar(carStore: self.carStore)) {
            Text("Add")
                .foregroundColor(.blue)
        }, trailing: EditButton())
    }
}Code language: PHP (php)

The Add button is configured to appear at the leading edge of the navigation bar and is implemented as a NavigationLink configured to display the AddNewCar view, passing through a reference to the observable carStore binding.

The Edit button, on the other hand, is positioned on the trailing edge of the navigation bar and is configured to display the built-in EditButton view. A preview of the modified layout at this point should match the following figure:

Figure 29-6

Using Live Preview mode, test that the Add button displays the new car screen and that entering new car details and clicking the Add Car button causes the new entry to appear in the list after returning to the content view screen.

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

Adding the Edit Button Methods

The final task in this tutorial is to add some action methods to be used by the EditButton view added to the navigation bar in the previous section. Because these actions are to be available for every row in the list, the actions must be applied to the list cells as follows:

var body: some View {
    
    NavigationView {
            List {
                ForEach (carStore.cars) { car in
                    ListCell(car: car)
            }
            .onDelete(perform: deleteItems)
            .onMove(perform: moveItems)
        }
        .navigationBarTitle(Text("EV Cars"))
.
.Code language: Swift (swift)

Next, implement the deleteItems() and moveItems() functions within the scope of the body declaration:

.
.
.navigationBarTitle(Text("EV Cars"))
            .navigationBarItems(leading: NavigationLink(destination: AddNewCar(carStore: self.carStore)) {
                Text("Add")
                    .foregroundColor(.blue)
            }, trailing: EditButton())
        }
    }
    
    func deleteItems(at offsets: IndexSet) {
        carStore.cars.remove(atOffsets: offsets)
    }
    
    func moveItems(from source: IndexSet, to destination: Int) {
        carStore.cars.move(fromOffsets: source, toOffset: destination)
    }
}Code language: Swift (swift)

In the case of the deleteItems() function, the offsets of the selected rows are provided and used to remove the corresponding elements from the car store array. The moveItems() function, on the other hand, is called when the user moves rows to a different location within the list. This function is passed source and destination values which are used to match the row position in the array.

Using Live Preview, click the Edit button and verify that it is possible to delete rows by tapping the red delete icon next to a row and to move rows by clicking and dragging on the three horizontal lines at the far-right edge of a row. In each case, the list contents should update to reflect the changes:

Figure 29-7

 

You are reading a sample chapter from SwiftUI Essentials – iOS 16 Edition.

Buy the full book now in eBook (PDF, ePub, and Kindle) or Print format.

The full book contains 64 chapters and over 560 pages of in-depth information.

Learn more.

Preview  Buy eBook  Buy Print

 

Summary

The main objective of this chapter has been to provide a practical example of using lists, navigation views and navigation links within a SwiftUI project. This included the implementation of dynamic lists and list editing features. The chapter also served to reinforce topics covered in previous chapters including the use of observable objects, state properties and property bindings. The chapter also introduced some additional SwiftUI features including the Form container view, navigation bar items and the TextField view.