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.

1.1 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.

1.2 Creating the ListNavDemo Project

Launch Xcode and select the option to create a new Single View App named ListNavDemo with the User Interface option set to SwiftUI.

1.3 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.

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/SwiftUIEssentialsSamples.zip

Once the code samples have been downloaded and unpacked, open a Finder window, locate the CarAssets.xcassets folder and drag and drop it onto the project navigator panel as illustrated in Figure 26‑1:

Figure 26‑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 Project navigator panel to also add it to the project.

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"
}

1.4 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
}

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.

1.5 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.

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)")
    }
}

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 apps.

1.6 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 that 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
    }
}

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.

1.7 Designing the Content View

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

import SwiftUI

struct ContentView: View {

    @ObservedObject var carStore : CarStore = CarStore(cars: carData)
.
.

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)
            }
        }
      }
    }
}
.
.

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

Figure 26‑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 to Subview menu option:

Figure 26‑3

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)
        }
    }
}

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

1.8 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 CarDetails: View {

    let selectedCar: Car

    var body: some View {
        Text"Hello World!")
    }
}

struct CarDetails_Previews: PreviewProvider {
    static var previews: some View {
        CarDetails(selectedCar: carData[0])
    }
}

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:

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" )
                    }
        }
    }
}

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 26‑4:

Figure 26‑4

1.9 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:

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)
            }
        }
    }
}

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)
            }
        }
    }
}

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 that matches the selected car model.

1.10 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 {

    @ObservedObject var carStore : CarStore
    @State var isHybrid = false
    @State var name: String = ""
    @State var description: String = ""
.
.
struct AddNewCar_Previews: PreviewProvider {
    static var previews: some View {
        AddNewCar(carStore: CarStore(cars: carData))
    }
}

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 Working with SwiftUI State, Observable 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()
    }
}

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

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")
            }
        }
}

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)
    }
}

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 simply 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 26‑5 below:

Figure 26‑5

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

1.11 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())
    }
}

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 26‑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.

1.12 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"))
.
.

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 offets: IndexSet) {
        carStore.cars.remove(atOffsets: offets)
    }

    func moveItems(from source: IndexSet, to destination: Int) {
        carStore.cars.move(fromOffsets: source, toOffset: destination)
    }
}

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, list contents should update to reflect the changes:

Figure 26‑7

1.13 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.