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
- Widgets are self-contained and can be worked on in isolation
- Adding new widgets doesn’t require any manual labor from the developers
- We support the rapid development of new and existing widgets
- Widgets can be driven via data
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.
- Create a typealias for the
WidgetIDfor improved readability, we could also turn this into a real type to improve type-safety, that way a String can’t be passed directly where ID is expected
struct WidgetID: Hashable, Codable {
var value: String
init(_ value: String) {
self.value = value
}
}
- We define a
ViewBodyhas to be aSwiftUI.Viewimplementation for consistency, we could make this more generic but this should suffice. Configurationis whatever is needed to maintain the state of theViewBodywe will be creating, this type could be ourViewModelor hold ourStoreif we were to use The Composable Architecture.
🤔
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
- We defined a
Configurationas aObservableObjectthat only carries reference date. - Our
ViewBodyisContentview that simply displays relative date to the date configured inConfiguration
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
AnyWidgetlets us get rid of the real generic type from the type system, as such, we can create a list of[AnyWidget]instances, the underlying types are hidden (erased) via closures- This is a pretty common technique when working with generic constructs
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:
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.