Building a Simple, Scalable and Maintainable Design System

Written by ze8c | Published 2023/09/18
Tech Story Tags: software-development | design-systems | ios-development | xcode | swift | swiftui | building-a-design-system | mobile-app-development

TLDRA design system comprises visual language, framework, and guidelines, offering components like color palettes, typography, and UI elements. It accelerates development but requires collaboration between designers and developers. Gradual implementation is recommended. In this context, a Swift-based iOS design system is discussed, starting with color and typography models. These models are integrated into the project via extensions and modifiers, creating a standardized button component. The ButtonKind enum defines button types, and the ButtonModifier customizes their appearance. The result is an easy-to-maintain, iOS-friendly design system using Swift and Pattern Factory for scalable, consistent UI development.via the TL;DR App

A design system amalgamates three integral components, each playing a critical role in the software development lifecycle:

  1. Visual Language.

  2. Framework.

  3. Guidelines.

Design systems are made up of:

  • Color Palette

  • Typography

  • Spacing and Alignment

  • Geometric Shapes

  • Icons

  • Images and Visual Assets

  • User Interactions

  • Animations

  • Reusable UI Components

  • Auditory Components

Visual language conveys the core values of the brand to end users. To summarize, a design system is a repository filled with components. Its main goal is to accelerate agile and cohesive development for both developers and designers. It is extremely important to understand that maintaining the design system is a joint effort between these two areas, albeit at a more expensive cost. At the same time, it is recommended to start developing a design system only when the project has strengthened its stylistic vision and corporate identity; otherwise, it may result in constant changes and modifications.

An alternative approach involves gradual implementation, in which specific standardized elements are established, laying the foundation for a holistic design system. Let's dive deeper into this methodology, focusing on creating color schemes and typographic guidelines in the context of developing a design system customized for the iOS platform using Swift, and then adding simple components using buttons as an example.

The starting point is to use the expertise of designers to create an extensive repository of the colors and fonts used in the project.

Moving on, let's create a model that controls the color scheme.

struct ColorModel {
    let red: Double
    let green: Double
    let blue: Double
    let alpha: Double

    init(
        red: Double = 0,
        green: Double = 0,
        blue: Double = 0,
        alpha: Double = 0
    ) {
        self.red = red / 255.0
        self.green = green / 255.0
        self.blue = blue / 255.0
        self.alpha = alpha
    }

    init?(hex: String) {
        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")

        var rgb: UInt64 = 0

        var r: CGFloat = 0.0
        var g: CGFloat = 0.0
        var b: CGFloat = 0.0
        var a: CGFloat = 1.0

        let length = hexSanitized.count

        guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }

        if length == 6 {
            r = CGFloat((rgb & 0xFF0000) >> 16)
            g = CGFloat((rgb & 0x00FF00) >> 8)
            b = CGFloat(rgb & 0x0000FF)

        } else if length == 8 {
            r = CGFloat((rgb & 0xFF000000) >> 24)
            g = CGFloat((rgb & 0x00FF0000) >> 16)
            b = CGFloat((rgb & 0x0000FF00) >> 8)
            a = CGFloat(rgb & 0x000000FF) / 255.0
        } else {
            return nil
        }

        self.init(red: r, green: g, blue: b, alpha: a)
    }
}

After creating the model, the next step is to create a color scheme for our application.

public enum Palette {
    case clear
    case black
    case black30
    case black5
    case white
}

extension Palette {
    var color: ColorModel {
        switch self {
        case .clear: return .init(red: 0, green: 0, blue: 0, alpha: 0)
        case .black: return .init(red: 0, green: 0, blue: 0, alpha: 1)
        case .black30: return .init(red: 0, green: 0, blue: 0, alpha: 0.3)
        case .black5: return .init(red: 0, green: 0, blue: 0, alpha: 0.05)
        case .white: return .init(red: 255, green: 255, blue: 255, alpha: 1)
        }
    }
}

Transitioning from color to typography, we initiate a model for font management.

enum FontModel {
    case medium(size: CGFloat)
    case regular(size: CGFloat)
    
    private var familyName: String { "CustomFontName" }
    
    private var name: String {
        switch self {
        case .medium: return familyName + "-Medium"
        case .regular: return familyName + "-Regular"
        }
    }
    
    private var size: CGFloat {
        switch self {
        case let .medium(size): return size
        case let .regular(size): return size
        }
    }
    
    func evaluate<Result>(_ setup: (String, CGFloat) -> Result) -> Result {
        setup(name, size)
    }
}

Once the font model is operational, it's time to integrate the designers' curated font repository into the project.

public enum AppFont: String {
    /// 60
    case forKeyboard
    /// 36
    case h1Medium
    /// 36
    case h1Regular
    /// 17
    case h4Medium
    /// 17
    case h4Regular
}

extension AppFont {
    
    private var font: FontModel {
        switch self {
        case .forKeyboard: return .medium(size: 60)
        case .h1Medium: return .medium(size: 36)
        case .h1Regular: return .regular(size: 36)
        case .h4Medium: return .medium(size: 17)
        case .h4Regular: return .regular(size: 17)
        }
    }
    
    var suFont: Font {
        font.evaluate(Font.custom(_:size:))
    }
}

Having successfully created the color and font repositories, the next phase involves their integration into the OS API. This necessitates the development of extensions that complement standard UI components, effectively extending the system's capabilities.

public extension Color {
    init(_ palette: Palette) {
        let color = palette.color
        self.init(red: color.red, green: color.green, blue: color.blue, opacity: color.alpha)
    }
}

extension Color {
    init(model: ColorModel) {
        self.init(red: model.red, green: model.green, blue: model.blue, opacity: model.alpha)
    }
}

extension Font {
    public static func get(_ fontDecor: AppFont) -> Font {
        fontDecor.suFont
    }
}

extension View {
    public func background(_ palette: Palette) -> some View {
        background(palette.suColor)
    }
    
    public func foreground(_ palette: Palette) -> some View {
        foregroundColor(palette.suColor)
    }
    
    public func font(_ fontstyle: AppFont) -> some View {
        font(fontstyle.suFont)
    }
}

Our designers have already charted out standardized states for application buttons.

Moving ahead, we'll set up a ButtonModel to serve as the nucleus for managing button configurations.

struct ButtonModel {
    let background: Palette
    let border: Palette
    let font: AppFont = .h4Regular
    let fontColor: Palette
    let cornerRadius: CGFloat = 30
}

The next step is to create a Button Modifier - a universal tool for customizing buttons in our design system.

struct ButtonModifier: ViewModifier {
    let setup: ButtonModel
    
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .font(setup.font)
            .foreground(setup.fontColor)
            .background(setup.background)
            .cornerRadius(setup.cornerRadius)
            .overlay(
                RoundedRectangle(cornerRadius: setup.cornerRadius)
                    .stroke(setup.border.suColor, lineWidth: 1)
            )
    }
}

We'll extend our View with added convenience methods.

extension View {
    var asAnyView: AnyView {
        AnyView(self)
    }
}

We'll make various button types using the ButtonKind enumeration.

public enum ButtonKind {
    case black(title: String, action: () -> Void)
    case whiteBordered(title: String, action: () -> Void)
}

private extension ButtonKind {
    var active: ButtonModel {
        switch self {
        case .black: return .init(background: .black, border: .clear, fontColor: .white)
        case .whiteBordered: return .init(background: .white, border: .black, fontColor: .black)
        }
    }
    
    private var inactive: ButtonModel {
        switch self {
        case .black: return .init(background: .black5, border: .clear, fontColor: .black30)
        case .whiteBordered: return .init(background: .black5, border: .black30, fontColor: .black30)
        }
    }
}

extension ButtonKind {
    func modifier(_ isActive: Bool) -> ButtonModifier {
        ButtonModifier(setup: isActive ? self.active : self.inactive)
    }
    
    func action(_ isActive: Bool) -> () -> Void {
        guard isActive else { return {} }
        
        switch self {
        case let .black(_, action): return action
        case let .whiteBordered(_, action): return action
        }
    }
    
    func label() -> AnyView {
        switch self {
        case let .black(title, _): return Text(title).asAnyView
        case let .whiteBordered(title, _): return Text(title).asAnyView
        }
    }
}

Bringing it all together, we'll introduce a new initializer for our button component.

extension Button {
    public init(_ kind: ButtonKind, isActive: Bool = true) where Label == AnyView {
        self.init(action: kind.action(isActive)) {
            kind.label()
                .modifier(kind.modifier(isActive))
                .asAnyView
        }
    }
}

And finally, the buttons we've standardized can be easily added to the application

VStack {
    Button(.black(title: "Primary", action: {}))
        .frame(maxWidth: .infinity, maxHeight: 60)
    Button(.black(title: "Primary", action: {}), isActive: false)
        .frame(maxWidth: .infinity, maxHeight: 60)
    Button(.whiteBordered(title: "Secondary", action: {}))
        .frame(maxWidth: .infinity, maxHeight: 60)
    Button(.whiteBordered(title: "Secondary", action: {}), isActive: false)
        .frame(maxWidth: .infinity, maxHeight: 60)
}
.padding(.horizontal)

In essence, we've gone from laying the groundwork for the design system to refining iOS elements like buttons. We've made it easy to maintain, customized the look and feel to iOS standards, and created a clear categorization system with Pattern Factory. Factory allows you to create easy-to-maintain and scalable systems.


Written by ze8c | iOS Tech Lead
Published by HackerNoon on 2023/09/18