Skip to content
Go back

Widget Architecture - Part 3

We successfully converted our component system to a data-driven approach in the previous article. We improved our development process through the use of live reloading and Sourcery’s auto-registration feature.

This time, we’ll focus on creating an in-app Help widget and leveraging Sourcery to automate both in-app documentation creation and custom implementation of Decodable that will allow us to provide default values.

With this approach, we can streamline the development of our Help widget and ensure that it remains up-to-date with the rest of our application.

Help Widget in Action

Defining WidgetDocumentation

We’ll begin by defining a set of data structures representing the help widget’s content.

We want to document the contents of Data objects of each widget, what variables it contains, and what their documentation says about them.

struct WidgetDocumentation: Hashable {
    struct Variable: Hashable {
        let name: String
        let type: String
        let documentation: String
        let defaultValue: String?
    }

    let name: String
    let kind: String
    let variables: [Variable]
}

Defining WidgetDocumentation

Using Sourcery to fill in the data

With our data structures in place, the next step is to populate them with the necessary information for each widget in our system. To streamline this process, we can use Sourcery’s powerful automation capabilities.

To begin, we’ll need to enable the “parseDocumentation” setting in our .sourcery.yml file, as this is not enabled by default.

Once we’ve done that, we can use Sourcery to iterate over all Widget conforming types in our project and generate a computed static variable called documentation for each one. This variable will contain all of the relevant data for the widget, including its variables and documentation.

By automating this process, we can ensure that our Help widget remains up-to-date with any changes or additions to our system. This will save us time and reduce the risk of errors arising from manually updating the widget’s data. Additionally, having all the necessary data in one place will make it easier for us to maintain and troubleshoot the widget as needed.

{% for type in types.all|implements:"Widget" %}
extension {{ type.name }} {
    public static var documentation: WidgetDocumentation {
        WidgetDocumentation(
            name: "{{ type.name }}",
            kind: {{ type.name }}.kind,
            variables: [
                {% for variable in type.containedType.Data.storedVariables %}
                {% map variable.documentation into documentation %}{{maploop.item}}{% endmap %}
                .init(
                    name: "{{ variable.name }}",
                    type: "{{ variable.typeName }}",
                    documentation: {{ documentation|default:"[]" }},
                    defaultValue: {{ variable.defaultValue|default:"nil" }}
                ),
                {% endfor %}
            ]
        )
    }
}
{% endfor %}

Stencil template for help generation

Interesting bits:

Here’s an example of generated code for Help widget itself:

extension HelpWidget {
    public static var documentation: WidgetDocumentation {
        WidgetDocumentation(
            name: "HelpWidget",
            kind: HelpWidget.kind,
            variables: [
                .init(
                    name: "tag",
                    type: "String?",
                    documentation: ["optional identifier of the help widget", "e.g. specyfing `help` would only show help documentation"],
                    defaultValue: nil
                ),
            ]
        )
    }
}

To finalize registration, we can do what we did previously with registerAllWidgetDecoders:

func registerAllDocumentation() {
    {% for type in types.all|implements:"Widget" %}
    WidgetDocumentation.all.append({{ type.name }}.documentation)
    {% endfor %}
}

Creating Help widget

Now that we have all of the necessary data for our Help widget, the next step is to create the widget itself and its user interface.

Let’s start with the base structure:

struct HelpWidget: Widget {
  struct Data: WidgetData {
    /// optional identifier of the help widget
    /// e.g. specyfing `help` would only show help documentation
    let tag: String?
  }

  struct ContentView: View {
    let documentation: [WidgetDocumentation]

    var body: some View {
      ...
    }
  }

  func make() -> some View {
    ContentView(documentation: WidgetDocumentation.all.filter { data.tag == nil || $0.kind == data.tag })
  }

  var data: Data
}

To display the help documentation within our application, we will provide a custom ContentView that is dynamically fed with the relevant documentation set.

Only the documentation for that particular widget will be displayed if the user specifies a tag property. Otherwise, the ContentView will list all available widget documentation.

ContentView

The view itself is pretty straightforward. It will stack together all widget documentation.

We need to store the expanded state for different widgets:

@State
var expandedWidgets = Set<String>()

private func isExpanded(for info: WidgetDocumentation) -> Bool {
    expandedWidgets.contains(info.kind)
}

We are leveraging DisclosureGroup and need to be able to provide isExpanded binding, for this, we are manually tracking expanded groups like this:

DisclosureGroup(isExpanded: .init(get: {
  isExpanded(for: info)
}, set: { expanded in
  if expanded {
    expandedWidgets.insert(info.kind)
  } else {
    expandedWidgets.remove(info.kind)
  }
})

Creating isExpanded binding dynamically

ℹ️

The full source code is available at the end of the article.

Turning Help on

To see the help widget, we simply need to add the following to our Widgets.json

{
  "kind": "help",
  "payload": {
  }
}

and if we want to focus our help on just a single widget, we can provide tag property.

Adding support for default values in Codable

Default Codable implementation doesn’t support providing default decoding values in types. As John shows in his article, one way to work around this is by using property wrappers.

But since we already have Sourcery, we can use it to create a different model altogether. Let’s say I want to avoid dealing with optionals and instead always fallback to the defaults Widget creator wanted.

Let’s say we modify TextWidget to be like this:

struct TextWidget: Widget {
  struct Data: WidgetData {
    var text: String
    var highlight: Bool = false
  }

  func make() -> some View {
    Text(data.text)
      .foregroundColor(data.highlight ? .red : .primary)
  }

  var data: Data
}

For this, we need to create a new Sourcery template that will add a custom Decodable implementation:

import Foundation
// 1
  { % for type in types.all | implements: "WidgetData" % }
extension {{ type.name }} {
  enum CodingKeys: String, CodingKey {
    // 2
    { % for variable in type.storedVariables % }
    // 3
    case {{ variable.name }} = "{{ variable.name|camelToSnakeCase }}"
    { % endfor % }
  }

  init(from decoder: Decoder) throws {
    let container: KeyedDecodingContainer<{{ type.name }}.CodingKeys> = try decoder.container(keyedBy: {{ type.name }}.CodingKeys.self)
      { % for variable in type.storedVariables % }
    { % if variable.defaultValue % }
    // 4
    self.{{ variable.name }} = try container.decodeIfPresent({{ variable.typeName }}.self, forKey: Self.CodingKeys.{{ variable.name }}) ?? {{ variable.defaultValue }}
    { % else % }
    self.{{ variable.name }} = try container.decode({{ variable.typeName }}.self, forKey: Self.CodingKeys.{{ variable.name }})
      { % endif % }
    { % endfor % }
  }
}

{ % endfor % }

Stencil for Decodable implementation behaviour we want

  1. We iterate over all types conforming to WidgetData and generate custom Decodable implementation
  2. For each variable in the type we generate CodingKey
  3. Keys values are mapped to variables converted with snake_case filter to be more aligned with how JSON tends to be defined.
  4. If a variable has defaultValue we don’t require it to exist by using decodeIfPresent

Example generated code:

extension TextWidget.Data {
  enum CodingKeys: String, CodingKey {
    case text
    case highlight
    case somethingLong = "something_long"
  }

  init(from decoder: Decoder) throws {
    let container: KeyedDecodingContainer<TextWidget.Data.CodingKeys> = try decoder.container(keyedBy: TextWidget.Data.CodingKeys.self)
    self.text = try container.decode(String.self, forKey: Self.CodingKeys.text)
    self.highlight = try container.decodeIfPresent(Bool.self, forKey: Self.CodingKeys.highlight) ?? false
    self.somethingLong = try container.decodeIfPresent(Bool.self, forKey: Self.CodingKeys.somethingLong) ?? false
  }
}

Generated code with fake longNameVariable

With this in place, we can now see that our Widgets can be updated with highlight property and use a default value if one is not provided in JSON.

Optional value support in JSON

Conclusion

Final source code for this article:

Full Source Code Widgets3.zip 6 MB

In this series of articles, we showed how we could create a self-contained widget-based architecture that can be used to power up different kinds of apps.

I’ve used this pattern to power up generic list content at The New York Times or create new home widgets at The Browser Company.

Furthermore, we showed how Sourcery could streamline and automate large parts of the required code. The tool really starts to show its real power when you start using it for your specific project needs instead of just using the popular templates from the community.

Follow @merowing_


Share this post on:

Previous Post
The Composable Architecture - Best Practices
Next Post
Widget Architecture - Part 2