Run custom code whenever a view's state changes

Viewed 56

I'm trying to build a game in Swift that should run in the browser and build its GUI in Tokamak. I'd like to use a <canvas> element to render the game's 2D graphics [0].

I was able to create a custom view which creates a <div> using Tokamak's DynamicHTML view and place the <canvas> element inside of it. The problem I'm facing is how I can trigger a re-draw of the canvas' content whenever the state of my view changes. I'm trying to find some way to have a function called whenever the view's state changes after any updates have been applied to the DOM.

My current solution is hurting quite bad; I've attached a custom observer to my @ObservedObject containing the state to be drawn. The observer is called during the update so I'm scheduling a call to my paint() function using setTimeout(..., 0). I have to main issues that I'd like to solve:

  • It is a hack and I'm expecting it to break in subtle ways.
  • I had to manually add an observer to the object holding the game's state which seems like I'm circumventing Tokamak's update logic.

Is there a better way to achieve my goal of having the state managed by Tokamak but customize how updates are applied to the DOM?

This is the complete code I currently have, which works as intended. The code can be tested live by running carton init --template tokamak in a new directory, placing the code in the created main.swift file and running carton dev:

import JavaScriptKit
import TokamakDOM
import CombineShim

let global = JSObject.global
let document = global.document

/// Example game state which simply specifies the darkness of a circle rendered to a `<canvas>` element.
class GameState: ObservableObject {
    @TokamakDOM.Published var circleGrayLevel = 0.0
}

struct Canvas: View {
    /// State that should be rendered by this Canvas instance.
    @ObservedObject var gameState: GameState
    
    /// This property is set while  the view is first rendered. It contains a reference to a `<div>` element created through Tokamak into which the `<canvas>` is placed.
    @State private var canvasContainer: JSObject!
    
    /// This property is set once the view is first rendered. It contains reference to the `<canvas>` element we create manually.
    @State private var canvas: JSObject!
    
    /// Keeps a listener on gameState alive.
    @State private var sink: CombineShim.AnyCancellable?
    
    /// Keeps a `JSClosure` reference  to `paint()` alive.
    @State private var paintClosure: JSClosure!
    
    /// This is a hack which allows `paint()` to be called _after_ an update to `gameState` has been processed. Otherwise `paint()` would not have access to the new state of `gameState`.
    private func schedulePaint() {
        _ = global.setTimeout!(paintClosure, 0)
    }
    
    private func paint() {
        var ctx = canvas!.getContext!("2d")
        
        _ = ctx.clearRect(0, 0, canvas.width, canvas.height)
        
        // Convert gray level (darkness) to value (brightness) used for each RGB channel.
        let v = (1 - gameState.circleGrayLevel) * 255
        ctx.fillStyle = .string("rgb(\(v) \(v) \(v))")
        
        /// Draw a circle with a gray controlled by `gameState`.
        _ = ctx.beginPath()
        _ = ctx.arc(100, 75, 50, 0, 2 * Double.pi);
        _ = ctx.fill()
        _ = ctx.stroke()
    }
    
    var body: some View {
        DynamicHTML(
            "div",
            [:],
            listeners: [:])
            ._domRef($canvasContainer)
            .onAppear(perform: {
                /// Create the `<canvas>` element we will draw to. We let Tokamak create a dummy `<div>` element and place our element inside of it. This is necessary because otherwise Tokamak will set the `width` and `height` attributes on the `<canvas>` element on each update. Setting these properties clears the canvas' backing buffer, clearing the drawing.
                canvas = document.createElement("canvas").object
                canvas.width = 300
                canvas.height = 300
                
                /// Workaround.
                /// See https://github.com/swiftwasm/JavaScriptKit/issues/132
                var style = canvas.style
                style.imageRendering = "pixelated"
                
                _ = canvasContainer.appendChild!(canvas)
                
                paintClosure = JSClosure { _ in
                    self.paint()
                    return .undefined
                }
                
                /// Call `paint()` whenever `gameState` changes.
                sink = gameState.objectWillChange.sink { _ in
                    schedulePaint()
                }
                
                /// Initial paint after the `<canvas>` element has been created.
                schedulePaint()
            })
    }
}

struct GameApp: App {
    @StateObject private var gameState = GameState()
    
    var body: some Scene {
        WindowGroup("Test") {
            VStack {
                Canvas(gameState: gameState)
                Slider(value: $gameState.circleGrayLevel, in: 0...1)
            }
        }
    }
}

GameApp.main()

[0]: The game does not use animations but may need to render a large amount of graphical elements, only part of which need to be redrawn on each update, similiar to some older stategy games like Sim City or Civilization. So I may want to draw 10'000 tiles on screen but only need to re-draw 100 on an update. I could do this by placing a lot of Image views in a grid but I don't want to push such a large tree of views though Tokamak on each update.

0 Answers
Related