In the first part of the Working With Property Lists in Swift series, I showcased through a simple example how easy it is to use plist files in order to read and save data. Using the PropertyListEncoder
and PropertyListDecoder
classes along with the Codable type, encoding and decoding to and from property lists is just a matter of a couple of lines.
However, there is a downside. Codable work instantly out of the box when all properties in a custom type are of basic data types; numbers, string, data, boolean, and a few more (you can find them all in a plist file). Exactly like it happens in the CustomShape sample type that I used for demonstration in the previous part and you can see right next:
1 2 3 4 5 6 7 8 9 10 11 12 |
struct CustomShape { var width: CGFloat = 0 var height: CGFloat = 0 var backgroundColor: String = “000000” var borderColor: String = “000000” var borderWidth: CGFloat = 0 var cornerRadius: CGFloat = 0 var isInteractive: Bool = false var patternImage: Data = Data() } |
What happens when a custom type contains properties of data types incompatible with plists and the Codable type? For example, colors in the CustomShape type are being represented with string values, and pattern image is a Data object. What if we wanted to have UIColor and UIImage objects respectively?
Unfortunately, that’s something that cannot be done directly. UIColor and UIImage are programming entities that cannot be stored natively in property lists, which are XML files. And therefore, they cannot be encoded and decoded using Codable automatically.
But with some additional amount of work we can actually have unsupported types with Codable, and initialize or store them indirectly. And here you are going to meet what takes in order to manage that.
Changing the scenario
At first, let’s make things a bit more interesting, and instead of representing colors as hex with string values in the DefaultShape.plist sample file (presented in the previous part), let’s use arrays of color values. Values on each array match to red, green, blue and alpha respectively, ranging from 0 to 255. The only exception is the alpha value that ranges from 0 to 1:
Note that the key names have changed as well. The key backgroundColor changed to backgroundColorValues, borderColor changed to borderColorValues, and patternImage changed to patternImageData.
Now let make some modifications to the CustomShape type as well. We are changing both backgroundColor
and borderColor
property types to UIColor, and patternImage
from Data to UIImage. Additionally, we are adding the following three new properties so they match to the updated keys in the DefaultShape.plist file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct CustomShape: Codable { var backgroundColor: UIColor = .clear var borderColor: UIColor = .clear var patternImage: UIImage? // New properties: var backgroundColorValues = [CGFloat]() var borderColorValues = [CGFloat]() var patternImageData = Data() … } |
What we want to achieve with the above modifications is to decode respective plist values to the new properties shown above, and then use those to initialize the actual color and image objects that will be assigned to the matching properties.
With the above changes Xcode will show an error saying that the CustomShape type does not conform to protocols Encodable and Decodable. That’s most expected, as UIColor and UIImage are types not compatible with Codable.
To work around that and eventually reach our goal we will do the following:
- We will explicitly specify the properties that conform to Codable, leaving aside those that do not.
- We’ll become in charge of the decoding process, and we’ll manually populate decoded values to the matching properties.
- In the end, we’ll create actual color and image objects using the values decoded in the previous step.
Being in control of decoding
In order to explicitly specify the properties that we want to be part of the encoding/decoding process, it’s necessary to define an enumeration that will be conforming to a specific protocol named CodingKey. We have to nest that enumeration in the custom type, and even though it can have any name, we usually call it CodingKeys.
The cases of that enum actually list the properties we want to include in the encoding or decoding, so they must have the exact same name to those properties:
1 2 3 4 5 |
enum CodingKeys: CodingKey { case width, height, borderWidth, cornerRadius, isInteractive, backgroundColorValues, borderColorValues, patternImageData } |
Next, we are going to implement the following init method coming from the Decodable protocol. Normally, it’s called automatically behind the scenes every time data is being decoded. When implemented explicitly though, we can control the exact values that will be decoded. Note that this one is triggered after we call the init(withPlistAt:)
custom initializer presented in the previous post:
1 2 3 4 5 |
init(from decoder: Decoder) throws { } |
The first move now is to get the data that will be decoded. We do so by getting a keyed representation of the data, where keys are coming from the CodingKeys enumeration previously specified:
1 2 3 |
let container = try decoder.container(keyedBy: CodingKeys.self) |
Using the container
we can now decode each piece of data, or phrased differently, every single key. Let’s see how to do that for the width value:
1 2 3 |
width = try container.decode(CGFloat.self, forKey: .width) |
In the decode(_:forKey:)
method that we access through container
, we specify the data type of the currently decoded value, and the matching case in the CodingKeys enum. The decoded result is assigned to the width
property.
In an exactly similar fashion we can decode the other properties too:
1 2 3 4 5 6 7 8 9 |
height = try container.decode(CGFloat.self, forKey: .height) borderWidth = try container.decode(CGFloat.self, forKey: .borderWidth) cornerRadius = try container.decode(CGFloat.self, forKey: .cornerRadius) isInteractive = try container.decode(Bool.self, forKey: .isInteractive) patternImageData = try container.decode(Data.self, forKey: .patternImageData) backgroundColorValues = try container.decode([CGFloat].self, forKey: .backgroundColorValues) borderColorValues = try container.decode([CGFloat].self, forKey: .borderColorValues) |
Note two things here; the first is the data type provided as argument on each method and depends on what should be decoded. The second is to focus on the last two lines, where we are decoding arrays of values and not single values. Notice that in such cases the actual data type is included in brackets; decoder will know that way that what we are trying to decode is an array.
With decoding in place, we can move forward and initialize the color properties:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
if backgroundColorValues.count == 4, borderColorValues.count == 4 { backgroundColor = UIColor(red: backgroundColorValues[0]/255, green: backgroundColorValues[1]/255, blue: backgroundColorValues[2]/255, alpha: backgroundColorValues[3]) borderColor = UIColor(red: borderColorValues[0]/255, green: borderColorValues[1]/255, blue: borderColorValues[2]/255, alpha: borderColorValues[3]) } |
See that we are proceeding to the colors initialization once we make sure that we have four values on each array (r, g, b, a).
Similarly we can handle the image:
1 2 3 4 5 |
if patternImageData.count > 0 { patternImage = UIImage(data: patternImageData) } |
The entire init(from:)
method is this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) width = try container.decode(CGFloat.self, forKey: .width) height = try container.decode(CGFloat.self, forKey: .height) borderWidth = try container.decode(CGFloat.self, forKey: .borderWidth) cornerRadius = try container.decode(CGFloat.self, forKey: .cornerRadius) isInteractive = try container.decode(Bool.self, forKey: .isInteractive) patternImageData = try container.decode(Data.self, forKey: .patternImageData) backgroundColorValues = try container.decode([CGFloat].self, forKey: .backgroundColorValues) borderColorValues = try container.decode([CGFloat].self, forKey: .borderColorValues) if backgroundColorValues.count == 4, borderColorValues.count == 4 { backgroundColor = UIColor(red: backgroundColorValues[0]/255, green: backgroundColorValues[1]/255, blue: backgroundColorValues[2]/255, alpha: backgroundColorValues[3]) borderColor = UIColor(red: borderColorValues[0]/255, green: borderColorValues[1]/255, blue: borderColorValues[2]/255, alpha: borderColorValues[3]) } if patternImageData.count > 0 { patternImage = UIImage(data: patternImageData) } } |
Color and image objects can now be used normally, even though no UIColor or UIImage exists in the original plist.
Overriding encoding
In a pretty much similar fashion we can override the encoding process and include only properties that can actually be encoded. For that purpose it’s necessary to implement the following initializer:
1 2 3 4 5 |
func encode(to encoder: Encoder) throws { } |
We begin here by getting a container which will be storing all values to be encoded, keyed by the CodingKeys enum:
1 2 3 |
var container = encoder.container(keyedBy: CodingKeys.self) |
Encoding property values now is done like so:
1 2 3 4 5 6 7 |
try container.encode(width, forKey: .width) try container.encode(height, forKey: .height) try container.encode(borderWidth, forKey: .borderWidth) try container.encode(cornerRadius, forKey: .cornerRadius) try container.encode(isInteractive, forKey: .isInteractive) |
Obviously, we are not going to encode the backgroundColor
, borderColor
, and patternImage
properties, because that’s not possible as explained in the beginning of this post. However, we can include in the encoding process the actual values that form the colors and the data that represents the image (if exists).
Starting with the background color, it’s necessary at first to declare four CGFloat variables where we’ll store the red, green, blue and alpha values respectively:
1 2 3 4 5 6 |
var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 |
We can now get color values like so:
1 2 3 |
backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) |
We want the values of these variables to be stored into an array in the resulting property list. For that reason, we’ll include them inside brackets while providing them for encoding:
1 2 3 |
try container.encode([red * 255, green * 255, blue * 255, alpha], forKey: .backgroundColorValues) |
We are going to handle the borderColor
exactly as above:
1 2 3 4 |
borderColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) try container.encode([red * 255, green * 255, blue * 255, alpha], forKey: .borderColorValues) |
There is obviously no reason to declare new variables to keep the color values; we use the previous ones.
Regarding the image, let’s suppose that we want to get the PNG representation of it, which if exists, is added to the container:
1 2 3 4 5 |
if let data = patternImage?.pngData() { try container.encode(data, forKey: .patternImageData) } |
With the steps shown right above, we can now encode the CustomShape instance to a property list, even though there are properties that cannot be actually encoded.
The entire encode(to:)
method is presented right next:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(width, forKey: .width) try container.encode(height, forKey: .height) try container.encode(borderWidth, forKey: .borderWidth) try container.encode(cornerRadius, forKey: .cornerRadius) try container.encode(isInteractive, forKey: .isInteractive) var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) try container.encode([red * 255, green * 255, blue * 255, alpha], forKey: .backgroundColorValues) borderColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) try container.encode([red * 255, green * 255, blue * 255, alpha], forKey: .borderColorValues) if let data = patternImage?.pngData() { try container.encode(data, forKey: .patternImageData) } } |
If we initialize a CustomShape instance, change a few properties and save it using the save(as:)
method that was implemented in the previous post, we will get a new property list file that will be containing all updated values.
1 2 3 4 5 6 7 8 9 10 11 12 |
var shape = CustomShape(withPlistAt: defaultShapeURL) shape.width = 500 shape.height = 250 shape.backgroundColor = .blue shape.borderColor = .yellow shape.borderWidth = 5 shape.cornerRadius = 15 shape.save(as: “myShape”) |
Summary
When a situation is anything else but the perfect one as described in the previous post, then some additional work is what can take us to the desired results. Encoding and decoding custom types that contain properties which cannot be actually included in the process is not impossible. However, more actions and implementation is required from us in order to define a CodingKey-conforming enumeration and override the initializers as presented above; and that’s the place where we should come up with solutions. But in no way the entire process is difficult! Use what you read in this one and the first post as how-to guides, and you will be dealing with property lists at no time!