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:
type.containedType.Dataallows us to refer to our Widget containedDatatype, thus allowing us to iterate over contained child data{% map variable.documentation into documentation %}{{maploop.item}}{% endmap %}using a map allows us to create a list of strings|default:allows us to supply alternative values if the given variable is empty
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
}
textis requiredhighlightis not and will default to false if not provided in the data payload
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
- We iterate over all types conforming to
WidgetDataand generate customDecodableimplementation - For each variable in the type we generate
CodingKey - Keys values are mapped to variables converted with snake_case
filterto be more aligned with how JSON tends to be defined. - If a variable has
defaultValuewe don’t require it to exist by usingdecodeIfPresent
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:
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.