You may remember that the LocationsView displays a static list of locations. We need to change that if we want to put the AddLocationView to use. We keep it simple and store the list of locations in the user's defaults database. Let's get to work.
Storing the List of Locations
The interface of the UserDefaults class is simple and easy to use, but that is also a potential source of problems. I prefer to wrap the API in an interface that avoids the need for string literals at the call site. Let me show you what I have in mind.
Add a Swift file to the Extensions group and name it UserDefaults+Helpers.swift. Create an extension for the UserDefaults class.
import Foundation
extension UserDefaults {
}
Declare a computed property with name Locations of type Data?. We declare a getter and a setter for the computed property. In the getter, we invoke the data(forKey:) method and return the result. The key we pass to the data(forKey:) method is locations.
import Foundation
extension UserDefaults {
var locations: Data? {
get {
data(forKey: "locations")
}
set {
}
}
}
In the setter, we invoke the set(_:forKey:) method. The first argument is newValue, the value that is passed to the setter. The second argument is the key we passed in the getter to the data(forKey:) method, locations.
import Foundation
extension UserDefaults {
var locations: Data? {
get {
data(forKey: "locations")
}
set {
set(newValue, forKey: "locations")
}
}
}
I tend to avoid string literals as much as possible to avoid typos and difficult to find bugs. Declare a private enum with name Keys. The Keys enum declares a static, constant property with name locations. Its value is a string, locations. We use the Keys enum in the getter and the setter of the computed locations property.
import Foundation
extension UserDefaults {
// MARK: - Types
private enum Keys {
static let locations = "locations"
}
// MARK: - Public API
var locations: Data? {
get {
data(forKey: Keys.locations)
}
set {
set(newValue, forKey: Keys.locations)
}
}
}
Observing the User's Defaults Database
The LocationsView should reflect what is stored in the user's defaults database. This means that its view model should notify the LocationsView when the list of locations stored in the user's defaults database changed. We can accomplish that with the help of the Combine framework.
Open LocationsViewModel.swift and add an import statement for the Combine framework. We conform the LocationsViewModel struct to the ObservableObject protocol. The compiler doesn't like this change because LocationsViewModel is a struct, a value type, and only reference types can conform to the ObservableObject protocol. Let's change the LocationsViewModel struct to a final class to meet the requirement of the ObservableObject protocol.
import Combine
import Foundation
@MainActor
final class LocationsViewModel: ObservableObject {
...
}
The next step is updating the computed locationCellViewModels property. We change it to a stored property and set its initial value to an empty array. We apply the Published property wrapper and declare the setter privately.
@Published private(set) var locationCellViewModels: [LocationCellViewModel] = []
Declare a method with name start(). As the name suggests, the view invokes this method to start the view model. In this method, the view model subscribes to a publisher that emits an event every time the value of the locations key changes in the user's defaults database.
func start() {
}
The view model accesses the UserDefaults singleton through the standard class property and invokes the publisher(for:) method, passing in a key path. Note that this works because we declared the computed locations property in the extension for the UserDefaults class earlier in this episode.
func start() {
UserDefaults.standard.publisher(for: \.locations)
}
We are not interested in nil values so we use the compactMap operator to filter those out.
func start() {
UserDefaults.standard.publisher(for: \.locations)
.compactMap { $0 }
}
Remember that the type of the computed locations property of the UserDefaults class is Data?. We use the decode operator to transform the Data object to an array of Location objects. The decode(type:decoder:) method accepts the type of the decoded data as its first argument and a decoder, an object that conforms to the TopLevelDecoder protocol, as its second argument. In this example, we use a JSONDecoder instance.
func start() {
UserDefaults.standard.publisher(for: \.locations)
.compactMap { $0 }
.decode(
type: [Location].self,
decoder: JSONDecoder()
)
}
We fail gracefully using the replaceError operator. If the publisher emits an error, for example, because the data stored in the user's defaults database is corrupt, the publisher emits an empty array.
func start() {
UserDefaults.standard.publisher(for: \.locations)
.compactMap { $0 }
.decode(
type: [Location].self,
decoder: JSONDecoder()
)
.replaceError(with: [])
}
The view expects an array of LocationCellViewModel objects. We transform the array of Location objects to an array of LocationCellViewModel objects using the map operator. In the closure we pass to the map operator, we invoke the map(_:) method on the array of Location objects, passing a reference to the initializer of the LocationCellViewModel struct.
func start() {
UserDefaults.standard.publisher(for: \.locations)
.compactMap { $0 }
.decode(
type: [Location].self,
decoder: JSONDecoder()
)
.replaceError(with: [])
.map { $0.map(LocationCellViewModel.init(location:)) }
}
We wrap the publisher the map operator returns with a type eraser using the eraseToAnyPublisher() method.
func start() {
UserDefaults.standard.publisher(for: \.locations)
.compactMap { $0 }
.decode(
type: [Location].self,
decoder: JSONDecoder()
)
.replaceError(with: [])
.map { $0.map(LocationCellViewModel.init(location:)) }
.eraseToAnyPublisher()
}
We assign the values the publisher emits to the locationCellViewModels property using the assign(to:) method. The assign(to:) method defines one parameter, an in-out parameter. We pass the locationCellViewModels publisher as an argument to the in-out parameter.
func start() {
UserDefaults.standard.publisher(for: \.locations)
.compactMap { $0 }
.decode(
type: [Location].self,
decoder: JSONDecoder()
)
.replaceError(with: [])
.map { $0.map(LocationCellViewModel.init(location:)) }
.eraseToAnyPublisher()
.assign(to: &$locationCellViewModels)
}
Displaying the List of Locations
We need to make a few small changes to the LocationsView. Open LocationsView.swift. The viewModel property is no longer a constant property. We replace let with var, apply the ObservedObject property wrapper, and declare the setter privately.
import SwiftUI
struct LocationsView: View {
// MARK: - Properties
@ObservedObject private(set) var viewModel: LocationsViewModel
...
}
We also need to invoke the view model's start() method. We use the onAppear view modifier to invoke the view model's start() method before the view appears.
var body: some View {
NavigationView {
...
}
.onAppear {
viewModel.start()
}
}
Build and run the application. It looks like there is one more problem we need to resolve. The Objective-C runtime throws a fatal error because the locations key path isn't exposed to the Objective-C runtime.

Open UserDefaults+Helpers.swift and apply the objc attribute to the computed locations property. This is easily overlooked, but, as you can see, the Objective-C runtime complains about this pretty explicitly. The danger is that the compiler doesn't catch these kinds of problems at compile time so you need to make sure to diligently apply the objc attribute when needed.
import Foundation
extension UserDefaults {
// MARK: - Types
private enum Keys {
static let locations = "locations"
}
// MARK: - Public API
@objc var locations: Data? {
get {
data(forKey: Keys.locations)
}
set {
set(newValue, forKey: Keys.locations)
}
}
}
Build and run the application one more time. The application should no longer crash.
What's Next?
The LocationsView displays an empty view because no locations are stored in the user's defaults database. We address that in the next episode by finishing the implementation of the addLocation(with:) method of the AddLocationViewModel class.