Skip to content

Commit

Permalink
feat: basic web support (#206)
Browse files Browse the repository at this point in the history
  • Loading branch information
sargunv authored Dec 31, 2024
1 parent 45b6c27 commit 5ea3da2
Show file tree
Hide file tree
Showing 20 changed files with 315 additions and 56 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ Android and iOS support is implemented with
[MapLibre Native](https://github.com/maplibre/maplibre-native). A broad set of
features are supported.

Desktop support is implemented with
[MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) and
[KCEF](https://github.com/DatL4g/KCEF). It's currently **very** limited and
experimental.
Web support is implemented with
[MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js).

Desktop support is also implemented with MapLibre GL JS and
[KCEF](https://github.com/DatL4g/KCEF) for now, though we'd like to switch to
MapLibre Native.

Web is not yet supported.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ dependencies {
dokka(project(":lib:maplibre-compose:"))
dokka(project(":lib:maplibre-compose-expressions:"))
dokka(project(":lib:maplibre-compose-material3:"))
dokka(project(":lib:kotlin-maplibre-js"))
dokka(project(":lib:compose-html-interop:"))
}

spotless {
Expand Down
6 changes: 5 additions & 1 deletion demo-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ kotlin {

androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.ktor.client.okhttp)
}

Expand All @@ -101,7 +102,10 @@ kotlin {
implementation(libs.ktor.client.okhttp)
}

jsMain.dependencies { implementation(libs.ktor.client.js) }
jsMain.dependencies {
implementation(compose.html.core)
implementation(libs.ktor.client.js)
}

commonTest.dependencies {
implementation(kotlin("test"))
Expand Down
8 changes: 5 additions & 3 deletions demo-app/src/jsMain/resources/index.html
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
<!doctype html>
<!--suppress HtmlUnknownTarget -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MapLibre Compose demo app</title>
<script src="skiko.js"></script>
<link rel='stylesheet' href='https://unpkg.com/[email protected]/dist/maplibre-gl.css'/>
<style>
html,
body {
width: 100%;
height: 100%;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
<script src="skiko.js"></script>
</head>
<body></body>
<script src="app.js"></script>
Expand Down
48 changes: 25 additions & 23 deletions docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,31 @@ to express an interactive map API in Compose.

Android and iOS support is implemented with [MapLibre Native][maplibre-native].

Desktop support is implemented with [MapLibre GL JS][maplibre-js] and
[KCEF][kcef].

| Feature | Android | iOS | Desktop | Web |
| ------------------------------------------------- | ------------------ | ------------------ | ------------------ | --- |
| Render a map | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Overlay Compose UI over the map | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Load Compose resource URIs | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Configure ornaments (compass, logo, attribution) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Configure gestures (pan, zoom, rotate, pitch) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Respond to a map click or long click | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Query visible map features | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Get, set, and animate the camera position | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Convert between screen and geographic coordinates | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Get the currently visible region and bounding box | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Insert, remove, and replace layers | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Configure layers with expressions | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Add data sources by URI or GeoJSON | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Add images to the style | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Add annotations | :x: | :x: | :x: | :x: |
| Snapshot the map as an image | :x: | :x: | :x: | :x: |
| Configure the offline cache | :x: | :x: | :x: | :x: |
| Configure layer transitions | :x: | :x: | :x: | :x: |
Web support is implemented with [MapLibre GL JS][maplibre-js].

Desktop support is also implemented with MapLibre GL JS in [KCEF][kcef] for now,
though we'd like to switch to MapLibre Native.

| Feature | Android | iOS | Desktop | Web |
| ------------------------------------------------- | ------------------ | ------------------ | ------------------ | ------------------ |
| Render a map | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Overlay Compose UI over the map | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Load Compose resource URIs | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Configure ornaments (compass, logo, attribution) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Configure gestures (pan, zoom, rotate, pitch) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Respond to a map click or long click | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Query visible map features | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Get, set, and animate the camera position | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Convert between screen and geographic coordinates | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Get the currently visible region and bounding box | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Insert, remove, and replace layers | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Configure layers with expressions | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Add data sources by URI or GeoJSON | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Add images to the style | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Add annotations | :x: | :x: | :x: | :x: |
| Snapshot the map as an image | :x: | :x: | :x: | :x: |
| Configure the offline cache | :x: | :x: | :x: | :x: |
| Configure layer transitions | :x: | :x: | :x: | :x: |

[compose]: https://www.jetbrains.com/compose-multiplatform/
[maplibre]: https://maplibre.org/
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navi
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinx-browser" }
webview = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "webview" }
kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
Expand Down
18 changes: 18 additions & 0 deletions iosApp/iosApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
B92378962B6B1156000C7307 /* Frameworks */,
7555FF79242A565900829871 /* Resources */,
FB29095E4E4AE33FC6A4F030 /* [CP] Embed Pods Frameworks */,
1DBF57571B44936782348BCD /* [CP] Copy Pods Resources */,
);
buildRules = (
);
Expand Down Expand Up @@ -169,6 +170,23 @@
/* End PBXResourcesBuildPhase section */

/* Begin PBXShellScriptBuildPhase section */
1DBF57571B44936782348BCD /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n";
showEnvVarsInLog = 0;
};
81EA16B41DBA59C4915ABE3F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
Expand Down
3 changes: 3 additions & 0 deletions lib/compose-html-interop/MODULE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Module compose-html-interop

Include an HTML element in a Compose Web UI.
32 changes: 32 additions & 0 deletions lib/compose-html-interop/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
plugins {
id("library-conventions")
id(libs.plugins.kotlin.multiplatform.get().pluginId)
id(libs.plugins.kotlin.composeCompiler.get().pluginId)
id(libs.plugins.compose.get().pluginId)
id(libs.plugins.mavenPublish.get().pluginId)
}

mavenPublishing {
pom {
name = "Compose HTML Interop"
description = "Include an HTML element in a Compose Web UI."
url = "https://github.com/sargunv/maplibre-compose"
}
}

kotlin {
js(IR) { browser() }

sourceSets {
commonMain.dependencies {
implementation(kotlin("stdlib-js"))
implementation(compose.foundation)
}

commonTest.dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.sargunv.composehtmlinterop

import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import kotlinx.browser.document
import org.w3c.dom.HTMLElement

@Composable
public fun <T : HTMLElement> HtmlElement(
factory: () -> T,
update: (T) -> Unit = {},
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current

val container =
rememberDomNode(parent = document.body!!) {
document.createElement("div").unsafeCast<HTMLElement>().apply {
style.position = "absolute"
style.margin = "0px"
}
}

val child = rememberDomNode(parent = container, factory = factory)

SnapshotEffect(child) { update(it) }

Box(modifier.onGloballyPositioned { container.matchLayout(it, density) })

HtmlFocusAdapter(container)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package dev.sargunv.composehtmlinterop

import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import org.w3c.dom.HTMLElement

@Composable
internal fun HtmlFocusAdapter(container: HTMLElement) {
val focusManager = LocalFocusManager.current
var ownFocusRequest by remember { mutableStateOf(false) }

val head = remember { FocusRequester() }
val tail = remember { FocusRequester() }

val currentContainer by rememberUpdatedState(container)

Box(
modifier =
Modifier.focusRequester(head).onFocusChanged {
if (it.isFocused && !ownFocusRequest) {
val htmlHead = currentContainer.firstElementChild
if (htmlHead != null) {
focusManager.clearFocus(force = true)
htmlHead.unsafeCast<HTMLElement>().focus()
} else {
ownFocusRequest = true
tail.requestFocus()
ownFocusRequest = false
focusManager.moveFocus(FocusDirection.Next)
}
}
}
)

Box(
modifier =
Modifier.focusRequester(tail).onFocusChanged {
if (it.isFocused && !ownFocusRequest) {
val htmlTail = currentContainer.lastElementChild
if (htmlTail != null) {
focusManager.clearFocus(force = true)
htmlTail.unsafeCast<HTMLElement>().focus()
} else {
ownFocusRequest = true
head.requestFocus()
ownFocusRequest = false
focusManager.moveFocus(FocusDirection.Previous)
}
}
}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dev.sargunv.composehtmlinterop

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshots.SnapshotStateObserver

@Composable
internal fun <T : Any> SnapshotEffect(target: T, effect: (T) -> Unit) {
val observer = remember { SnapshotStateObserver { it() } }
val currentTarget by rememberUpdatedState(target)
val currentEffect by rememberUpdatedState(effect)
DisposableEffect(observer) {
observer.start()
observer.observeReads(Unit, { currentEffect(currentTarget) }) { currentEffect(currentTarget) }
onDispose {
observer.stop()
observer.clear()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.sargunv.composehtmlinterop

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import org.w3c.dom.HTMLElement
import org.w3c.dom.Node

internal fun Dp.toCssValue(): String = "${value}px"

internal fun HTMLElement.matchLayout(layoutCoordinates: LayoutCoordinates, density: Density) {
with(density) {
style.apply {
val rect = layoutCoordinates.boundsInWindow()
width = rect.width.toDp().toCssValue()
height = rect.height.toDp().toCssValue()
left = rect.left.toDp().toCssValue()
top = rect.top.toDp().toCssValue()
}
}
}

@Composable
internal fun <T : Node> rememberDomNode(parent: Node, factory: () -> T): T {
return remember(key1 = parent, calculation = factory).also { child ->
DisposableEffect(parent, child) {
parent.insertBefore(child, parent.firstChild)
onDispose { parent.removeChild(child) }
}
}
}
2 changes: 1 addition & 1 deletion lib/kotlin-maplibre-js/MODULE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Module maplibre-gl-js-kotlin

Kotlin wrapper for [MapLibre GL JS](https://www.npmjs.com/package/maplibre-gl).
Kotlin bindings for [MapLibre GL JS](https://www.npmjs.com/package/maplibre-gl).
Loading

0 comments on commit 5ea3da2

Please sign in to comment.