Codable with options

Let’s talk about Codable. This is a quite nice solution to encoding and parsing JSON, that is used everywhere in Swift. It is quite simple, but sometimes you need a little more than just “parse this easy thing”. I recently bumped into something like it and thought that the solution could be helpful for others.

How exactly did I meet this? I reverse engineered KEF API, to be able to control the speakers from the Stream Deck +. This project is quite successful, but it’s a story for another time. While investigating API I found out this piece of JSON (this was part of the current volume request):

"value": {
  "type": "i32_",
  "i32_": 40
}

After some more tinkering I found out that there are several types: standard string_, bool_, and some custom ones, like kefPhysicalSource.

This looks a lot like an enum with associated value, or some kind of union-like structure. Unfortunately, if I try to encode enum with associated values in Swift, I get [totally another structure]. This means that we need to create custom coding.

Let’s figure out the structure first. It contains one constant property: type, that defines which property name to use for the value, and its type. For i32_ it will be an integer value, a boolean for bool_ and string for the string_. Custom values like kefPhysicalSource can be encoded in some different way. Also we will need to create CodingKeys enum with all the options.

In theory this is an easy code to write. For decoding, we just parse type and then create a switch based on its value. Every time we need to parse another type, we add a case to the switch.

public struct RawValue: Codable {
    public var value: Any // Let's skip this Any for a while

    enum CodingKeys: String, CodingKey {
        case type = "type"
        case int32 = "i32_"
        case bool = "bool_"
        // ... other cases
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(String.self, forKey: .type)
        switch type {
            case "i32_":
                value = try container.decode(Int32.self, forKey: .type)
            case "bool_":
                value = try container.decode(Int32.self, forKey: .type)
            // ... other cases
        }
    }

    public func encode(to encoder: Encoder) throws {
        // Here the encoding will be :)
    }
}

We will also need to find out a way of encoding the value correctly. For example, we can check the type of the value, and choose the name of the property based on it.

var container = encoder.container(keyedBy: CodingKeys.self)
switch value {
    case let value as? Int32:
        try container.encode("i32_", forKey: .type)
        try container.encode(value, forKey: .int32)
    // similar code for every type
}

This is a direct solution, and it is not really expandable. The code will be changing every time we need to parse a new type, that will result in more convoluted code, extra bugs and problems. As the code for all types is similar, it will be copy-pasted, that will lead to even more errors. Can we use Swift features to simplify this solution?

Using Swift

Let’s think. First of all, we would like to limit the types that we can use with the RawValue. For example, we would like to allow String, Int32, Bool, custom PhysicalSource, but not UIView or UInt64. This can be made by using the marker protocol (I’ll skip the PhysicalSource for now, we’ll return to it later):

public protocol RawValueType {
}

extension String: RawValueType {
}

extension Int32: RawValueType {
}

extension Bool: RawValueType {
}

Now we can constrain RawValue.value to this protocol which will do the trick:

public struct RawValue<Type: RawValueType>: Codable { 
    public var value: Type?

    // Coding Keys
    // Decoding part
    // Encoding part
}

Now we can’t create a RawValue with a non-parsable type.

Next task is to put JSON property names to a place, where we can use them for everything. The ideal place will be inside already created extensions:

public protocol RawValueType {
    static var encodingType: String { get }
}

extension String: RawValueType {
    public static let encodingType: String = "string_"
}

extension Int32: RawValueType {
    public static let encodingType: String = "i32_"
}

extension Bool: RawValueType {
    public static let encodingType: String = "bool_"
}

Next task is to deal with the CodingKeys. Usually it contains cases with JSON property names, but we need to use different names for different types. So ideally we’d like to use something like this:

enum CodingKeys: CodingKey {
    case type
    case value(type: String)
}

Looks great, but now we need to implement the requirements for the CodingKey protocol. Usually we do this by providing rawValues (String or Int), but in our case we have to do it manually:

// We don't need these two, because we are using strings for the keys
init?(intValue: Int) { nil }
var intValue: Int? { nil }

init?(stringValue: String) {
    self = stringValue == "type" ? .type : .value(type: stringValue)
}

var stringValue: String {
    switch self {
        case .type: return "type"
        case .value(let type): return type
    }
}

Now our CodingKeys will support custom types, and we will not need to update it ever again.

Let’s try to write the encoder now:

public struct RawValue<Type: RawValueType>: Codable {
    public var value: Type?

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(Type.encodingType, forKey: .type)
        let valueKey = CodingKeys.value(type: Type.encodingType)
        try container.encode(value, forKey: valueKey)
    }
}

Looks awesome, but it will not work for now, because the type of our value is RawValueType, that is not Encodable. Let’s make it comply, it only requires the addition of the conformance itself, that’s all.

public protocol RawValueType: Codable { ... }

OK, encoding works now, we need to think about the decoding:

public struct RawValue<Type: RawValueType>: Codable {
    public var value: Type?

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let decodedType = try container.decode(String.self, forKey: .type)
        guard decodedType == Type.encodingType else {
            let message = "\(decodedType) != \(Type.encodingType) :("
            throw DecodingError
                .dataCorruptedError(in: container, debugDescription: message)
        }

        let valueKey = CodingKeys.value(type: Type.encodingType)
        value = try container.decode(Type?.self, forKey: valueKey)
    }

    public func encode(to encoder: Encoder) throws { ... }
}

This code will never change and adding a new type is easy, we only need to make it implement RawValueType protocol.

Wait, we’ve forgotten the PhysicalSource. It is different because it is custom, and may not implement Codable out of the box, in which case we will need to implement it ourselves.

extension PhysicalSource: RawValueType {
    public static let encodingType: String = "kefPhysicalSource"

    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)
        switch string {
            case "usb": self = .usb
            case "standby": self = .standby
            default: self = .unsupported
        }
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
            case .usb: try container.encode("usb")
            case .standby: try container.encode("standby")
            case .unsupported:
                throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Can't encode unknown source"))
        }
    }
}

Result

In this article we went from the direct approach, that is quite unsupportable to the nice solution that uses Swift features, will be statically checked on compilation, and will not require any maintenance. I’d say that this is a success. Did I miss something? ‾\(ツ)/‾

Full code can be found here:

Black Screen on Launch in Scene-Based iOS App in Xcode 14.3

Everything was OK in Xcode 14.2, but when I tried to launch one of my projects from Xcode 14.3 (still the RC, but nevertheless), I got black screen. Some logs told me that AppDelegate was initialized, but no methods related to SceneDelegate were called. Spent couple of hours to figure it out.

Long story short, there is a setting in Build Settings, that generates Info.plist for your app (GENERATE_INFOPLIST_FILE) and another one, that generates Scene Manifest (INFOPLIST_KEY_UIApplicationSceneManifest_Generation). FIrst one I knew and it was already disabled. Second one was a surprise. Turned it off (it did generate empty Scene Manifest and could not load any Scenes because of that) and everything worked again!

Ångström Style System

Building an app requires iteration. Photoshop is a good place to start designing, AppCode is a good place to start developing, but when the first prototype is ready, design and development merge: most changes require coding. When the designer wants to adjust colors and fonts, refine interactions, he asks the developer to do it. This slows down the process and makes experimenting prohibitively costly.

When Ilya and I started making Ångström, we decided to create a flexible style engine, so that tinkering with the design would be easy for both of us.

Design considerations

The stylesheets must be stored separately from the code, be easy to read, write and parse. That’s why we use JSON, not Plist or CSS and keep them in a Dropbox.

The styles must reload on demand in the running app. No recompiling, no reinstalling, no restarting. That’s why we have Shake to Refresh.

The app must open fast no matter how long the stylesheet is. Ångström has ended up having hundreds of style rules, bigger apps will require thousands. That’s why we compile styles into binary.

The code related to styles should be easy to maintain. That’s why we do not address the style variables by string literals, i.e. [styler boolForKey:@"isTopBarHidden"]. A typo would end up being a debugging nightmare. Instead, we create an object with the corresponding fields, style.isTopBarHidden, and let the compiler do its job looking for errors.

Architecture

Ångström Style System has three objects:

  • Styler loads and stores stylesheets, sends notifications if style changes.
  • Style Objects are created by the Styler from the stylesheets and contain typed style values.
  • Style Listeners receive and process notifications on style changes.

Styler is able to generate Style Object classes automatically from JSON. After that it can:

  • Use NSArchiver to save and restore styles in the binary form (for startup speed)
  • Get the values from this class the fastest way possible, simply by accessing its properties (for general application speed and convenience).

For example, style system will transform this JSON:

"cursor": {
    "showTime": 0.2,
    "hideTime": 0.2,

    "color": "@colors.cursor.color",

    "period12": 0.4,
    "timingType12": "linear",
}

into this class:

@interface AGRCursorStyle : ASStyleObject
    @property (…) CGFloat showTime;
    @property (…) CGFloat hideTime;
    @property (…) UIColor *color;
    @property (…) CGFloat period12;
    @property (…) AGRConfigAnimationType timingType12;
@end

ASStyleObject is the base class for all Style Objects.

Workflow

Before using the Styler, we need to parse JSON with the styles definitions:

ASStyler *styler = [ASStyler sharedInstance];
[styler addStylesFromURL:@"styles.json" 
   toClass:[AGRStyle class] 
   pathForSimulatorGeneratedCache:@"SOME_PATH"];

ÅSS classes have prefix "AS". Example classes are prefixed with "AGR" for "Ångström".

Here I use the AGRStyle class as the root class for all my styles. This is the main Style Object. The last parameter is for automatic saving of the binary cache (serialized AGRStyle instance that is used to speed up the application startup). It is saved during Simulator launch and allows me to be sure that this cache is up to date.

Of course this cache must be updated right after style was changed. But this requires IDE plugin.

You can get the style value like this:

ASStyler *styler = [ASStyler sharedInstance];
AGRCursorStyle *style = 
    ((AGSStyle *) styler.styleObject).cursor

This variant allows to get the value and forget about it. A good choice If it is needed just once.

Also you can get the same style another way:

AGRCursorStyle *style = [[AGRCursorStyle alloc] 
   initWithStyleReloadedCallback:
        ^{
             [self styleUpdated];
         }];

This choice allows to create a callback, that will be called when the style is updated. It's a good place to send some a message like [self setNeedsDisplay]; or something that will restyle your view.

After that you can use the style object as a usual Objective-C object.

How can you create this class? When the Style System runs in the Simulator, it can generate all the classes from JSON structure for you and write a ProjectStyles.h/m files with them. Autogeneration can be done with this line of code:

[styler generateStyleClassesForClassPrefix:@"AGR"
        savePath:@"[PATH_TO_CODE]/Styles/"
        needEnumImport:YES];

It will generate AGR[CapitalizedStyleName]Style classes for every style rule in the JSON file. For example, a style rule "editor" will generate a class AGREditorStyle, and if the "editor" style rule contains "toolbar" subrule, it will become AGREditorToolbarStyle. Main class will be AGRStyle.

Ideally, the class generation has to happen in the IDE on the fly during JSON editing, but it requires an IDE plugin and some more coding time. I hope to do this in the future.

Stylesheet format

Stylesheets are hierarchical. Any style can be referenced via all it's parent styles like this: superstyle.parentstyle.style.

Styler supports references. If a value looks like "@another.name", then it is replaced with the value of "another.name" style.

Also there is include support. That allows creating large and detailed file with all the styles and smaller, simplier files for remote editing. Here is the example:

"@include.fonts": {
    "inApp": "fontStyles.json",
    "remote": "http://[SERVER]/fontStyles.json"
},

Includes have optional remote part that will load file from the remote server. Local part is for production, remote for development.

Style name suffixes define value types:

  • color for UIColor,
  • image for UIImage,
  • point, origin, location, position, center for CGPoint,
  • size or dimensions for CGSize,
  • rect, frame, bounds for CGRect,
  • margin(s), padding(s), border for UIEdgeInsets,
  • font for UIFont,
  • textattributes for NSAttributedString attributes (in the end it is a simple NSDictionary but with specific keys).

For example:

"margins": [23, 15, 10, 15]
"separatorColor": "@colors.about.separatorColor"
"appBackgroundColor": "#0f0d0a"
"listTapColor": "@colors.activeColor",

and so on.

Fonts and TextAttributes are dictionaries with specifically named parameters, points/sizes/rects/margins are simple arrays. Here is a font example:

"font": {
    "name": "HelveticaNeue",
    "size": 13
}

And this is a definition of a dictionary for NSAttributedString:

"normalTextAttributes": {
    "font": {
        "name": "HelveticaNeue",
        "size": 18
    },
    "lineBreakMode": "NSLineBreakByTruncatingTail",
    "color": "#990202"
}

You can define other NSAttributedString attributes too.

Style also supports "functions". Right now there are only two of them:

~color.alpha(COLOR, ALPHA)
~color.mix(COLOR1, COLOR2, PART)

First one adds (or replaces) alpha channel to the color, second one takes PART from COLOR1, (1 - PART) from COLOR2 and adds them to get a new color. Something like this:

RESULT = COLOR1*PART + COLOR2*(1 - PART)

Conclusion

I am already using ÅSS in other projects and it helps a lot. If you have any questions or suggestions, drop me a line: alex@lonelybytes.com.

Can’t innovate anymore, my ÅSS! Sorry, could not help :-)

Mastodon