Skip to content

Commit 21b2fd2

Browse files
authored
Auto-wire Compose ui-tooling for KMP preview rendering (#10) (#12)
On KMP modules Compose `ui-tooling` was not on the render classpath, so the preview renderer drew a "ComposeViewAdapter" placeholder yet reported status SUCCESS with the class only in `missingClasses` (which the plugin never parsed) — so those placeholders were shipped as real thumbnails. - Auto-add the consumer's own `compose.uiTooling` (their exact Compose version, no pinning; androidx `ui-tooling` fallback) to the KMP Android render classpath, skipping when it is already declared. - Parse `missingClasses` and fail the render when `ComposeViewAdapter` is missing, so placeholders are rejected and an actionable hint is logged. - Update the KMP docs + agent guide, and drop the now-redundant androidRuntimeClasspath(ui-tooling) lines from the KotlinConf sample.
1 parent 053c484 commit 21b2fd2

8 files changed

Lines changed: 176 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,8 @@ module shape and wires the right KSP pass automatically:
274274
- **KMP + Android**: the graph is extracted from the Android compilation, and every screen still gets a device free
275275
thumbnail. A shared module has no Android resources of its own, so the renderer reuses the consuming Android app's
276276
merged Compose Multiplatform resources automatically. Complex Compose Multiplatform screens that Layoutlib can't
277-
draw fall back to the Robolectric backend (`renderBackend` defaults to `AUTO`).
277+
draw fall back to the Robolectric backend (`renderBackend` defaults to `AUTO`). The plugin auto-adds the Compose
278+
`ui-tooling` that device free rendering needs on KMP, so this works with only the plugin applied.
278279
- **KMP without Android** (iOS/JS/wasm only): extraction runs on the common metadata pass and produces a structure
279280
only graph: nodes, typed arguments, and transitions, without thumbnails.
280281

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/LayoutlibRenderTask.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
9797
@get:Input
9898
public abstract val apiLevel: Property<String>
9999

100+
/** Whether this module is Kotlin Multiplatform — only tailors the actionable hint when the preview host
101+
* ([COMPOSE_VIEW_ADAPTER]) is missing from the render classpath (KMP fixes it on the Android runtime classpath,
102+
* plain Android via `debugImplementation`). */
103+
@get:Input
104+
public abstract val kmpModule: Property<Boolean>
105+
100106
/** Scratch dir for the generated JSON + raw PNGs + results.json (not the consumed thumbnails). */
101107
@get:Internal
102108
public abstract val workDir: DirectoryProperty
@@ -137,8 +143,13 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
137143
} else {
138144
add(
139145
Shot(
140-
node.id, pv.previewName, pv.primary, method, "nav${i++}",
141-
pv.previewParameters, pv.locale,
146+
node.id,
147+
pv.previewName,
148+
pv.primary,
149+
method,
150+
"nav${i++}",
151+
pv.previewParameters,
152+
pv.locale,
142153
),
143154
)
144155
}
@@ -304,6 +315,13 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
304315
"missing classes on the render classpath — " +
305316
"${res.brokenClasses.joinToString()}"
306317

318+
res.missingClasses.any { it.endsWith("ComposeViewAdapter") } ->
319+
"the preview host '$COMPOSE_VIEW_ADAPTER' is missing from the render classpath"
320+
321+
res.missingClasses.isNotEmpty() ->
322+
"missing classes on the render classpath — " +
323+
"${res.missingClasses.joinToString()}"
324+
307325
res.problems.isNotEmpty() -> "render problem — ${res.problems.first()}"
308326

309327
res.message != null -> res.message
@@ -317,6 +335,23 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
317335
}
318336
indexFile.writeText(index.toString())
319337

338+
// ComposeViewAdapter (Compose `ui-tooling`) absent from the render classpath ⇒ the renderer emits a placeholder
339+
// for EVERY preview (issue #10, typically KMP). navgraph auto-adds ui-tooling; if it couldn't (autoDependencies
340+
// = false, or an undetectable Compose setup), tell the consumer exactly what to add.
341+
if (byId.values.any { r -> r.missingClasses.any { it.endsWith("ComposeViewAdapter") } }) {
342+
val fix = if (kmpModule.getOrElse(false)) {
343+
"add it to the Android runtime classpath: " +
344+
"androidRuntimeClasspath(compose.uiTooling) (Compose Multiplatform) or " +
345+
"androidRuntimeClasspath(\"androidx.compose.ui:ui-tooling:<version>\")"
346+
} else {
347+
"add debugImplementation(\"androidx.compose.ui:ui-tooling:<version>\")"
348+
}
349+
logger.warn(
350+
"navgraph: previews can't render — the preview host '$COMPOSE_VIEW_ADAPTER' " +
351+
"(Compose 'ui-tooling') is missing from the render classpath. $fix",
352+
)
353+
}
354+
320355
if (ok == 0) {
321356
logger.warn(
322357
"navgraph: layoutlib rendered 0/${shots.size} thumbnail(s) in ${elapsed}s ($exitInfo). " +

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/LayoutlibResults.kt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ package com.github.skydoves.navgraph.gradle
1818
import kotlinx.serialization.json.Json
1919
import kotlinx.serialization.json.JsonArray
2020
import kotlinx.serialization.json.JsonObject
21+
import kotlinx.serialization.json.JsonPrimitive
22+
import kotlinx.serialization.json.contentOrNull
2123
import kotlinx.serialization.json.jsonObject
2224
import java.io.File
2325

@@ -31,24 +33,35 @@ internal data class ShotResult(
3133
val imagePath: String?,
3234
val status: String?,
3335
val brokenClasses: List<String>,
36+
val missingClasses: List<String>,
3437
val problems: List<String>,
3538
val message: String?,
3639
)
3740

41+
/** The renderer's preview host. When it isn't on the render classpath the renderer emits a gray
42+
* "…ComposeViewAdapter" placeholder yet still reports `status:SUCCESS` (with the class in `missingClasses`), so it
43+
* must be detected explicitly. Absent on KMP modules when Compose `ui-tooling` isn't on the render classpath. */
44+
internal const val COMPOSE_VIEW_ADAPTER: String = "androidx.compose.ui.tooling.ComposeViewAdapter"
45+
3846
/**
3947
* Whether [result] is a real, full-fidelity render. The renderer reports `status:SUCCESS` even with an empty
4048
* error object, so success additionally requires: no broken classes (a placeholder image), no `problems` (e.g.
41-
* "View measure failed" wrapping a `Resources$NotFoundException` → a 1×1 blank), and a non-empty PNG on disk.
49+
* "View measure failed" wrapping a `Resources$NotFoundException` → a 1×1 blank), the preview host
50+
* [COMPOSE_VIEW_ADAPTER] not among `missingClasses` (its absence ⇒ a placeholder the renderer mislabels SUCCESS),
51+
* and a non-empty PNG on disk.
4252
*/
4353
internal fun isLayoutlibSuccess(result: ShotResult?, image: File?): Boolean =
4454
result != null && (result.status == null || result.status == "SUCCESS") &&
4555
result.brokenClasses.isEmpty() && result.problems.isEmpty() &&
56+
result.missingClasses.none { it.endsWith("ComposeViewAdapter") } &&
4657
image != null && image.isFile && image.length() > 0L
4758

4859
/**
4960
* Parse the renderer's `results.json` (`screenshotResults[]`) → `previewId` → image + render status. The `error`
5061
* object is present even on success (status SUCCESS); `brokenClasses` lists classes Layoutlib failed to load
51-
* (⇒ an error placeholder, not a real render), and `problems` carries non-fatal-looking layout/measure failures.
62+
* (⇒ an error placeholder, not a real render), `missingClasses` lists classes the renderer couldn't find at all
63+
* (the preview host [COMPOSE_VIEW_ADAPTER] lands here), and `problems` carries non-fatal-looking layout/measure
64+
* failures.
5265
*/
5366
internal fun readLayoutlibResults(file: File): Map<String, ShotResult> {
5467
if (!file.isFile) return emptyMap()
@@ -65,6 +78,13 @@ internal fun readLayoutlibResults(file: File): Map<String, ShotResult> {
6578
(it as? JsonObject)?.str("className")
6679
}
6780
?: emptyList()
81+
// The renderer reports an absent class (incl. the preview host ComposeViewAdapter) here — as bare strings, NOT
82+
// in `brokenClasses` — and still reports SUCCESS with a placeholder image, so this field must be parsed too.
83+
val missing =
84+
(err?.get("missingClasses") as? JsonArray)?.mapNotNull {
85+
(it as? JsonPrimitive)?.contentOrNull ?: (it as? JsonObject)?.str("className")
86+
}
87+
?: emptyList()
6888
val problems = (err?.get("problems") as? JsonArray)?.mapNotNull { p ->
6989
(p as? JsonObject)?.let {
7090
it.str("stackTrace")?.lineSequence()?.firstOrNull()?.takeIf { l -> l.isNotBlank() }
@@ -74,6 +94,6 @@ internal fun readLayoutlibResults(file: File): Map<String, ShotResult> {
7494
val message =
7595
err?.str("message")?.takeIf { it.isNotBlank() }
7696
?: err?.str("stackTrace")?.takeIf { it.isNotBlank() }
77-
id to ShotResult(o.str("imagePath"), err?.str("status"), broken, problems, message)
97+
id to ShotResult(o.str("imagePath"), err?.str("status"), broken, missing, problems, message)
7898
}.toMap()
7999
}

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/NavGraphGradlePlugin.kt

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,30 @@ public class NavGraphGradlePlugin : Plugin<Project> {
182182
}
183183
}
184184

185+
// Layoutlib renders `@NavPreview` through `androidx.compose.ui.tooling.ComposeViewAdapter`, in Compose
186+
// `ui-tooling`. For KMP+Android the render classpath is the androidMain runtime — `androidRuntimeClasspath`
187+
// (new `androidLibrary {}`) / `debugRuntimeClasspath` (legacy `androidTarget()`), see registerLayoutlibRender —
188+
// and ui-tooling isn't on it by default, so the renderer emits a "…ComposeViewAdapter" placeholder for every
189+
// preview (issue #10). (Plain Android already gets ui-tooling on its variant runtime via the conventional
190+
// debugImplementation, so only KMP needs this.) Add the consumer's OWN ui-tooling notation — the exact string
191+
// they'd write as `compose.uiTooling`, so version+flavor match their screens (no NoSuchMethodError), honoring
192+
// the no-pin rule above. CMP plugin → read it reflectively; else derive from a declared `androidx.compose.ui:ui`.
193+
val layoutlib = ext.renderBackend.get() != RenderBackend.ROBOLECTRIC
194+
if (kmp && android && layoutlib && ext.renderThumbnails.get()) {
195+
val runtimeCfg = listOf("androidRuntimeClasspath", "debugRuntimeClasspath")
196+
.firstOrNull { configurations.findByName(it) != null }
197+
if (runtimeCfg != null && !uiToolingAlreadyDeclared()) {
198+
val notation = composeUiToolingNotation()
199+
?: androidxComposeUiVersion()?.let { "androidx.compose.ui:ui-tooling:$it" }
200+
if (notation != null) {
201+
dependencies.add(runtimeCfg, notation)
202+
logger.info(
203+
"navgraph: added '$notation' to '$runtimeCfg' so Layoutlib can render KMP previews.",
204+
)
205+
}
206+
}
207+
}
208+
185209
if (!pluginManager.hasPlugin("com.google.devtools.ksp")) {
186210
logger.error(
187211
"navgraph: the KSP plugin 'com.google.devtools.ksp' is not applied, so navgraph can't " +
@@ -302,6 +326,53 @@ public class NavGraphGradlePlugin : Plugin<Project> {
302326
}.getOrNull()
303327
}
304328

329+
/** The consumer's own Compose `ui-tooling` dependency notation (`org.jetbrains.compose.ui:ui-tooling:<their CMP
330+
* version>`) — the exact string they'd write as `compose.uiTooling` — read off the `org.jetbrains.compose`
331+
* plugin's `compose` extension reflectively (no Compose-Gradle-plugin compile dependency). Because it carries the
332+
* consumer's own version + flavor, adding it to the render classpath can't skew the Compose API (NoSuchMethodError).
333+
* Null when the JetBrains Compose plugin isn't applied (androidx-Compose consumers use [androidxComposeUiVersion]). */
334+
private fun Project.composeUiToolingNotation(): String? {
335+
if (!plugins.hasPlugin("org.jetbrains.compose")) return null
336+
val compose = extensions.findByName("compose") ?: return null
337+
fun Any.uiTooling(): String? =
338+
runCatching { javaClass.getMethod("getUiTooling").invoke(this) as? String }.getOrNull()
339+
// `compose.uiTooling` is sugar for either ComposeExtension.getUiTooling() or its getDependencies().getUiTooling().
340+
return (
341+
compose.uiTooling()
342+
?: runCatching {
343+
compose.javaClass.getMethod("getDependencies").invoke(compose)
344+
}.getOrNull()
345+
?.uiTooling()
346+
)?.takeIf(String::isNotBlank)
347+
}
348+
349+
/** The version of an already-DECLARED `androidx.compose.ui:ui`, scanned from the common declarable configs (never
350+
* the runtime classpath we add to, so nothing is resolved). The androidx-Compose fallback for
351+
* [composeUiToolingNotation]. Null when absent or version-less (e.g. pinned only by a BOM). */
352+
private fun Project.androidxComposeUiVersion(): String? =
353+
sequenceOf("commonMainImplementation", "androidMainImplementation", "implementation")
354+
.mapNotNull { configurations.findByName(it) }
355+
.flatMap { it.dependencies.asSequence() }
356+
.firstOrNull { it.group == "androidx.compose.ui" && it.name == "ui" }
357+
?.version?.takeIf(String::isNotBlank)
358+
359+
/** Whether Compose `ui-tooling` (either flavor) is already DECLARED on the render/impl configs — checked against
360+
* declared dependencies only (no resolution), so we never add a duplicate/conflicting coordinate when the consumer
361+
* already wired it by hand. */
362+
private fun Project.uiToolingAlreadyDeclared(): Boolean = sequenceOf(
363+
"androidRuntimeClasspath",
364+
"debugRuntimeClasspath",
365+
"commonMainImplementation",
366+
"androidMainImplementation",
367+
"implementation",
368+
)
369+
.mapNotNull { configurations.findByName(it) }
370+
.flatMap { it.dependencies.asSequence() }
371+
.any {
372+
(it.group == "org.jetbrains.compose.ui" || it.group == "androidx.compose.ui") &&
373+
it.name == "ui-tooling"
374+
}
375+
305376
/** Registers the pipeline tasks for the detected variant (Android Layoutlib render vs KMP structure-only). */
306377
private fun wire(
307378
project: Project,
@@ -851,6 +922,7 @@ public class NavGraphGradlePlugin : Plugin<Project> {
851922
workDir.set(scratchDir)
852923
this.thumbsDir.set(thumbsOut)
853924
previewIndex.set(indexOut)
925+
kmpModule.set(kmp)
854926
dependsOn(kspTaskName, setup.prepare)
855927

856928
if (kmp) {

docs/kotlin-multiplatform.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ module, the renderer runs against that app's fully merged Compose Multiplatform
3737
module's, and every transitive dependency's). Your `composeResources` images, fonts, and strings all show up in the
3838
thumbnails.
3939

40+
!!! note "Compose `ui-tooling` on the render classpath"
41+
42+
Layoutlib renders a `@Preview` through `androidx.compose.ui.tooling.ComposeViewAdapter`, which ships in Compose
43+
`ui-tooling`. On a KMP module the device free render runs against the **Android target's runtime classpath**
44+
(`androidRuntimeClasspath`), where `ui-tooling` isn't present by default — without it the renderer emits a
45+
placeholder for every preview. The plugin **auto-adds it for you** when the JetBrains Compose plugin
46+
(`org.jetbrains.compose`) is applied — the usual Compose Multiplatform case — at your project's own Compose
47+
version, so there's nothing to configure. Add it by hand only when auto-wiring can't apply (`autoDependencies =
48+
false`, or a module on AndroidX Compose without the `org.jetbrains.compose` plugin):
49+
50+
```kotlin
51+
dependencies {
52+
androidRuntimeClasspath(compose.uiTooling) // Compose Multiplatform
53+
// androidx Compose: androidRuntimeClasspath("androidx.compose.ui:ui-tooling:<your compose version>")
54+
}
55+
```
56+
57+
A plain `debugImplementation(…ui-tooling)` is **not** enough on KMP — the render reads the Android *runtime*
58+
classpath, not a debug runtime.
59+
4060
Two render backends cooperate (see [`renderBackend`](gradle-plugin/configuration.md#renderbackend)):
4161

4262
- **Layoutlib** (device free, fast): the same engine Android Studio uses for `@Preview`. Most screens render here.

plugin-agent-guides.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,27 @@ If a build reports `SDK location not found`, create `local.properties` with `sdk
193193
Android SDK on this machine (for example `sdk.dir=/Users/<you>/Library/Android/sdk`). The file is
194194
gitignored.
195195

196+
### 4.7 Kotlin Multiplatform: device free rendering needs Compose ui-tooling
197+
198+
Layoutlib renders a `@Preview` through `androidx.compose.ui.tooling.ComposeViewAdapter`, which ships in Compose
199+
`ui-tooling`. On a KMP module the device free render runs against the Android target's runtime classpath
200+
(`androidRuntimeClasspath`), and `ui-tooling` is not on it by default — without it the renderer produces a
201+
placeholder for every preview. The plugin auto-adds it for you when the JetBrains Compose plugin
202+
(`org.jetbrains.compose`) is applied — the usual Compose Multiplatform case — so you do nothing. Add it by hand only
203+
when auto-wiring cannot apply: you set `navgraph { autoDependencies.set(false) }`, or the module uses AndroidX
204+
Compose directly with no `org.jetbrains.compose` plugin.
205+
206+
```kotlin
207+
dependencies {
208+
androidRuntimeClasspath(compose.uiTooling) // Compose Multiplatform
209+
// androidx Compose instead: androidRuntimeClasspath("androidx.compose.ui:ui-tooling:<your compose version>")
210+
}
211+
```
212+
213+
If KMP previews come back as placeholders and the build log says `ComposeViewAdapter` is missing from the render
214+
classpath, this is the fix. A plain `debugImplementation(...ui-tooling)` is not enough on KMP: the render reads the
215+
Android target's runtime classpath, not a debug runtime.
216+
196217
---
197218

198219
## 5. Choose destinations and edges
@@ -358,6 +379,7 @@ Multiplatform modules are excluded from aggregation automatically and always use
358379
| `@Composable` or `@Preview` does not resolve | the module is not set up for Compose | apply the Compose compiler plugin and add material3 plus the Compose preview tooling |
359380
| KSP task missing, or an empty `nav-graph.json` | the KSP plugin is not applied, or its version does not match Kotlin | apply `com.google.devtools.ksp` at the version that matches your Kotlin version |
360381
| thumbnails are blank and all the same size | a preview crashed because it was not DI free, or a runtime class was missing | make every preview DI free; for a Compose Multiplatform app set ROBOLECTRIC |
382+
| KMP previews are all an identical "ComposeViewAdapter" placeholder | Compose `ui-tooling` is not on the KMP Android render classpath | auto-added for `org.jetbrains.compose` projects; otherwise add `androidRuntimeClasspath(compose.uiTooling)` (see 4.7) |
361383
| the plugin cannot be resolved | `mavenCentral()` is missing from the plugin repositories | add it to `pluginManagement { repositories }` |
362384
| Compose Multiplatform screens never render under Layoutlib | Layoutlib cannot draw those screens | set the backend to ROBOLECTRIC |
363385
| a minSdk merge conflict from a transitive library | a dependency wants a higher minSdk than the consumer | add `tools:overrideLibrary` to the unit test or debug manifest |

samples/sample-kotlinconf/app/shared/build.gradle.kts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,6 @@ kotlin {
146146
jvmToolchain(21)
147147
}
148148

149-
// Android-based preview support
150-
dependencies {
151-
androidRuntimeClasspath(libs.compose.ui.tooling)
152-
}
153-
154149
compose.resources {
155150
packageOfResClass = "org.jetbrains.kotlinconf.generated.resources"
156151
}

samples/sample-kotlinconf/app/ui-components/build.gradle.kts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,3 @@ compose.resources {
8686
nameOfResClass = "UiRes"
8787
packageOfResClass = "org.jetbrains.kotlinconf.ui.generated.resources"
8888
}
89-
90-
// Android-based preview support
91-
dependencies {
92-
androidRuntimeClasspath(libs.compose.ui.tooling)
93-
}

0 commit comments

Comments
 (0)