Tokamak/docs/RenderersGuide.md

11 KiB
Raw Blame History

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, 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. TokamakCores 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:

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:

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 wont 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
}}
  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:

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:

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 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:

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.