Tokamak/docs/RenderersGuide.md

364 lines
11 KiB
Markdown
Raw Normal View History

# `Renderers` in Tokamak
**Author: [@carson-katri](https://github.com/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`, well 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:
1. 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`s `StackReconciler` will let your `Renderer` know when a
`View` has to be redrawn.
2. HTML should be rendered. `TokamakDOM` provides HTML representations of many `Views`, so we can
utilize it. However, we will cover how to provide custom `View` bodies your `Renderer` can
understand, and when you are required to do so.
And thats it! In the next part well go more in depth on `Renderers`.
## Understanding `Renderers`
So, what goes into a `Renderer`?
1. A `Target` - Targets are the destination for rendered `Views`. For instance, on iOS this is
`UIView`, on macOS an `NSView`, and on the web we render to DOM nodes.
2. A `StackReconciler` - The reconciler does all the heavy lifting to understand the view tree. It
notifies your `Renderer` of what views need to be mounted/unmounted.
3. `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 its a layout
constraint).
4. `func update` - This function is called when an existing target instance should be updated (e.g.
when `State` changes).
5. `func unmount` - This function is called when an existing target instance should be unmounted:
removed from the parent and most likely destroyed.
Thats it! Lets 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`, well use the
following `Core.swift` file:
```swift
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)
}
}
```
Weve 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 dont 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:
```swift
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:
```swift
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`:
```swift
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`.
```swift
...
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 wont need to update, so we can crash.
`HTMLBody` is declared like so:
```swift
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.
```swift
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
}}
```
1. We use the `mapAnyView` function to convert the `AnyView` passed in to `AnyHTML`, which can be
used with our `HTMLTarget`.
2. `ParentView` is a special type of `View` in Tokamak. It indicates that the view has no
representation itself, and is purely a container for children (e.g. `ForEach` or `Group`).
3. 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`
doesnt support state changes:
```swift
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, heres a quick synopsis:
- `func update` - Mutate the `target` to match the `host`.
- `func unmount` - Remove the `target` from the `parent`, and call `completion` once it has been
removed.
Now that we can mount, lets give it a try:
```swift
struct ContentView : View {
var body: some View {
Text("Hello, world!")
}
}
let renderer = StaticHTMLRenderer(ContentView())
print(renderer.html)
```
This spits out:
```html
<html>
<body>
<span style="...">Hello, world!</span>
</body>
</html>
```
Congratulations 🎉 You successfully wrote a `Renderer`. We cant wait to see what platforms youll
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. Lets look at a simpler version of this view:
```swift
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`:
```swift
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`:
```swift
// 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.