11 KiB
Renderers
in Tokamak
Author: @carson-katri
Tokamak is a flexible library. TokamakCore
provides the SwiftUI API, which your Renderer
can use
to construct a representation of Views
that your platform understands.
To explain the creation of Renderers
, we’ll be creating a simple one: TokamakStaticHTML
(which
you can find in the Tokamak
repository).
Before we create the Renderer
, we need to understand the requirements of our platform:
- Stateful apps cannot be created. This simplifies the scope of our project, as we only have to
render once. However, if you are building a
Renderer
that supports state changes, the process is largely the same.TokamakCore
’sStackReconciler
will let yourRenderer
know when aView
has to be redrawn. - HTML should be rendered.
TokamakDOM
provides HTML representations of manyViews
, so we can utilize it. However, we will cover how to provide customView
bodies yourRenderer
can understand, and when you are required to do so.
And that’s it! In the next part we’ll go more in depth on Renderers
.
Understanding Renderers
So, what goes into a Renderer
?
- A
Target
- Targets are the destination for renderedViews
. For instance, on iOS this isUIView
, on macOS anNSView
, and on the web we render to DOM nodes. - A
StackReconciler
- The reconciler does all the heavy lifting to understand the view tree. It notifies yourRenderer
of what views need to be mounted/unmounted. func mountTarget
- This function is called when a new target instance should be created and added to the parent (either as a subview or some other way, e.g. installed if it’s a layout constraint).func update
- This function is called when an existing target instance should be updated (e.g. whenState
changes).func unmount
- This function is called when an existing target instance should be unmounted: removed from the parent and most likely destroyed.
That’s it! Let’s get our project set up.
TokamakStaticHTML
Setup
Every Renderer
can choose what Views
, ViewModifiers
, property wrappers, etc. are available to
use. A Core.swift
file is used to re-export these symbols. For TokamakStaticHTML
, we’ll use the
following Core.swift
file:
import TokamakCore
// MARK: Environment & State
public typealias Environment = TokamakCore.Environment
// MARK: Modifiers & Styles
public typealias ViewModifier = TokamakCore.ViewModifier
public typealias ModifiedContent = TokamakCore.ModifiedContent
public typealias DefaultListStyle = TokamakCore.DefaultListStyle
public typealias PlainListStyle = TokamakCore.PlainListStyle
public typealias InsetListStyle = TokamakCore.InsetListStyle
public typealias GroupedListStyle = TokamakCore.GroupedListStyle
public typealias InsetGroupedListStyle = TokamakCore.InsetGroupedListStyle
// MARK: Shapes
public typealias Shape = TokamakCore.Shape
public typealias Capsule = TokamakCore.Capsule
public typealias Circle = TokamakCore.Circle
public typealias Ellipse = TokamakCore.Ellipse
public typealias Path = TokamakCore.Path
public typealias Rectangle = TokamakCore.Rectangle
public typealias RoundedRectangle = TokamakCore.RoundedRectangle
// MARK: Primitive values
public typealias Color = TokamakCore.Color
public typealias Font = TokamakCore.Font
public typealias CGAffineTransform = TokamakCore.CGAffineTransform
public typealias CGPoint = TokamakCore.CGPoint
public typealias CGRect = TokamakCore.CGRect
public typealias CGSize = TokamakCore.CGSize
// MARK: Views
public typealias Divider = TokamakCore.Divider
public typealias ForEach = TokamakCore.ForEach
public typealias GridItem = TokamakCore.GridItem
public typealias Group = TokamakCore.Group
public typealias HStack = TokamakCore.HStack
public typealias LazyHGrid = TokamakCore.LazyHGrid
public typealias LazyVGrid = TokamakCore.LazyVGrid
public typealias List = TokamakCore.List
public typealias ScrollView = TokamakCore.ScrollView
public typealias Section = TokamakCore.Section
public typealias Spacer = TokamakCore.Spacer
public typealias Text = TokamakCore.Text
public typealias VStack = TokamakCore.VStack
public typealias ZStack = TokamakCore.ZStack
// MARK: Special Views
public typealias View = TokamakCore.View
public typealias AnyView = TokamakCore.AnyView
public typealias EmptyView = TokamakCore.EmptyView
// MARK: Misc
// Note: This extension is required to support concatenation of `Text`.
extension Text {
public static func + (lhs: Self, rhs: Self) -> Self {
_concatenating(lhs: lhs, rhs: rhs)
}
}
We’ve omitted any stateful Views
, as well as property wrappers used to modify state.
Building the Target
If you recall, we defined a Target
as:
the destination for rendered
Views
In TokamakStaticHTML
, this would be a tag in an HTML
file. A tag has several properties,
although we don’t need to worry about all of them. For now, we can consider a tag to have:
- The HTML for the tag itself (outer HTML)
- Child tags (inner HTML)
We can describe our target simply:
public final class HTMLTarget: Target {
var html: AnyHTML
var children: [HTMLTarget] = []
init<V: View>(_ view: V,
_ html: AnyHTML) {
self.html = html
super.init(view)
}
}
AnyHTML
type is coming from TokamakDOM
, which you can declare as a dependency. The target stores
the View
it hosts, the HTML
that represents it, and its child elements.
Lastly, we can also provide an HTML string representation of the target:
extension HTMLTarget {
var outerHTML: String {
"""
<\(html.tag)\(html.attributes.isEmpty ? "" : " ")\
\(html.attributes.map { #"\#($0)="\#($1)""# }.joined(separator: " "))>\
\(html.innerHTML ?? "")\
\(children.map(\.outerHTML).joined(separator: "\n"))\
</\(html.tag)>
"""
}
}
Building the Renderer
Now that we have a Target
, we can start the Renderer
:
public final class StaticHTMLRenderer: Renderer {
public private(set) var reconciler: StackReconciler<StaticHTMLRenderer>?
var rootTarget: HTMLTarget
public var html: String {
"""
<html>
\(rootTarget.outerHTML)
</html>
"""
}
}
We start by declaring the StackReconciler
. It will handle the app, while our Renderer
can focus
on mounting and un-mounting Views
.
...
public init<V: View>(_ view: V) {
rootTarget = HTMLTarget(view, HTMLBody())
reconciler = StackReconciler(
view: view,
target: rootTarget,
renderer: self,
environment: EnvironmentValues()
) { closure in
fatalError("Stateful apps cannot be created with TokamakStaticHTML")
}
}
Next we declare an initializer that takes a View
and builds a reconciler. The reconciler takes the
View
, our root Target
(in this case, HTMLBody
), the renderer (self
), and any default
EnvironmentValues
we may need to setup. The closure at the end is the scheduler. It tells the
reconciler when it can update. In this case, we won’t need to update, so we can crash.
HTMLBody
is declared like so:
struct HTMLBody: AnyHTML {
let tag: String = "body"
let innerHTML: String? = nil
let attributes: [String : String] = [:]
let listeners: [String : Listener] = [:]
}
Mounting
Now that we have a reconciler, we need to be able to mount the HTMLTargets
it asks for.
public func mountTarget(to parent: HTMLTarget, with host: MountedHost) -> HTMLTarget? {
// 1.
guard let html = mapAnyView(
host.view,
transform: { (html: AnyHTML) in html }
) else {
// 2.
if mapAnyView(host.view, transform: { (view: ParentView) in view }) != nil {
return parent
}
return nil
}
// 3.
let node = HTMLTarget(host.view, html)
parent.children.append(node)
return node
}}
- We use the
mapAnyView
function to convert theAnyView
passed in toAnyHTML
, which can be used with ourHTMLTarget
. ParentView
is a special type ofView
in Tokamak. It indicates that the view has no representation itself, and is purely a container for children (e.g.ForEach
orGroup
).- We create a new
HTMLTarget
for the view, assign it as a child of the parent, and return it.
The other two functions required by the Renderer
protocol can crash, as TokamakStaticHTML
doesn’t support state changes:
public func update(target: HTMLTarget, with host: MountedHost) {
fatalError("Stateful apps cannot be created with TokamakStaticHTML")
}
public func unmount(
target: HTMLTarget,
from parent: HTMLTarget,
with host: MountedHost,
completion: @escaping () -> ()
) {
fatalError("Stateful apps cannot be created with TokamakStaticHTML")
}
If you are creating a Renderer
that supports state changes, here’s a quick synopsis:
func update
- Mutate thetarget
to match thehost
.func unmount
- Remove thetarget
from theparent
, and callcompletion
once it has been removed.
Now that we can mount, let’s give it a try:
struct ContentView : View {
var body: some View {
Text("Hello, world!")
}
}
let renderer = StaticHTMLRenderer(ContentView())
print(renderer.html)
This spits out:
<html>
<body>
<span style="...">Hello, world!</span>
</body>
</html>
Congratulations 🎉 You successfully wrote a Renderer
. We can’t wait to see what platforms you’ll
bring Tokamak to.
Providing platform-specific primitives
Primitive Views
, such as Text
, Button
, HStack
, etc. have a body type of Never
. When the
StackReconciler
goes to render these Views
, it expects your Renderer
to provide a body.
This is done via the ViewDeferredToRenderer
protocol. There we can provide a View
that our
Renderer
understands. For instance, TokamakDOM
(and TokamakStaticHTML
by extension) use the
HTML
view. Let’s look at a simpler version of this view:
protocol AnyHTML {
let tag: String
let attributes: [String:String]
let innerHTML: String
}
struct HTML: View, AnyHTML {
let tag: String
let attributes: [String:String]
let innerHTML: String
var body: Never {
neverBody("HTML")
}
}
Here we define an HTML
view to have a body type of Never
, like other primitive Views
. It also
conforms to AnyHTML
, which allows our Renderer
to access the attributes of the HTML
without
worrying about the associatedtypes
involved with View
.
ViewDeferredToRenderer
Now we can use HTML
to override the body of the primitive Views
provided by TokamakCore
:
extension Text: ViewDeferredToRenderer {
var deferredBody: AnyView {
AnyView(HTML("span", [:], _TextProxy(self).rawText))
}
}
If you recall, our Renderer
mapped the AnyView
received from the reconciler to AnyHTML
:
// 1.
guard let html = mapAnyView(
host.view,
transform: { (html: AnyHTML) in html }
) else { ... }
Then we were able to access the properties of the HTML.
Proxies
Proxies allow access to internal properties of views implemented by TokamakCore
. For instance, to
access the storage of the Text
view, we were required to use a _TextProxy
.
Proxies contain all of the properties of the primitive necessary to build your platform-specific implementation.