Compose for Web on Canvas/Skia/Wasm: I have a simp...
# compose-web
o
Compose for Web on Canvas/Skia/Wasm: I have a simple function
BrowserViewportWindow
for the initial window. It is easy to use, supports resizing, fills the entire browser viewport, sets the window title correctly and does not require a stylesheet. Inspired by code in @Tlaster's PreCompose. If you want to try: Code in 🧵
👏🏾 1
👏 11
Copy code
@file:Suppress(
    "INVISIBLE_MEMBER",
    "INVISIBLE_REFERENCE",
    "EXPOSED_PARAMETER_TYPE"
) // WORKAROUND: ComposeWindow and ComposeLayer are internal

import androidx.compose.runtime.Composable
import androidx.compose.ui.window.ComposeWindow
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLStyleElement
import org.w3c.dom.HTMLTitleElement

private const val CANVAS_ELEMENT_ID = "ComposeTarget" // Hardwired into ComposeWindow

/**
 * A Skiko/Canvas-based top-level window using the browser's entire viewport. Supports resizing.
 */
fun BrowserViewportWindow(
    title: String = "Untitled",
    content: @Composable ComposeWindow.() -> Unit
) {
    val htmlHeadElement = document.head!!
    htmlHeadElement.appendChild(
        (document.createElement("style") as HTMLStyleElement).apply {
            type = "text/css"
            appendChild(
                document.createTextNode(
                    """
                    html, body {
                        overflow: hidden;
                        margin: 0 !important;
                        padding: 0 !important;
                    }

                    #$CANVAS_ELEMENT_ID {
                        outline: none;
                    }
                    """.trimIndent()
                )
            )
        }
    )

    fun HTMLCanvasElement.fillViewportSize() {
        setAttribute("width", "${window.innerWidth}")
        setAttribute("height", "${window.innerHeight}")
    }

    var canvas = (document.getElementById(CANVAS_ELEMENT_ID) as HTMLCanvasElement).apply {
        fillViewportSize()
    }

    ComposeWindow().apply {
        window.addEventListener("resize", {
            val newCanvas = canvas.cloneNode(false) as HTMLCanvasElement
            canvas.replaceWith(newCanvas)
            canvas = newCanvas

            val scale = layer.layer.contentScale
            newCanvas.fillViewportSize()
            layer.layer.attachTo(newCanvas)
            layer.layer.needRedraw()
            layer.setSize((newCanvas.width / scale).toInt(), (newCanvas.height / scale).toInt())
        })

        // WORKAROUND: ComposeWindow does not implement `setTitle(title)`
        val htmlTitleElement = (
            htmlHeadElement.getElementsByTagName("title").item(0)
                ?: document.createElement("title").also { htmlHeadElement.appendChild(it) }
            ) as HTMLTitleElement
        htmlTitleElement.textContent = title

        setContent {
            content(this)
        }
    }
}
And you'd call it like this:
Copy code
import androidx.compose.material.Text
import org.jetbrains.skiko.wasm.onWasmReady

fun main() {
    onWasmReady {
        BrowserViewportWindow("My Compose Application") {
            Text("Hello Compose for Web/Canvas!")
        }
    }
}
d
Super! Thanks for sharing @Oliver.O 👍
h
Nice, never tried canvas. could you upload/link your project? 😅
g
I just incorporated this into the sample web app for my plotting library https://github.com/KoalaPlot/koalaplot-samples. A new build should be up in a few minutes at https://koalaplot.github.io/koalaplot-samples/index.html.
Thanks Oliver!
o
Hey everyone, good to hear that you like it. @hfhbd I don't have a project to publish, unfortunately, but thankfully, Greg has done just that, so everyone can see how to set up the Gradle build and
index.html
. @Greg Steckman You should be able to safely remove
styles.css
and the attributes
width="1024" height="768"
from the HTML
canvas
element for clarity, as
[bB]rowserViewportWindow
would override those anyway. Apart from that, consider the function an easy, slightly hacky, but viable entry point into Compose for Web on Canvas. I hope that as soon as stuff is easy to use, more people will try. The idea of having the entire Compose UI (non-DOM) infrastructure available on the Web is so enticing (productivity-wise, but also performance-wise). Of course, everything is still at an early stage.
g
I've found including Compose for web/canvas in my build to not be difficult. The main hiccups are that not all features are implemented and you sometimes don't find out until runtime, like lazy grids and anything that scrolls (scrollable tab pane, scrollable columns, etc.),
o
Exactly, I found it hard to believe how easy the integration actually is. Unimplemented stuff is there (scrolling for sure, but also text input missing a visible caret), but some of it does not seem to be that hard to tackle (I could be wrong here). Let's see what it can become. 😃
m
That’s really great 🙏. I just upgraded my conference example to that too. (https://github.com/mipastgt/JavaForumStuttgartTalk2022/tree/main/PolySpiralMpp) You can try it live here: https://mpmediasoft.de/test/PolySpiralMpp/ Be careful! The graphics has some hypnotizing effect 😉
While playing around a bit I noticed that resizing of the window is a little bit bumpy. Is this inherent to how the web canvas works or is there anything one could do about this? I have to admit that I do know almost nothing about web technologies 🤔.
o
Yes, that's to be expected: There is exactly zero optimization, so on each resize event the entire content will be redrawn. This is way below usual Compose standards, which would just redraw those regions that were actually affected by a size change. So it's less than perfect (that's also why I did not try to introduce it via a PR), but still usable.
m
I have simplified your code a little bit
Copy code
window.addEventListener("resize", {
//            val newCanvas = canvas.cloneNode(false) as HTMLCanvasElement
//            canvas.replaceWith(newCanvas)
//            canvas = newCanvas

            val scale = layer.layer.contentScale
            canvas.fillViewportSize()
            layer.layer.attachTo(canvas)
            layer.layer.needRedraw()
            layer.setSize((canvas.width / scale).toInt(), (canvas.height / scale).toInt())
        })
and my app works as before but I am wondering why you had the commented out part in there. Am I missing something?
o
Maybe you don't. That was part of the code I found in PreCompose (see initial comment). If it's not really necessary, fine. I did not explore the interdependencies between the Compose Skia layer stuff and HTML cancas, just combined several scattered parts into one function, integrated required CSS and added setting the title.
a
but at my company, me and one other dev got web canvas compose for our android comppose ui library to relatively work and render in 1.5 days
so easy
t
Thank you so much!! @Oliver.O Your work helped me get this working! https://twitter.com/thelumiereguy/status/1582806206552252416
a
On my MacBook Air M1 2020, in Chrome, after window resize it scales to exactly half of the actual width and height. It seems that canvas width and height return the correct dimensions in pixels, but layer scale is 2.0.
o
Interesting. Is the Mac special in this regard? Sounds you’re close to fixing the problem. 🙂
a
I could fix it, but I'm unfamiliar with web dev. I could remove division by scale, but this should break other devices I guess. Will check later. Thanks!
m
Compose Canvas is always measuring in pixels. You have to use something like
Copy code
val density = LocalDensity.current
and then you can use
Copy code
val width = with(density) { someWidthInPixels.toDp() }
to get the normal width in dp. I also have a Mac and I don’t see the problem you describe when using the above code.
a
As far as I can see, that code runs outside of Compose. That one inside event listener.
m
Does this example show any strange sizing effects on your Mac? https://www.mpmediasoft.de/test/PolySpiralMpp
a
Nope, looks fine.
m
a
Thanks! I already tried that one. I copied both
BrowserViewportWindow
and
index.html
and my project still has the issue. However, I have just cloned and ran
PolySpiralMpp
and it works fine. I still can't find the cause.
Ok. Turned out that downgrading Compose from
1.3.0
and Kotlin from
1.8.0
to
1.3.0-alpha01-dev849
and
1.7.20
respectively solves the issue for me. Wondering whether this is a regression or the implementation of
BrowserViewportWindow
should be updated to match some internal changes?
m
Good to know. I’d be interested in that too but don’t have the time to do any research in that direction myself at the moment.
a
Thanks anyway! This is not urgent for me as well. Will stay subscribed.
d
Also trying this out. Naively removing the use of
layer.layer.contentScale
and setting
scale
to
1.0
fixes it on my MacBook too; which begs the question: Is
layer.layer.contentScale
even supposed to be related to screen density or was this perhaps a mistaken assumption?
Also; I'm not seeing why the canvas object needs to be cloned during the resize process. I removed the cloning on the canvas and everything seemed to resize just as 'well' as before (it's a bit janky with or without but that's to be expected). Is the cloning an unnecessary use of resources? Not very experienced at front-end but neither Google or ChatGPT could illuminate on this.
@Oliver.O ☝️
o
@darkmoon_uk Yes, there is no need to clone the canvas object, as mentioned above: https://kotlinlang.slack.com/archives/C01F2HV7868/p1660225896213389?thread_ts=1660083398.571449&cid=C01F2HV7868 For a more up-to-date incarnation you could look at this code: https://github.com/OliverO2/compose-counting-grid/tree/master/src/frontendJsMain/kotlin
d
Thanks @Oliver.O
Just an evenings fooling around but I got a GitLab CI pipeline deploying straight into 'production' so there is that 😄
a
I also had the density issue on a Mac M1, where after resizing everything is halved, I fixed it like this:
Copy code
ComposeWindow().apply {
        window.addEventListener("resize", {
            val newCanvas = canvas.cloneNode(false) as HTMLCanvasElement
            val density = window.devicePixelRatio.toFloat() // <-- get density
            canvas.replaceWith(newCanvas)
            canvas = newCanvas

            val scale = layer.layer.contentScale
            newCanvas.fillViewportSize()
            layer.layer.attachTo(newCanvas)
            layer.layer.needRedraw()
            // and use it here
            layer.setSize((newCanvas.width / scale * density).toInt(), (newCanvas.height / scale * density).toInt())
        })
Lol, scale = 2 and density = 2, so they cancel each other out.... don't know if they are always the same, but I'll keep this for now.
o
Checked this on a PC running Ubuntu with a "normal" 43" UHD monitor:
window.devicePixelRatio = 1
, so that appears to be the differentiator. Thanks for sharing!
Added the fix to the more recent version in the repo: https://github.com/OliverO2/compose-counting-grid
z
Hmm, been trying to run it, but the page seems blank always. used correct index.html with the correct 2nd js file, using kotlin version of 1.7.20 and the compose 1.3.0-alpha01-dev849
o
What happens if you use the project in its original state in the repo? What does the browser console log show?
z
Failed to load resource: the server responded with a status of 404 (Not Found) localhost/:1 Refused to execute script from 'http://localhost:8081/webJvmAndroid-web.js' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled. got these two above errors. Initially when I saw logs in IDEA I thought webJvmAndroid-web.js is the one to use, but for main entry point, it says to use web.js. So, after changing that to web.js, it is working now.. IDEA Logs: Asset Size Chunks Chunk Names web.js 10.5 MiB main [emitted] main Entrypoint main = web.js [0] multi (webpack)-dev-server/client?http://localhost:8081 ./kotlin/webJvmAndroid-web.js 40 bytes {main} [built]
a
@Oliver.O have you noticed any benefits of switching to WASM?
o
Full Wasm is not working yet due to missing dependencies. For now, it remains Skiko on Wasm, the rest on Kotlin/Js. But I will post an update once that changes.
Made it work with full Wasm (custom Compose build, application integrated as another demo into compose-jb sources, not published). Preliminary results with this particular application suggest that Wasm can yield about 28% higher FPS rates, although (not unsurprising at this early stage) there seem to be some rough edges, making frame rates drop by 70%. Details here: https://kotlinlang.slack.com/archives/CDFP59223/p1678050170581749?thread_ts=1677885198.715929&amp;cid=CDFP59223
c
Lol, scale = 2 and density = 2, so they cancel each other out.... don't know if they are always the same, but I'll keep this for now.
Following up on this, I actually had to remove the scale/density calculation. I believe in web, the pixel dimensions returned from window in JavaScript are density independent already. So the resize listener is even simpler
Copy code
window.addEventListener("resize", {
    canvas.fillViewportSize()
    layer.layer.attachTo(canvas)
    layer.setSize(canvas.width, canvas.height)
    layer.layer.needRedraw()
})
s
This workaround is not working in kotlin 1.9.0 and compose 1.4.3 anymore @Oliver.O, getting this error in console:
IrLinkageError: Property accessor 'layer.<get-layer>' can not be called: Private property accessor declared in module <org.jetbrains.compose.ui:ui> can not be accessed in module <compose-instagram-clone-multiplatform:webApp>\n
we are not able to access layer(ComposeLayer) from ComposeWindow now, Is there any workaround for this
s
Cool @Oliver.O, so you created custom compose window for adding resize event listener, I was thinking of same, but i was not sure whether it will be the good approach or not, But thanx, as of now we dont have any other approach, so it looks fine to this approach as of now. Thanx, And i starred the repo, it deserved that. I was wondering why team is not adding this solution in compose itlself?
o
Thanks. I’m not star-chasing with this repo and it was actually created for another purpose. But for now, it is the home of
BrowserViewportWindow
and AFAIK starring it is the easiest way to get notified about updates.
s
Yep, thats why i starred..
o
Update from the trenches of Compose for Web (Wasm/JS/Canvas): Kotlin 1.9.20 has arrived today and as usual, it takes some time for all Wasm artifacts to be released. In the meantime (or if other Wasm libraries are unavailable), you can use Compose for Web (Canvas) with the JavaScript target, featuring complete library support. Unlike before,
BrowserViewportWindow
is no longer needed: Compose Multiplatform releases since 1.5.0-beta02 contain
CanvasBasedWindow
, a fully featured replacement for all functionality formerly provided by
BrowserViewportWindow
. It works identically on
js
and
jsWasm
targets, and is an integral part of Compose Multiplatform. Say goodbye to
BrowserViewportWindow
, we had a
fun
time. Hello
CanvasBasedWindow
! 😃 compose-counting-grid has been updated accordingly, reflecting the latest changes.
👍 4
👍🏾 1
🎉 12
👍🏻 1
c
I still had problems on mobile devices, and came up with this minimal solution: // index.html
Copy code
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">  <!-- note: no meta viewport tag here! -->
    <title>Compose App</title>

<script>
        // Sets the viewport to the correct size for mobile devices
        window.addEventListener("load", function(){ onLoad() } );
        function onLoad() {
           window.setTimeout(function() {
              const meta = document.createElement('meta');
              meta.name = "viewport";
              meta.content = "width=" + (window.screen.width * window.devicePixelRatio) / 2 + ", initial-scale=1";

              document.head.appendChild(meta);
           }, 0);
        }
    </script>
Here’s the project: https://github.com/realityexpander/KMPMensAdventure
1104 Views