In this episode, we revisit the addLocation(with:) method of the AddLocationViewModel class. In that method, the view model stores the location the user selected in the user's defaults database.

Setting the Requirements

Open AddLocationViewModel.swift and navigate to the addLocation(with:) method. In the previous episode, we persisted the list of locations in the user's defaults database by converting the list of locations to a Data object and writing that Data object to the user's defaults database. This means that we need to take several steps to store the location the user selected in the user's defaults database.

We need to get the list of locations as a Data object from the user's defaults database, decode it to a list of locations, add the location to the list of locations, encode the list of locations to a Data object, and write the Data object to the user's defaults database.

There are several ways to implement this solution. I prefer to keep the implementation of the addLocation(with:) method clean and simple. This is what I have in mind. In the addLocation(with:) method of the AddLocationViewModel class, we invoke an addLocation(_:) method on the UserDefaults singleton. That method accepts a Location object and is throwing, which means we prefix the method call with the try keyword and wrap it in a do-catch statement. In the catch clause, we print the error to the console.

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

    do {
        try UserDefaults.standard.addLocation(location)
    } catch {
        print("Unable to Add Location \(error)")
    }
}

With the implementation of the addLocation(with:) method in place, we can work backwards and fill in the gaps. The heavy lifting is handled by the UserDefaults class.

Extending the User Defaults Class

Open UserDefaults+Helpers.swift and define the addLocation(_:) method. The addLocation(_:) method accepts a Location object as its only argument and is throwing.

func addLocation(_ location: Location) throws {
	
}

In the body of the addLocation(_:) method, we declare a variable property with name locations of type [Location]. The locations variable is a buffer to which we add the Location object that is passed to the addLocation(_:) method.

func addLocation(_ location: Location) throws {
    var locations: [Location] = []
}

We use an if statement and optional binding to get the list of locations as a Data object from the user's defaults database. The Data object is stored in a constant with name locationsData. If the user's defaults database doesn't have a value for the locations key, the locations variable remains unchanged, that is, an empty array.

func addLocation(_ location: Location) throws {
    var locations: [Location] = []

    if let locationsData = data(forKey: Keys.locations) {
		
    }
}

In the body of the if statement, we use a JSONDecoder instance to decode the Data object to a an array of Location objects by invoking the decode(_:from:) method. The decode(_:from:) method is a throwing method, but we use the try? keyword to fail silently. If the decoding of the Data object fails, we fall back to an empty array. Why do we take this approach?

func addLocation(_ location: Location) throws {
    var locations: [Location] = []

    if let locationsData = data(forKey: Keys.locations) {
        locations = (try? JSONDecoder().decode(
            [Location].self,
            from: locationsData
        )) ?? []
    }
}

If the decoding of the Data object fails, then that means the data stored in the user's defaults database is corrupt. This can happen if we modify the implementation of the Location struct without updating the list of locations stored in the user's defaults database. If we throw an error if the decoding fails, then the user won't be able to add a location. In other words, if we don't fall back to an empty array if the decoding fails, then adding a location is broken for as long as the user's defaults database contains the corrupt value for the locations key. The user has no way to remove the value for the locations key. The only option they have is reinstalling the application and that isn't a great user experience.

The next step is adding the Location object to the list of locations.

func addLocation(_ location: Location) throws {
    var locations: [Location] = []

    if let locationsData = data(forKey: Keys.locations) {
        locations = (try? JSONDecoder().decode(
            [Location].self,
            from: locationsData
        )) ?? []
    }

    locations.append(location)
}

We then encode the list of locations to a Data object using a JSONEncoder instance. We invoke its encode(_:) method, passing in the array of Location objects. Note that we use the try keyword without the question mark. Encoding the list of locations should never fail as long as the Location struct conforms to the Encodable protocol.

func addLocation(_ location: Location) throws {
    var locations: [Location] = []

    if let locationsData = data(forKey: Keys.locations) {
        locations = (try? JSONDecoder().decode(
            [Location].self,
            from: locationsData
        )) ?? []
    }

    locations.append(location)

    let locationsData = try JSONEncoder().encode(locations)
}

The last step is updating the value for the locations key in the user's defaults database.

func addLocation(_ location: Location) throws {
    var locations: [Location] = []

    if let locationsData = data(forKey: Keys.locations) {
        locations = (try? JSONDecoder().decode(
            [Location].self,
            from: locationsData
        )) ?? []
    }

    locations.append(location)

    let locationsData = try JSONEncoder().encode(locations)
    set(locationsData, forKey: Keys.locations)
}

Improving the Implementation

The UserDefaults class supports only a handful of data types and that can be limiting. That is the reason we store the list of locations as a Data object. What follows isn't strictly necessary for this series, but it makes it easier to store unsupported types in the user's defaults database. Add a fileprivate extension for the UserDefaults class to UserDefaults+Helpers.swift.

fileprivate extension UserDefaults {
	
}

We declare two methods, a method to decode data for a given key and a method to encode an object for a given key. Both methods make use of generics to do their magic. The decode(_:forKey:) method defines two parameters, the type to decode the Data object to and the key in the user's defaults database. Notice that T, the generic type, is required to conform to the Decodable protocol. The method is throwing and its return type is T?.

fileprivate extension UserDefaults {

    func decode<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
		
    }

}

In the body of the decode(_:forKey:) method, we use a guard statement to safely unwrap the result of the data(forKey:) method and return nil if the key doesn't exist.

fileprivate extension UserDefaults {

    func decode<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
        guard let data = data(forKey: key) else {
            return nil
        }
    }

}

The next step is decoding the Data object using a JSONDecoder instance and returning the result of the decoding operation. Notice that we use the try? keyword to fail silently.

fileprivate extension UserDefaults {

    func decode<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
        guard let data = data(forKey: key) else {
            return nil
        }

        return try? JSONDecoder().decode(type, from: data)
    }

}

The encode(_:forKey:) method also defines two parameters, an object of type T and the key for the Data object in the user's defaults database. Notice that the generic parameter T is required to conform to the Encodable protocol. Like the decode(_:forKey:) method, the encode(_:forKey:) method is throwing.

fileprivate extension UserDefaults {

    func decode<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
        guard let data = data(forKey: key) else {
            return nil
        }

        return try? JSONDecoder().decode(type, from: data)
    }

    func encode<T: Encodable>(_ value: T, forKey key: String) throws {
    	
    }

}

In the body of the encode(_:forKey:) method, we use a JSONEncoder instance to encode the object of type T to a Data object. We pass the Data object to the set(_:forKey:) method of the UserDefaults class.

fileprivate extension UserDefaults {

    func decode<T: Decodable>(_ type: T.Type, forKey key: String) throws -> T? {
        guard let data = data(forKey: key) else {
            return nil
        }

        return try? JSONDecoder().decode(type, from: data)
    }

    func encode<T: Encodable>(_ value: T, forKey key: String) throws {
        let data = try JSONEncoder().encode(value)

        set(data, forKey: key)
    }

}

With these methods in place, we can simplify the addLocation(_:) method. We declare a variable with name locations. The value of the locations variable is the result of the decode(_:forKey:) method we implemented a few moments ago. The return type of the decode(_:forKey:) method is an optional type so we fall back to an empty array.

func addLocation(_ location: Location) throws {
    var locations = try decode(
        [Location].self,
        forKey: Keys.locations
    ) ?? []
}

The next step should look familiar. We add the Location object to the array of Location objects.

func addLocation(_ location: Location) throws {
    var locations = try decode(
        [Location].self,
        forKey: Keys.locations
    ) ?? []

    locations.append(location)
}

In the last step, we encode the list of locations to a Data object and persist it in the user's defaults database. That's it.

func addLocation(_ location: Location) throws {
    var locations = try decode(
        [Location].self,
        forKey: Keys.locations
    ) ?? []

    locations.append(location)

    try encode(
        locations,
        forKey: Keys.locations
    )
}

Build and run the application to make sure adding locations works as advertised. Navigate to the AddLocationView, enter the name of a town or city in the TextField, add a location, and dismiss the AddLocationView. The location you added should be displayed by the LocationsView.

What's Next?

The LocationsViewModel and AddLocationViewModel classes are tightly coupled to the UserDefaults class. In the next episode, we explore a few options to reduce or eliminate that coupling.