I have an appearance manager that reads styles from a plist file and applies then throughout the app through the use of the appearance proxy and through notifications to various custom subclasses of the standard UIKit views and controls.
This works great and allows for of a lot of easy features like different coloured themes or dark modes. The major downside right now is that none of the changes to the plist are reflected in Storyboards or Xib files.
I like using Interface Builder as it means less code that I have to write and, therefore, fewer opportunities for bugs, especially when dealing with AutoLayout. Unfortunately, IB has some severe limitations, the major one for me being that there’s no way to set a global default range of styles that is then reflected throughout the Storyboard (other than `tintColor`).
One of the advantages of having a design tool is to instantly see changes rather than having to build and run an app in order to see them. This advantage is negated somewhat when I have to manually change the font on every control when the design is updated.
Seeing as I’m already using a centralised plist file to manage my app’s appearance, it would be great if I could start seeing changes I made to this file in Storyboards and Xib files too.
After digging into the process that spins up when a custom `@IBDesignable` class is rendered, I found that Xcode creates a unique environment that is entirely separate to the app. It makes sense to compile only the absolutely minimum it needs to in order to render the views but that does mean that it severely limits the places where the plist file is visible to the process.
Nothing within the app bundle can be called nor can any custom environment variables set, which means that the Appearance manager has no knowledge of the wider app context in which it sits. However, it does have an environment and this environment does include a reference to a location that should exist on any system that is running Xcode, namely the `~/Library/Developer/Xcode/UserData` directory.
(The standard `FileManager` URLs aren’t any more helpful in this context, as their reference are relative to a `Simulator Devices` directory. For example, the `.libraryDirectory` points to `~/Library/Developer/Xcode/UserData/IB Support/Simulator Devices/<UUID>/data/Library/`, rather than to `~/Library`.)
I gave the plist file a generic name (`IBAppearance.plist`) and then hardcoded this name as a string directly into the Appearance manager. I also created a special method (below) in the appearance manager that is only called when a custom subclass of a standard UIKit view has its `prepareForInterfaceBuilder()` method called.
This means that none of the Interface Builder code is called when the app is running normally. It also means that, for the appearance styles to work, there needs to be at least one custom view somewhere in the Interface Builder document (once there is, however, all the styles will be applied—even those affecting standard UIKit views).
I then tried a symlinked copy of the plist file that exists in the project bundle instead and it broke everything. The default `FileManager` does not resolve aliases automatically and I had to add some additional code to check if the file was an alias and, if so, resolve that alias.
Here’s the complete method in the appearance manager that searches for `IBAppearance.plist` (which itself is a symlink to `Appearance.plist` in the project workspace) and then loads those styles:
/** Loads the styles from a `~/Library/Developer/Xcode/UserData/IBAppearance.plist` file. This allows you to preview some of the styles in Interface Builder. This is called from `prepareForInterfaceBuilder()` in a custom subclass of a UIView. One of these custom subclasses has to exist in the Storyboard or Xib file in order to render all of the styles. However, the styles will apply to all elements, not just the custom subclass. */ open func loadIBStyles() throws { let sharedResourcesDirectory = ProcessInfo.processInfo.environment["SIMULATOR_SHARED_RESOURCES_DIRECTORY"] let fileName = "IBAppearance.plist" guard let plistDirectory = sharedResourcesDirectory else { throw ArmadilloError.directoryNotFound } var plistURL = URL(fileURLWithPath: plistDirectory) // We want to be in the root UserData directory, so let's move up a few directories // (This is not a great solution, but it works) plistURL.deleteLastPathComponent() plistURL.deleteLastPathComponent() plistURL.deleteLastPathComponent() plistURL.deleteLastPathComponent() plistURL.appendPathComponent(fileName) let hasValues : URLResourceValues do { hasValues = try plistURL.resourceValues(forKeys: [.isAliasFileKey]) } catch { throw ArmadilloError.fileNotFound } if let isAlias = hasValues.isAliasFile, isAlias { do { plistURL = try URL(resolvingAliasFileAt: plistURL) } catch { debugPrint("Error resolving alias: \(error.localizedDescription)") } } do { // Apply the styles from the plist file try loadStylesFromURL(plistURL) } catch { debugPrint(error) throw ArmadilloError.fileNotFound } }
I can now edit the appearance plist file within Xcode and see the changes in Interface Builder. Building and running the app confirmed that the IB changes were an accurate representation of how they’ll be rendered.
Very satisfying.
(As a bonus, the fonts also use the current user settings for dynamic type, adjusting their size as necessary to reflect the current system accessibility settings).
There are a few downsides to this system:
- The one `UserData` directory is shared over all of the projects that exist on that system, so that folder is filling up with plist files.
- Hardcoding the name of the file isn’t great as it means moving or renaming that file when switching between projects.
- The location is very fragile. If Xcode changes where the `SIMULATOR_SHARED_RESOURCES_DIRECTORY` points to, all of this will stop working.
I am still looking for better solutions for these issues, so let me know on Twitter if you have any ideas.
I wanted a way for the Appearance manager to figure out which project it belonged to and then search for a plist file based on this project name but, as noted, these rendered views have almost no knowledge of anything but themselves and so I haven’t yet managed to find a way.
The appearance manager exists as a (currently private) Cocoapod and is a dynamic framework which means adding a constant to any of the appearance manager files to tell it which one to load is also out, as this will be overwritten with any new version.
Protocols and delegates also don’t work, as nothing in the wider app context holds a reference to the appearance manager (it is the custom subviews, also part of the same framework, that call it in this instance).
Overall, though, the improvements are vast. I have already built an Adobe Swatch Exchange to plist converter script which means that named swatches from Adobe products can be injected directly into plists and will be there in IB the next time the project is opened, which helps reduce the friction involved in tweaking or changing designs.