Skip to content

Commit 49c6fc0

Browse files
authored
Honor @Preview size config in NavGraph thumbnails (#13) (#16)
NavGraph thumbnails always rendered at a fixed phone-portrait canvas: KSP extracted only `locale` from `@Preview` and the Layoutlib renderer hardcoded a single device, so `widthDp`/`heightDp`/`device` never reached the render. - Extract `widthDp`/`heightDp`/`device` in KSP (mirroring the `locale` walk, including multipreview meta-annotations) and carry them through the shared model + the Gradle manifest. - In the Layoutlib render, pass an explicit `device` through and synthesize a `spec:` from `widthDp`/`heightDp`; keep the fixed phone default only when the preview declares no size, so unsized lazy layouts stay bounded. Layoutlib backend only — the Robolectric backend keeps a fixed size for now (its device comes from a class-level @config qualifier).
1 parent 21b2fd2 commit 49c6fc0

8 files changed

Lines changed: 140 additions & 9 deletions

File tree

compose-nav-graph-annotations/api/android/compose-nav-graph-annotations.api

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,25 +220,31 @@ public final class com/github/skydoves/navgraph/model/NavPreviewParam$Companion
220220

221221
public final class com/github/skydoves/navgraph/model/NavPreviewRef {
222222
public static final field Companion Lcom/github/skydoves/navgraph/model/NavPreviewRef$Companion;
223-
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;)V
224-
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
223+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)V
224+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
225225
public final fun component1 ()Ljava/lang/String;
226+
public final fun component10 ()Ljava/lang/String;
226227
public final fun component2 ()Ljava/lang/String;
227228
public final fun component3 ()Ljava/lang/String;
228229
public final fun component4 ()Ljava/util/List;
229230
public final fun component5 ()Ljava/lang/String;
230231
public final fun component6 ()Z
231232
public final fun component7 ()Ljava/lang/String;
232-
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
233-
public static synthetic fun copy$default (Lcom/github/skydoves/navgraph/model/NavPreviewRef;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
233+
public final fun component8 ()Ljava/lang/Integer;
234+
public final fun component9 ()Ljava/lang/Integer;
235+
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
236+
public static synthetic fun copy$default (Lcom/github/skydoves/navgraph/model/NavPreviewRef;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;ILjava/lang/Object;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
234237
public fun equals (Ljava/lang/Object;)Z
238+
public final fun getDevice ()Ljava/lang/String;
239+
public final fun getHeightDp ()Ljava/lang/Integer;
235240
public final fun getLocale ()Ljava/lang/String;
236241
public final fun getPreviewFqn ()Ljava/lang/String;
237242
public final fun getPreviewMethodFqn ()Ljava/lang/String;
238243
public final fun getPreviewName ()Ljava/lang/String;
239244
public final fun getPreviewParameters ()Ljava/util/List;
240245
public final fun getPrimary ()Z
241246
public final fun getThumbnail ()Ljava/lang/String;
247+
public final fun getWidthDp ()Ljava/lang/Integer;
242248
public fun hashCode ()I
243249
public fun toString ()Ljava/lang/String;
244250
}

compose-nav-graph-annotations/api/jvm/compose-nav-graph-annotations.api

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,25 +220,31 @@ public final class com/github/skydoves/navgraph/model/NavPreviewParam$Companion
220220

221221
public final class com/github/skydoves/navgraph/model/NavPreviewRef {
222222
public static final field Companion Lcom/github/skydoves/navgraph/model/NavPreviewRef$Companion;
223-
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;)V
224-
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
223+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)V
224+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
225225
public final fun component1 ()Ljava/lang/String;
226+
public final fun component10 ()Ljava/lang/String;
226227
public final fun component2 ()Ljava/lang/String;
227228
public final fun component3 ()Ljava/lang/String;
228229
public final fun component4 ()Ljava/util/List;
229230
public final fun component5 ()Ljava/lang/String;
230231
public final fun component6 ()Z
231232
public final fun component7 ()Ljava/lang/String;
232-
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
233-
public static synthetic fun copy$default (Lcom/github/skydoves/navgraph/model/NavPreviewRef;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
233+
public final fun component8 ()Ljava/lang/Integer;
234+
public final fun component9 ()Ljava/lang/Integer;
235+
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
236+
public static synthetic fun copy$default (Lcom/github/skydoves/navgraph/model/NavPreviewRef;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;ILjava/lang/Object;)Lcom/github/skydoves/navgraph/model/NavPreviewRef;
234237
public fun equals (Ljava/lang/Object;)Z
238+
public final fun getDevice ()Ljava/lang/String;
239+
public final fun getHeightDp ()Ljava/lang/Integer;
235240
public final fun getLocale ()Ljava/lang/String;
236241
public final fun getPreviewFqn ()Ljava/lang/String;
237242
public final fun getPreviewMethodFqn ()Ljava/lang/String;
238243
public final fun getPreviewName ()Ljava/lang/String;
239244
public final fun getPreviewParameters ()Ljava/util/List;
240245
public final fun getPrimary ()Z
241246
public final fun getThumbnail ()Ljava/lang/String;
247+
public final fun getWidthDp ()Ljava/lang/Integer;
242248
public fun hashCode ()I
243249
public fun toString ()Ljava/lang/String;
244250
}

compose-nav-graph-annotations/src/commonMain/kotlin/com/github/skydoves/navgraph/model/NavGraphModels.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ public data class NavPreviewRef(
140140
val thumbnail: String? = null,
141141
val primary: Boolean = false,
142142
val locale: String? = null,
143+
val widthDp: Int? = null,
144+
val heightDp: Int? = null,
145+
val device: String? = null,
143146
)
144147

145148
/**

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
121121
val id: String,
122122
val params: List<HPreviewParam>,
123123
val locale: String?,
124+
val widthDp: Int?,
125+
val heightDp: Int?,
126+
val device: String?,
124127
)
125128

126129
@TaskAction
@@ -150,6 +153,9 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
150153
"nav${i++}",
151154
pv.previewParameters,
152155
pv.locale,
156+
pv.widthDp,
157+
pv.heightDp,
158+
pv.device,
153159
),
154160
)
155161
}
@@ -219,7 +225,17 @@ public abstract class LayoutlibRenderTask : DefaultTask() {
219225
// `@DevicePreviews`-annotated preview already carries its own device and renders fine either way.
220226
putJsonObject("previewParams") {
221227
put("apiLevel", apiLevel.get())
222-
put("device", PREVIEW_DEVICE)
228+
// Honor @Preview sizing (#13): an explicit `device` passes through; otherwise widthDp/heightDp
229+
// synthesize a device spec (an unset dimension keeps the default 411/891 canvas). The fixed phone
230+
// default (above) still applies when the preview declares no size, so unsized lazy layouts stay bounded.
231+
put(
232+
"device",
233+
s.device ?: if (s.widthDp != null || s.heightDp != null) {
234+
"spec:width=${s.widthDp ?: 411}dp,height=${s.heightDp ?: 891}dp,dpi=420"
235+
} else {
236+
PREVIEW_DEVICE
237+
},
238+
)
223239
// The @Preview(locale = …) qualifier, extracted by KSP (a multipreview's meta-annotation isn't
224240
// visible to the renderer itself, so it must be passed explicitly).
225241
s.locale?.let { put("locale", it) }

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ internal data class HPreview(
6666
val thumbnail: String? = null,
6767
val primary: Boolean = false,
6868
val locale: String? = null,
69+
val widthDp: Int? = null,
70+
val heightDp: Int? = null,
71+
val device: String? = null,
6972
)
7073

7174
internal data class HPreviewParam(val name: String = "", val provider: String = "")
@@ -109,6 +112,9 @@ internal fun parseGraph(text: String): HGraph {
109112
thumbnail = po.str("thumbnail"),
110113
primary = po.bool("primary"),
111114
locale = po.str("locale"),
115+
widthDp = po.int("widthDp"),
116+
heightDp = po.int("heightDp"),
117+
device = po.str("device"),
112118
)
113119
},
114120
start = o.bool("start"),

compose-nav-graph-ksp/src/main/kotlin/com/github/skydoves/navgraph/ksp/NavGraphProcessor.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ internal class NavGraphProcessor(
128128
.forEach { fn ->
129129
fn.annotationsNamed(NAV_PREVIEW).forEach { ann ->
130130
val route = ann.typeArgFqn("route") ?: return@forEach
131+
val size = previewSizeOf(fn)
131132
previews.getOrPut(route) { mutableListOf() }.add(
132133
NavPreviewRef(
133134
previewName = fn.simpleName.asString(),
@@ -136,6 +137,9 @@ internal class NavGraphProcessor(
136137
previewParameters = previewParametersOf(fn),
137138
primary = ann.boolArg("primary"),
138139
locale = previewLocaleOf(fn),
140+
widthDp = size.widthDp,
141+
heightDp = size.heightDp,
142+
device = size.device,
139143
),
140144
)
141145
}
@@ -319,13 +323,17 @@ internal class NavGraphProcessor(
319323
fn.simpleName.asString()
320324
}
321325
val n = seen.merge(base, 1, Int::plus)!!
326+
val size = previewSizeOf(fn)
322327
NavPreviewRef(
323328
previewName = if (n == 1) base else "$base#$n",
324329
previewFqn = fn.qualifiedName?.asString(),
325330
previewMethodFqn = method,
326331
previewParameters = previewParametersOf(fn),
327332
primary = false,
328333
locale = previewLocaleOf(fn),
334+
widthDp = size.widthDp,
335+
heightDp = size.heightDp,
336+
device = size.device,
329337
)
330338
}
331339
// The slug is lossy (two packages/modules differing only by `_` vs `.`/`-`/`:` collapse to one), so append
@@ -403,6 +411,37 @@ internal class NavGraphProcessor(
403411
return null
404412
}
405413

414+
/**
415+
* The `@Preview(widthDp/heightDp/device)` sizing the function's preview declares (null per field when none).
416+
* Walks annotations in declaration order — recursing into multipreview meta-annotations (same cycle guard as
417+
* [previewLocaleOf]) — and returns the FIRST `@Preview` that declares any sizing, taking width/height/device
418+
* from that one annotation so a landscape preview's width and height stay paired.
419+
*/
420+
private fun previewSizeOf(fn: KSFunctionDeclaration): PreviewSize =
421+
firstPreviewSize(fn.annotations, mutableSetOf()) ?: PreviewSize(null, null, null)
422+
423+
private fun firstPreviewSize(
424+
annotations: Sequence<KSAnnotation>,
425+
visited: MutableSet<String>,
426+
): PreviewSize? {
427+
for (ann in annotations) {
428+
val decl = ann.annotationType.resolve().declaration as? KSClassDeclaration ?: continue
429+
val name = decl.qualifiedName?.asString() ?: continue
430+
if (name == PREVIEW) {
431+
val w = ann.intArg("widthDp")?.takeIf { it > 0 }
432+
val h = ann.intArg("heightDp")?.takeIf { it > 0 }
433+
val device = ann.stringArg("device")?.takeIf(String::isNotBlank)
434+
if (w != null || h != null || device != null) return PreviewSize(w, h, device)
435+
continue
436+
}
437+
if (!visited.add(name)) continue
438+
firstPreviewSize(decl.annotations, visited)?.let { return it }
439+
}
440+
return null
441+
}
442+
443+
private data class PreviewSize(val widthDp: Int?, val heightDp: Int?, val device: String?)
444+
406445
/**
407446
* Serializable properties = the navigation arguments: primary-constructor properties (in ctor order) then
408447
* body `val/var` with backing fields (declared order), excluding `@Transient`. Uses *declared* (not
@@ -625,6 +664,8 @@ private fun KSAnnotation.stringArg(name: String): String? = arg(name) as? String
625664

626665
private fun KSAnnotation.boolArg(name: String): Boolean = arg(name) as? Boolean ?: false
627666

667+
private fun KSAnnotation.intArg(name: String): Int? = arg(name) as? Int
668+
628669
private fun KSAnnotation.arg(name: String): Any? = arguments.firstOrNull {
629670
it.name?.asString() ==
630671
name

compose-nav-graph-ksp/src/test/kotlin/com/github/skydoves/navgraph/ksp/NavGraphProcessorTest.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,56 @@ class NavGraphProcessorTest {
368368
assertEquals("fr-rFR", graph.node("Profile").previews.single().locale)
369369
}
370370

371+
@Test
372+
fun navPreview_carriesPreviewSizeAndDevice() {
373+
val graph = compileNavGraph(
374+
SourceFile.kotlin(
375+
"Previews.kt",
376+
"""
377+
package com.app
378+
import androidx.compose.ui.tooling.preview.Preview
379+
import androidx.navigation3.runtime.NavKey
380+
import com.github.skydoves.navgraph.annotations.NavPreview
381+
class Profile : NavKey
382+
@Preview(widthDp = 1280, heightDp = 800, device = "spec:width=1280dp,height=800dp,dpi=240")
383+
@NavPreview(route = Profile::class, primary = true)
384+
fun ProfilePreview() {}
385+
""".trimIndent(),
386+
),
387+
PREVIEW_STUB,
388+
)
389+
390+
val preview = graph.node("Profile").previews.single()
391+
assertEquals(1280, preview.widthDp)
392+
assertEquals(800, preview.heightDp)
393+
assertEquals("spec:width=1280dp,height=800dp,dpi=240", preview.device)
394+
}
395+
396+
@Test
397+
fun navPreview_withoutSize_hasNullSize() {
398+
val graph = compileNavGraph(
399+
SourceFile.kotlin(
400+
"Previews.kt",
401+
"""
402+
package com.app
403+
import androidx.compose.ui.tooling.preview.Preview
404+
import androidx.navigation3.runtime.NavKey
405+
import com.github.skydoves.navgraph.annotations.NavPreview
406+
class Profile : NavKey
407+
@Preview
408+
@NavPreview(route = Profile::class, primary = true)
409+
fun ProfilePreview() {}
410+
""".trimIndent(),
411+
),
412+
PREVIEW_STUB,
413+
)
414+
415+
val preview = graph.node("Profile").previews.single()
416+
assertEquals(null, preview.widthDp)
417+
assertEquals(null, preview.heightDp)
418+
assertEquals(null, preview.device)
419+
}
420+
371421
@Test
372422
fun navPreview_multiplePreviewsSortPrimaryFirst() {
373423
val graph = compileNavGraph(

compose-nav-graph-ksp/src/test/kotlin/com/github/skydoves/navgraph/ksp/NavGraphTestSupport.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ internal val PREVIEW_STUB = SourceFile.kotlin(
4444
annotation class Preview(
4545
val name: String = "",
4646
val locale: String = "",
47+
val widthDp: Int = -1,
48+
val heightDp: Int = -1,
49+
val device: String = "",
4750
)
4851
""".trimIndent(),
4952
)

0 commit comments

Comments
 (0)