Implementing a Design System compatible with UIKit and SwiftUI
The Design System (DS) of an application standardizes various graphical elements in order to assist in project implementation. With it, the programmer uses standard terms for colors, fonts, spacing and has access to various graphic components, with specific attributes of each component.
Its benefits are many and building a complete DS makes a difference in any project. The standardization and the interface components defined by it make the implementation process practical where the responsibility of the graphical part is transferred to the project’s DS.
DS implementation is not limited to just one application feature. It is recommended that it be developed for its benefits, even on projects that do not adopt a DS.
DS for iOS apps brings together a series of codes and rules essential for the functioning and implementation of a project. Because of this, the framework becomes an essential part of the application.
Its distribution can be done using both the package managers known by the community (SPM or Cocoapods), as well as in binary format, making the code proprietary. The design of a DS has a number of specific features and terms.
The concept of Tokens is used, which define all the application’s properties, representing the colors, fonts, spacing, logos, shadows, gradients, and several others that can be defined by DS. As each token depends on how it is used by UIKit and SwiftUI, a specific implementation cannot always be used.
In addition to Tokens, the DS has a library of graphical components, containing buttons, texts, bottom sheets, and cards, for example. The development explores the use of view code and declarative programming.
Although it is not a mandatory rule, it is important to limit the DS dependencies to keep the project intact and independent, even when you have the option of using SnapKit to overcome the difficulty of using the native solution for constraint layout.
This article explores three essential parts of DS that make the framework API complete and usable: the tokens; the graphic components; and the extra components. In each of them, the implementation aligned with the UIKit and SwiftUI frameworks based on Swift resources is detailed.
Due to SwiftUI not being mature enough to allow customization of all graphical components, it is normal that during the DS development process some incompatibilities for the solution are encountered, despite this, the token support is fully functional and compatible.
Inspired by Swift 5.4, the implementation of tokens must follow a hierarchical pattern of properties and attributes. One of the features of the language is the possibility of accessing object members implicitly and continuously.
Exploring this functionality, the DS proposal fits this feature very well, as shown in Figure 1, where properties such as color, font, and spacing have several continuous properties that together represent some DS token. Specifically, the use of the font with the properties arial, medium, and xxxl.
Visually, it is possible to understand that the font Arial, medium and extra-large extra-large size is being used. Thus, the developer uses tokens using programming and no longer static values such as Strings to get assets and integers to define the spacing.
Understanding this programming proposal, it is possible to notice the idea of beginning and end. The start properties are usually static like ds, arial, and medium. The end properties always return the object being constructed, such as the color medium, font xxxl, and line spacing medium.
Intermediate properties are not mandatory, typically used to get object categories or branch flow to another variable branch of the token. These parallel properties can be implemented in extensions based on conditions of generic types, a feature that can be exploited.
The implementation of tokens follows three different proposals. The first implementation is done using the concept of property hierarchy to get the token. The second implementation uses constant and static variables, similar to standard programming. The third makes use of specific graphic objects to shape a token.
Providers and Types
Defining a specific standard for implementing tokens is critical. First, we need two protocols: (a) Type; and (b) Provider. Both protocols do not exist and are conceptual to make the explanation easier.
In fact, they are implemented as ColorType and ColorProvider, FontType and FontProvider, ImageType and ImageProvider, among others.
Protocol (a) does not contain any properties or functions and only limits the types accepted by providers. Concrete objects like UIColor and Color can conform to the ColorType protocol to be built by providers.
Protocol (b) is also empty in variables and methods but has the associated type
associatedtype Xyz: XyzType. Providers are objects that transform values, containing an internal transformHandler variable whose type is a callback that returns the Type and, as a parameter, the data that will be used to define the token attribute.
In the case of colors, images and any other token that is configured as an asset, your providers can use String as a parameter for callbacks, since it is possible to concatenate the values to get the literal name of the assets. Figure 2 details how the provider and transformer concept is used to generate DS tokens.
The ColorScale example can be thought of as the final property of the declarative process to get the token. The intermediate properties are implemented inside the providers and have as associated type the
Provider: ColorProvider where
Color == Provider.Color.
As shown in Figure 3, ColorVariant was implemented, which allows accessing variations in the hue of color before obtaining the final property. However, this development pattern does not apply to all token providers.
Some tokens contain a larger number of intermediate properties, each with alternate flows, not sharing the same declarative path.
Figure 4 shows the development of a category-separated icon library, where all properties within the ImageSpace object do not share the same stream. Therefore, it needs to be branched to specific flows, such as the AccessibilityCategory that contains the icons for the accessibility category.
The implementation of the start properties occurs together with the conformation of the protocol with the object represented by the token. In the case of colors, both the UIColor and the Color are objects that conform to the ColorType.
Apple sets some static properties in line with the concept of styles on these two objects, causing conflict during implementation. As an example, the static variable
ds will be defined representing the theoretical name of the DS.
As shown in Figure 5, the variable that instantiates the UIColor based on the asset name assembled by the DSColors object is implemented, exposing all possible paths to obtain the color following the dot notation until the final variable.
The implementation of DSColors does not follow any examples detailed above. In fact, despite being a ColorProvider, its generic type is Color.
This is because each property of this object does not share any similarities, except that they all use ColorType. In addition, this object defines different types of colors, some of which contain subgroups and other specific endings.
Figure 6 details this distinct implementation with alternative declarative flows. The project’s syntax and API remain clean and straightforward and can be improved with consistent documentation.
Tokens that do not have a hierarchy of properties can be implemented using enums or simple objects. The spacing between views, shadow, and edge curvature do not require a complex programming structure, and can even be represented by constant values.
The enum is the simplest and most straightforward way to implement a token, but it requires extra adaptation to make it compatible with the graphics component.
In the case of shadows, thickness, and edge curvature that exploit the CoreAnimation API of iOS and SwiftUI with the declarative methods, it is necessary to implement other functions to give compatibility to the token expressed by enums.
Figure 7 details the implementation of the token for shadows, containing a series of internal properties of the framework to enable its use correctly.
Similar to border curvature and width, line spacing, and other simple variables, it is necessary to implement a function or view for the token to be used correctly. As shown in Figure 8, the setShadow(_ level: Shadow) function is implemented in CALayer (UIKit) and shadow(_ level: Shadow) in the View (SwiftUI).
Another constant token is the spacing that uses a different proposition. Implementing spacing using enums is not ideal and should have used the same idea as providers and types, with a straightforward format.
Based on the idea that the spacing is obtained from the CGFloat or Double, of the Numeric type, you may have implemented an object where the Type is actually Numeric and the variables return the value directly. Figure 9 explores the implementation of the Spacing object by returning values directly to the application.
The constants need to be accessible through CGFloat and Double, and it is necessary to extend these two objects to return an instance of Spacing. Thus, the spacing properties are given visibility. Figure 10 implements the static variable spacing that returns the spacing values.
Tokens are not always related to a data type or adaptable to enums and functions. These tokens also don’t have a direct representation by the UIKit or SwiftUI requiring you to implement a graphical object that will render them.
Some tokens can only be implemented using a graphical component because they rely on a set of UIKit and SwiftUI properties and rendering methods.
Figure 11 details the RoundedView implementation that changes the edge curvature as the frame is updated. In SwiftUI it’s also done by implementing a Shape that correctly applies curvature based on the public functions of Capsule and RoundedRectangle.
Graphics components standardize a series of visual elements based on specific usage rules. The implementation of these components follows two specific ways.
The first is using the idea of SwiftUI, in the case of UIKit, to rebuild the interface based on changing the object’s properties. The second proposal follows the traditional programming where changes are applied within a function that imperatively configures the layout. Both implementations become powerful when aligned with the concept of style, grouping the properties that these objects allow you to customize.
Styles should be explored to centralize all layout properties that are not externalized to the application. With them, you can also define DS styles and apply them by default to each component.
Their implementation is based on 3 objects: (a) ComponentStyle; (b) ComposableStyle; and (c) Style. Item (a) defines the type of styles and is implemented using the protocol, it can also be explored by the application to create new styles.
Item (b) is specific to each component, receiving names such as ButtonComposableStyle and CheckBoxComposableStyle, used to define all object layout properties. Item (c) is used to transform the ComponentStyle into a ComposableStyle.
The implementation of graphical components in DS needs to be consistent and flexible at the same time. Within a project, there may be variations of a component that need to be considered, otherwise DS can become a buggy and incomplete framework.
After different analyzes and implementations, the best solution was the idea used by SwiftUI to render views according to state. Connecting these states to styles made the components customizable allowing the application to be able to implement its styles and apply them to the components.
As a starting point (UIKit only), you need to create two protocols that will make the programming of UIKit components declarative. The first protocol is ViewCodable, which contains four methods: (a) setupView; (b) buildHierarchy; (c) setupConstraints; and (d) applyAdditionalChanges.
Method (a) is called at UIView startup and, with default implementation, calls the other methods to be executed sequentially. Method (b) should be explored to add subviews; method (c) is used to set the constraints; and method (d) for performing additional configurations.
The second protocol is the KeyPathSettable, made up of all NSObject objects, the basis of UIKit components. This protocol contains three main methods for setting variables using Swift’s KeyPath feature:
- setting(_: WritableKeyPath<Self, Value>, to: Value) -> Self;
- setting(_: WritableKeyPath<Self, Value>, to: KeyPath<Self, Value>) -> Self;
- setting(_: WritableKeyPath<Self, Value>, to: (Self) -> Value) -> Self.
With these methods, it is possible to configure any property of an NSObject (UIView included), making the view code process declarative and keeping the type of the objects. It is recommended that you exploit this syntax to create other components or methods to make the UIKit API declarative enough for coding projects.
Secondary methods are:
- setup(_: (inout Self) -> Void) -> Self;
- assign(to: inout Self) -> Self;
- assign(to: inout Self?) -> Self.
Secondaries help to set up views using imperative programming by allowing access to the object outside the declarative flow and within it. Its use can be positive for executing larger blocks that access various object properties, as well as for assigning the object instance to a variable.
A third protocol that should be explored is EditableObject, being used to program SwiftUI objects and styles, mainly because it is an internal protocol. This protocol contains a single edit(_:) method that returns the object instance and, as a parameter, has a callback that passes the inout Self, as in the KeyPathSettable.setup(_:) method.
This protocol must have a default implementation that makes the object mutable by passing its reference to the callback. With it, it is possible to internally manipulate struct objects and create public methods that change the self in a declarative flow.
Standardization of graphic components
Graphic components share the same implementation proposal and programming paradigm. It is possible to reuse the same programming to implement components in SwiftUI, as the files, objects, and code structure are the same.
The implementation of a single component for UIKit and SwiftUI is covered. They are simplified implementations that can be exploited to create methods and objects that simplify the coding process.
The CheckBox (UIKit) and DCheckBox (SwiftUI) are implemented with five properties: (a) style; (b) state; (c) size; (d) text; and (e) isSelected.
The (a) property is used to define which style the component should render. Property (b) defines the two states, which can be complemented to support other states. Property (c) defines three different sizes for the component. Property (d) contains the text to be rendered and property (e) the selection state.
All components (UIKit only) must contain the reloadStyle() function which is called when one of its monitored variables is changed, using Swift’s didSet. Within this function, the component layout hierarchy is reconstructed.
For that, it is necessary to remove all subviews of the component and recreate the subviews by calling some declarative method, in this case with ContentView(_: CheckBoxComposableStyle) -> UIView. With the recreated subviews, they are added to the component hierarchy and set zero margin constraints.
The CheckBox contains, as visual elements, the box with the check symbol when selected and the text to the right, if any. The ContentView method can be broken into three methods: CheckBox(_:); ImageView(); and Text(_:). Starting from the smallest part to the largest, the first detailed method is text rendering.
As shown in Figure 12, the UILabel configured with some ComposableStyle variables is instantiated. In a simple way, this method creates the label with all the properties monitored by CheckBox, generating the expected result by the component without inconsistencies.
Figure 13 instantiates the UIImageView which shows whether the component is selected or not. This particular method does not use ComposableStyle to configure the imageView.
Figure 14 uses the ImageView() method to define the box in which the check is rendered, applying some ComposableStyle layout settings. This declarative flow contains some specific methods and some utility methods.
UIImageView is not hidden using isHidden or alpha. This happens because the selected state is taken to the style which should return a clear color for the background, when not selected, and a solid color for when selected.
Figure 15 connects the methods for creating the CheckBox based on its rules and layout with ComposableStyle. When the ContentView(_:) function is called, the entire subview is set up and ready to be rendered by iOS.
Implementation of styles
Styles work by combining three objects: (a) ComponentStyle; (b) ComposableStyle; and (c) Style. Both (b) and (c) must be created for each component, however repetitive. Item (a) is used to create all styles, being objects that define all customizable properties of the component.
Before starting to build the styles, it is necessary to create the first ComponentStyle protocol that defines the types of styles. This protocol is empty and can be implemented by application objects or by the framework.
Aligning the proposal with a real problem, the DS includes PrimaryStyle, ComplementaryStyle, LightStyle, and DarkStyle styles. All are implemented as shown in Figure 16 with an empty initialization.
From the definition of styles, we can implement the specific ComposableStyle for each component. Using EditableObject it is possible to build a declarative API to assemble the layout of the components.
As shown in Figure 17, the LabelComposableStyle was implemented, exemplifying the style of the Label component implemented for UIKit and SwiftUI (DLabelComposableStyle). This component allows you to change the background color, text color, line limit, font, line spacing, and text alignment.
Thanks to the use of EditableObject, it is possible to create, for each LabelComposableStyle layout property, functions that change the values of the layout object using declarative programming. Figure 18 shows the application of the protocol to edit the object.
Before integrating styles into components, you need to create the LabelStyle protocol or DLabelStyle for SwiftUI. In this protocol, you can pass several parameters that influence the layout, for example, a LabelSize enum with
case medium, and
For each option that the application chooses as text size, the component’s font size must be changed. Figure 19 shows the definition of the LabelStyle protocol that extends ComponentStyle, requiring the object to always be the type used for styles.
With the structure defined to support the styles in the Label component, the framework implements the styles that are supported. As shown in Figure 20, the use of LabelSize is explored. This solution can even be to use a switch case to return different LabelComposableStyle, as in the case of being based on UIButton states.
The use of this feature inside a component occurs in the reloadStyle function through the style variable. In Label, this variable is of type LabelStyle.
Inside the function to update the layout, the component(_:) method of the style variable is called, which returns the ComposableStyle of the Label, allowing you to use it to apply the layout settings to the component. Inspired by SwiftUI, when text or size changes, the reloadStyle function is called to rebuild the entire component.
This concept involves implementing specific views that define alignment, spacing, shadow, and various other properties in a declarative way that UIKit lacks. Some DS features don’t always fit into a graphics component, so it’s necessary to define ViewModifier objects or implement methods with specific layout operations.
The implementation of extra components has as main objective to facilitate the development process. This feature can be linked to DS tokens, as well as to perform layout operations and render specific application components.
The solution proposed is based on two protocols: (a) WrapperViewable; and (b) Viewable. Protocol (a) contains two associated types and two variables. Protocol (b) is implemented according to components such as MarginViewable and SafeAreaViewable, for example.
The WrapperViewable’s associated types are Wrapped and Original, both of type UIView, with wrappedView and originalView variables. Figure 21 details the MarginView implementation that adds edge constraints when the setupConstraints function is called, based on the constant value entered.
As an example, an interface with centered text and a horizontal margin is built. It needs to be accessible to the view controller via the variable
The core idea of WrapperViewable is to allow you to access all wrapped views from the last declarative method.
Converting this interface to declarative format, there is a succession of methods that transform the text into an AlignmentView and then into a MarginView, assigning the titleLabel variable. The type of this variable is no longer UILabel and becomes MarginView, but you can get it using the wrappedView and originalView properties.
After implementing the margin component, you need to create the MarginViewable protocol. As shown in Figure 22, the same margin(_: CGFloat, _: MarginEdges) method accessible for both the UIView and the wrappers is created.
However, the method called by the wrappers differs from preserving the Original type. Thus, in a succession of calls, the Original type is kept and the Wrapped type is concatenated.
Keeping the DS well structured and organized there is a good folder structure. As a suggestion for this issue, including compatibility with UIKit and SwitUI, the framework root can be defined in three folders: (a) Shared; (b) UIKit; and (c) SwiftUI.
Each folder contains the same branch with the Tokens, Components, and Extensions folders. Inside (a) folders for styles and protocols are provided, always with objects that are shared between UIKit and SwiftUI.
Folders (b) and (c) have a folder for deploying extra components, as well as deploying specific UIKit and SwftiUI components that adapt tokens to graphics.
The Tokens folder contains all tokens implemented in DS like Color, Font, Image, Spacing and others. However, the content within each branch varies.
Shared must contain the generic implementation of the objects, while the tokens inside the UIKit and SwiftUI only contain the conformation of the objects of each framework. Not all tokens involve some feature of iOS UI frameworks, such as spacing that can only be implemented in Shared.
The Components folder follows an inverted proposal as it is directly linked to the UIKit and SwiftUI framework. Inside Shared > Components > Button we have shared files like ButtonSize, ButtonVariant, ButtonAlignment, and others specific to the component.
The UIKit and SwiftUI folders contain the implementation of the graphical components, containing the same files, based on the UIKit framework and SwiftUI.
Implementing DS using all the techniques detailed in this article has proven to be flexible and reusable. Not only simple components benefited from this approach, but larger components that involve the UINavigationController and UITabBarController.
However, components such as the UIButton and the UITextField proved to be inflexible in terms of the proposal to be rebuilt, being necessary to keep them in the hierarchy and make the layout options on them. Even so, there was no loss of performance and the solution was effective in all cases.
This solution was compatible with both UIKit and SwiftUI frameworks and unit testing, allowing the implementation of a hybrid DS demo application. This app renders all defined tokens and components, and also, because of syntax equality, is able to change components between UIKit and SwiftUI for visual testing.
I hope it inspires other programmers and allows for a better discussion on this subject. During the development of this solution, there were not many materials to guide the development of the API that was easy, simple, and standardized to use.
I would like to thank my colleagues Lucas Goes Valle, Nicolas A. Bicalho, and Bruno Nallis Villanova for their teamwork, who helped with the design of the project from start to finish, as well as technical discussions to adapt the solution to Apple frameworks. Thanks to Nava for creating and managing the project with a standardized syntax that inspired the implementation of this article with an excellent componentized design.
Thanks for the reading!