Skip to content
Go back

Widget architecture - part 1

This series of articles will explore a component-based architecture, in which each component or widget is self-contained, allowing for quick scaling of a project by adding more engineers.

This type of architecture has been used in several client projects, such as powering generic list content at The New York Times and creating new home widgets at The Browser Company.

Requirements

At the end of the series, we’ll have an architecture in which

Base implementation

ℹ️

Some implementation details will be simplified for tutorials, but the general ideas should apply to your projects.

We start by defining what a widget is:

/// Type of a widget

typealias WidgetID = String

/// Represents an widget in a list.
protocol Widget: Identifiable {
    /// Must be a `SwiftUI.View`
    associatedtype ViewBody: View
    associatedtype Configuration

    var id: WidgetID { get }
    func make(configuration: Configuration) -> ViewBody
}

Widget protocol A widget has to define a configuration it needs and then provide a View to implement that widget functionality.

struct WidgetID: Hashable, Codable {
  var value: String
  init(_ value: String) {
    self.value = value
  }
}

🤔

As you can see, the widget protocol doesn’t dictate what architecture the widget uses.

I’d usually recommend using a consistent architecture across whole projects since it makes it easier for engineers to jump around and helps build developer tooling.

For standalone widgets until we are doing data driven flow, we can add default id conformance:

extension Widget {
    var id: WidgetID {
        .init(String(describing: Self.self))
    }
}

Default widget id implementation

Sample widget

A simple state-driven widget could be defined like this:

struct TimerWidget: Widget {
    class Configuration: ObservableObject {
        @Published var date: Date = .init()
    }

    private struct Content: View {
        @ObservedObject var configuration: Configuration

        var body: some View {
            Text(configuration.date, style: .relative)
        }
    }

    func make(configuration: Configuration) -> some View {
        Content(configuration: configuration)
    }
}

Sample widget

Widget registration

For now, we are going to build widgets as standalone components instead of data-driven. To be able to drive that, we need some registry system.

AnyWidget - Type Erasure

We first need to be able to store Widget factories, and for that, we’ll leverage type erasure:

struct AnyWidget: Identifiable {
    private let widget: any Identifiable
    let id: WidgetID
    private let make: () -> AnyView

    init<WidgetType, Configuration>(_ widget: WidgetType, configuration: Configuration) where WidgetType: Widget, WidgetType.Configuration == Configuration {
        self.widget = widget
        self.id = widget.id
        self.make = {
            AnyView(widget.make(configuration: configuration))
        }
    }

    var contentView: AnyView {
        make()
    }
}

Widget type erasure

This will allow us to store widgets in a collection:

class WidgetRegistry: ObservableObject {
    var widgets: [AnyWidget] = []

    init() {
        registerWidget(TimerWidget(), configuration: .init())
    }
    
    func registerWidget<WidgetType, Configuration>(_ widget: WidgetType, configuration: Configuration) where WidgetType: Widget, WidgetType.Configuration == Configuration {
        widgets.append(AnyWidget(widget, configuration: configuration))
    }
}

Widget Registry If a widget has no configuration, we can also add a default extension to simplify the registry:

func registerWidget<WidgetType>(_ widget: WidgetType) where WidgetType: Widget, WidgetType.Configuration == Void, WidgetType.Configuration == Void {
    widgets.append(AnyWidget(widget, configuration: ()))
}

Then we can use that system to drive a view.

struct WidgetsView: View {
    @StateObject var registry: WidgetRegistry = .init()

    var body: some View {
        List {
            ForEach(registry.widgets) { widget in
                widget.contentView
            }
        }
    }
}

Swift 5.7 any

With new any the keyword, we could modify this slightly by adding AnyWidgetFactory

protocol WidgetFactory {
    var id: WidgetID { get }
    var contentView: AnyView { get }
}

extension AnyWidget: AnyWidgetFactory {}

and then having the registry hold any WidgetFactory

var widgets: [any WidgetFactory] = []

Albeit, given we still need to use AnyView that doesn’t really win us much. In the future, we’ll see how we can avoid AnyView by leveraging Sourcery.

Project in current form:

Full Source Code Archive.zip 40 KB

What’s next?

The next article in the series will turn this setup into a data-driven system where each widget can be provided a custom payload, and we’ll be able to leverage live reload.


Share this post on:

Previous Post
Widget Architecture - Part 2
Next Post
Debugging Tips