In the next few episodes, we populate the AddLocationView. We break this task up into several smaller steps. We first populate the AddLocationView with stub data. Later in this series, we replace the stub data with data provided by the Core Location framework.

Listing Locations

Open AddLocationViewModel.swift. The view model of the AddLocationView provides the data the view displays. The view doesn't know whether it displays stub data or data provided by the Core Location framework. That is important.

Declare a Published variable property with name locations. The property is of type [Location]. The initial value of the property is the stub data we defined earlier in this series. Notice that we declare the setter of the property privately.

@Published private(set) var locations: [Location] = Location.previews

Declare another property and name it addLocationCellViewModels. The property is of type [AddLocationCellViewModel]. In the body of the computed property, we map the array of Location objects to an array of AddLocationCellViewModel objects the AddLocationView can use to populate a List view.

var addLocationCellViewModels: [AddLocationCellViewModel] {
    locations.map(AddLocationCellViewModel.init)
}

You may be wondering what the purpose is of the locations property. It is true that it doesn't make much sense to use a Published property at this point. Later in this series, we use the locations property to connect the geocoding service to the view model. Don't worry about this for now.

We display the locations in a List view. Open AddLocationView.swift and add a bit of padding to the TextField.

var body: some View {
    VStack {
        TextField(viewModel.textFieldPlaceholder, text: $viewModel.query)
            .padding()
    }
}

We add a List view below the TextField. We ask the view model for an array of view models and pass that array to the initializer of the ForEach struct. The initializer accepts a view builder as its second argument. The view builder, a closure, accepts an item of the array of view models. In the view builder, we create an AddLocationCell object, passing the view model to the initializer.

var body: some View {
    VStack {
        TextField(viewModel.textFieldPlaceholder, text: $viewModel.query)
            .padding()
        List {
            ForEach(viewModel.addLocationCellViewModels) { cellViewModel in
                AddLocationCell(viewModel: cellViewModel)
            }
        }
    }
}

The compiler throws an error we have seen before. The type of the items we pass to the ForEach struct needs to conform to the Identifiable protocol. Open AddLocationCellViewModel.swift and conform the AddLocationCellViewModel struct to the Identifiable protocol. We define a computed property with name id and type String that uniquely identifies the AddLocationCellViewModel object. In the body of the computed property, we return the identifier of the Location object.

import Foundation

struct AddLocationCellViewModel: Identifiable {
    
    // MARK: - Properties
    
    private let location: Location

    // MARK: - Identifiable

    var id: String {
        location.id
    }

	...

}

Revisit AddLocationView.swift. The preview should show the list of locations we defined earlier in this series.

Adding a Location

The user can add a location to the list of locations by tapping a button. Open AddLocationCell.swift. Wrap the VStack in an HStack and add a Button above the VStack. The initializer of the Button accepts an action and a closure that provides the label. We leave the action empty for now.

var body: some View {
    HStack {
        VStack(alignment: .leading) {
            ...
        }
    }
}

In the closure that provides the label, we create an Image view that displays a system image with name plus. We use a few view modifiers to add padding, set the tint of the image to green, and define the size of the image. We also add padding to the Button and set its background color to white.

Button {
    ()
} label: {
    Image(systemName: "plus")
        .padding()
        .tint(.green)
        .frame(width: 5.0, height: 5.0)
}
.padding(.all, 10.0)
.background(.white)

What happens if the user taps the Button? The view notifies another object through a handler, a closure. Declare a property with name didTapPlusButton. The property is a closure that accepts no arguments and returns Void.

import SwiftUI

struct AddLocationCell: View {
    
    // MARK: - Properties
    
    let viewModel: AddLocationCellViewModel

    let didTapPlusButton: () -> Void

	...

}

The closure is invoked when the user taps the Button. We do this by passing a reference to the closure as the first argument, the action, of the initializer of the Button.

Button(action: didTapPlusButton) {
    Image(systemName: "plus")
        .padding()
        .tint(.green)
        .frame(width: 5.0, height: 5.0)
}
.padding(.all, 10.0)
.background(.white)

We finish the implementation of the view by adding a Spacer between the Button and the VStack. We set its width to 20.0 points. We also add a Spacer below the VStack to push the contents of the VStack to the left.

var body: some View {
    HStack {
        Button(action: didTapPlusButton) {
            Image(systemName: "plus")
                .padding()
                .tint(.green)
                .frame(width: 5.0, height: 5.0)
        }
        .padding(.all, 10.0)
        .background(.white)

        Spacer()
            .frame(width: 20.0)

        VStack(alignment: .leading) {
            Text(viewModel.name)
                .font(.headline)
                .foregroundColor(.accentColor)
            Text(viewModel.country)
                .font(.subheadline)
                .foregroundColor(.gray)
            Text(viewModel.coordinates)
                .font(.caption)
                .foregroundColor(.gray)
        }

        Spacer()
    }
}

We fix the preview of the AddLocationCell struct by passing a closure to the initializer. We use trailing closure syntax and leave the closure empty.

struct AddLocationCell_Previews: PreviewProvider {
    static var previews: some View {
        AddLocationCell(viewModel: .init(location: .preview)) {
            ()
        }
    }
}

We apply the same change to the AddLocationView struct. That change also fixes the preview of the AddLocationView struct.

import SwiftUI

struct AddLocationView: View {
    
    // MARK: - Properties
    
    @ObservedObject var viewModel: AddLocationViewModel
    
    // MARK: - View
    
    var body: some View {
        VStack {
            TextField(viewModel.textFieldPlaceholder, text: $viewModel.query)
                .padding()
            List {
                ForEach(viewModel.addLocationCellViewModels) { cellViewModel in
                    AddLocationCell(viewModel: cellViewModel) {

                    }
                }
            }
        }
    }

}

When the user taps the Button of a cell, the view model of the AddLocationView is notified. Open AddLocationViewModel.swift and define a method with name addLocation(with:). The method accepts one argument with name id and type String. In the body of the addLocation(with:) method, we use a guard statement to exit early. The view model inspects the array of Location objects and returns the Location object with the identifier that is passed to the addLocation(with:) method. We complete the implementation later in this series.

func addLocation(with id: String) {
    guard let location = locations.first(where: { $0.id == id }) else {
        return
    }

    // Add Location
}

Revisit AddLocationView.swift. In the handler we pass to the initializer of the AddLocationCell struct, we invoke the addLocation(with:) method of the view's view model we implemented a moment ago.

AddLocationCell(viewModel: cellViewModel) {
    viewModel.addLocation(with: cellViewModel.id)
}

Dismissing the Add Location View

The AddLocationView should be dismissed after the user tapped one of the locations. This is easy to do. In AddLocationView.swift, declare a variable property with name showsAddLocationView. The property is a Binding of type Bool.

import SwiftUI

struct AddLocationView: View {
    
    // MARK: - Properties
    
    @ObservedObject var viewModel: AddLocationViewModel

    var showsAddLocationView: Binding<Bool>

	...

}

In the handler we pass to the initializer of the AddLocationCell struct, we toggle the wrapped value of the showsAddLocationView property.

AddLocationCell(viewModel: cellViewModel) {
    viewModel.addLocation(with: cellViewModel.id)
    showsAddLocationView.wrappedValue.toggle()
}

We need to make two more changes. To fix the preview, we pass a constant binding to the initializer of the AddLocationView struct.

struct AddLocationView_Previews: PreviewProvider {
    static var previews: some View {
        AddLocationView(
            viewModel: .init(),
            showsAddLocationView: .constant(true)
        )
    }
}

In LocationsView.swift, we pass the projected value of the showsAddLocationView property to the initializer of the AddLocationView struct.

AddLocationView(
    viewModel: .init(),
    showsAddLocationView: $showsAddLocationView
)

Let's give it a try. You can run the application in the simulator or use the live preview. Tapping the plus button of one of the locations dismisses the AddLocationView.

What's Next?

With the user interface in place, we can focus on integrating the Core Location framework. That is the focus of the next episode.