Skip to content

Commit

Permalink
Add preview param support
Browse files Browse the repository at this point in the history
  • Loading branch information
geoff-powell committed Aug 26, 2024
1 parent 4fff41b commit 563618f
Show file tree
Hide file tree
Showing 28 changed files with 874 additions and 178 deletions.
114 changes: 114 additions & 0 deletions paparazzi-annotations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# `@Paparazzi`
An annotation used to generate Paparazzi snapshots for composable preview functions.

## Installation
Add the following to your `build.gradle` file

```groovy
apply plugin: 'app.cash.paparazzi.preview'
```

## Basic Usage
Apply the annotation alongside an existing preview method. The annotation processor will generate a manifest of information about this method and the previews applied.

```kotlin
import app.cash.paparazzi.preview.Paparazzi

@Paparazzi
@Preview
@Composable
fun MyViewPreview() {
MyView(title = "Hello, Paparazzi Annotation")
}
```

Run `:recordPaparazziDebug` in your module to generate preview snapshots (and optionally verify them using `:verifyPaparazziDebug`) as you normally would.

A test class to generate snapshots for annotated previews will automatically be generated.
If you prefer to define a custom snapshot test, you mey disable test generation by adding the following to your `build.gradle` file.

```groovy
paparazziPreview {
generateTestClass = false
}
```

You may implement your own test class, as shown below, to create snapshots for all previews included in the generated manifest (`paparazziAnnotations`).

```kotlin
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.preview.PaparazziPreviewData
import app.cash.paparazzi.preview.PaparazziValuesProvider
import app.cash.paparazzi.preview.deviceConfig
import app.cash.paparazzi.preview.snapshot
import com.android.ide.common.rendering.api.SessionParams.RenderingMode.SHRINK
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(TestParameterInjector::class)
class PreviewTests(
@TestParameter(valuesProvider = PreviewConfigValuesProvider::class)
private val preview: PaparazziPreviewData,
) {
private class PreviewConfigValuesProvider : PaparazziValuesProvider(paparazziPreviews)

@get:Rule
val paparazzi = Paparazzi(
deviceConfig = preview.deviceConfig(),
renderingMode = SHRINK,
)

@Test
fun preview() {
paparazzi.snapshot(preview)
}
}
```

## Preview Parameter
If your preview function accepts a parameter using `@PreviewParameter`, then snapshots will be created for each combination of preview / param.

```kotlin
@Paparazzi
@Preview
@Composable
fun MyViewPreview(@PreviewParameter(MyTitleProvider::class) title: String) {
MyView(title = title)
}

class MyTitleProvider : PreviewParameterProvider<String> {
override val values = sequenceOf("Hello", "Paparazzi", "Annotation")
}
```

## Composable Wrapping
If you need to apply additional UI treatment around your previews, you may provide a composable wrapper within the test.

```kotlin
paparazzi.snapshot(preview) { content ->
Box(modifier = Modifier.background(Color.Gray)) {
content()
}
}
```

## Preview Composition
If you have multiple preview annotations applied to a function, or have them nested behind a custom annotation, they will all be included in the snapshot manifest.

```kotlin
@Paparazzi
@ScaledThemedPreviews
@Composable
fun MyViewPreview() {
MyView(title = "Hello, Paparazzi Annotation")
}

@Preview(name = "small light", fontScale = 1f, uiMode = Configuration.UI_MODE_NIGHT_NO, device = PIXEL_3_XL)
@Preview(name = "small dark", fontScale = 1f, uiMode = Configuration.UI_MODE_NIGHT_YES, device = PIXEL_3_XL)
@Preview(name = "large light", fontScale = 2f, uiMode = Configuration.UI_MODE_NIGHT_NO, device = PIXEL_3_XL)
@Preview(name = "large dark", fontScale = 2f, uiMode = Configuration.UI_MODE_NIGHT_YES, device = PIXEL_3_XL)
annotation class ScaledThemedPreviews
```
88 changes: 80 additions & 8 deletions paparazzi-annotations/api/paparazzi-annotations.api
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ public abstract interface class app/cash/paparazzi/annotations/PaparazziPreviewD
}

public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Default : app/cash/paparazzi/annotations/PaparazziPreviewData {
public fun <init> (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
public fun <init> (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function0;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lkotlin/jvm/functions/Function0;
public final fun copy (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;
public final fun component2 ()Lapp/cash/paparazzi/annotations/PreviewData;
public final fun component3 ()Lkotlin/jvm/functions/Function0;
public final fun copy (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function0;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;
public fun equals (Ljava/lang/Object;)Z
public final fun getComposable ()Lkotlin/jvm/functions/Function0;
public final fun getPreview ()Lapp/cash/paparazzi/annotations/PreviewData;
public final fun getSnapshotName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
Expand All @@ -25,15 +27,85 @@ public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Empty : a
}

public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Error : app/cash/paparazzi/annotations/PaparazziPreviewData {
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;
public final fun component2 ()Lapp/cash/paparazzi/annotations/PreviewData;
public final fun component3 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;
public fun equals (Ljava/lang/Object;)Z
public final fun getMessage ()Ljava/lang/String;
public final fun getPreview ()Lapp/cash/paparazzi/annotations/PreviewData;
public final fun getSnapshotName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Provider : app/cash/paparazzi/annotations/PaparazziPreviewData {
public static final field $stable I
public fun <init> (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function3;Lapp/cash/paparazzi/annotations/PreviewParameterData;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lapp/cash/paparazzi/annotations/PreviewData;
public final fun component3 ()Lkotlin/jvm/functions/Function3;
public final fun component4 ()Lapp/cash/paparazzi/annotations/PreviewParameterData;
public final fun copy (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function3;Lapp/cash/paparazzi/annotations/PreviewParameterData;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider;Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function3;Lapp/cash/paparazzi/annotations/PreviewParameterData;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider;
public fun equals (Ljava/lang/Object;)Z
public final fun getComposable ()Lkotlin/jvm/functions/Function3;
public final fun getPreview ()Lapp/cash/paparazzi/annotations/PreviewData;
public final fun getPreviewParameter ()Lapp/cash/paparazzi/annotations/PreviewParameterData;
public final fun getSnapshotName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
public final fun withPreviewParameterIndex (I)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider;
}

public final class app/cash/paparazzi/annotations/PaparazziPreviewDefaults {
public static final field $stable I
public static final field DEVICE_ID Ljava/lang/String;
public static final field INSTANCE Lapp/cash/paparazzi/annotations/PaparazziPreviewDefaults;
}

public final class app/cash/paparazzi/annotations/PreviewData {
public static final field $stable I
public fun <init> ()V
public fun <init> (Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V
public synthetic fun <init> (Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/Float;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Ljava/lang/Integer;
public final fun component4 ()Ljava/lang/Integer;
public final fun component5 ()Ljava/lang/Integer;
public final fun component6 ()Ljava/lang/String;
public final fun component7 ()Ljava/lang/String;
public final fun copy (Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PreviewData;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PreviewData;
public fun equals (Ljava/lang/Object;)Z
public final fun getBackgroundColor ()Ljava/lang/String;
public final fun getDevice ()Ljava/lang/String;
public final fun getFontScale ()Ljava/lang/Float;
public final fun getHeightDp ()Ljava/lang/Integer;
public final fun getLocale ()Ljava/lang/String;
public final fun getUiMode ()Ljava/lang/Integer;
public final fun getWidthDp ()Ljava/lang/Integer;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class app/cash/paparazzi/annotations/PreviewParameterData {
public static final field $stable I
public fun <init> (Ljava/lang/String;Lkotlin/sequences/Sequence;I)V
public synthetic fun <init> (Ljava/lang/String;Lkotlin/sequences/Sequence;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lkotlin/sequences/Sequence;
public final fun component3 ()I
public final fun copy (Ljava/lang/String;Lkotlin/sequences/Sequence;I)Lapp/cash/paparazzi/annotations/PreviewParameterData;
public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PreviewParameterData;Ljava/lang/String;Lkotlin/sequences/Sequence;IILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PreviewParameterData;
public fun equals (Ljava/lang/Object;)Z
public final fun getIndex ()I
public final fun getName ()Ljava/lang/String;
public final fun getValues ()Lkotlin/sequences/Sequence;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

1 change: 1 addition & 0 deletions paparazzi-annotations/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ apply plugin: 'com.vanniktech.maven.publish'

dependencies {
compileOnly libs.compose.runtime
compileOnly libs.tools.layoutlib
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
package app.cash.paparazzi.annotations

import android.content.res.Configuration
import androidx.compose.runtime.Composable

public object PaparazziPreviewDefaults {
public const val DEVICE_ID: String = "id:pixel_5"
}

public sealed interface PaparazziPreviewData {

public data class Default(
val snapshotName: String,
val preview: PreviewData,
val composable: @Composable () -> Unit
) : PaparazziPreviewData {
override fun toString(): String = snapshotName
override fun toString(): String = buildList {
add(snapshotName)
preview.toString().takeIf { it.isNotEmpty() }?.let(::add)
}.joinToString(",")
}

public data class Provider<T>(
val snapshotName: String,
val preview: PreviewData,
val composable: @Composable (T) -> Unit,
val previewParameter: PreviewParameterData<T>
) : PaparazziPreviewData {
override fun toString(): String = buildList {
add(snapshotName)
preview.toString().takeIf { it.isNotEmpty() }?.let(::add)
add(previewParameter.toString())
}.joinToString(",")

public fun withPreviewParameterIndex(index: Int): Provider<T> = copy(previewParameter = previewParameter.copy(index = index))
}

public data object Empty : PaparazziPreviewData {
Expand All @@ -17,8 +41,84 @@ public sealed interface PaparazziPreviewData {

public data class Error(
val snapshotName: String,
val preview: PreviewData,
val message: String
) : PaparazziPreviewData {
override fun toString(): String = snapshotName
override fun toString(): String = buildList {
add(snapshotName)
preview.toString().takeIf { it.isNotEmpty() }?.let(::add)
}.joinToString(",")
}
}

public data class PreviewData(
val fontScale: Float? = null,
val device: String? = null,
val widthDp: Int? = null,
val heightDp: Int? = null,
val uiMode: Int? = null,
val locale: String? = null,
val backgroundColor: String? = null
) {
override fun toString(): String = buildList {
fontScale?.fontScale()?.displayName()?.let(::add)
uiMode?.lightDarkName()?.let(::add)
uiMode?.uiModeName()?.let(::add)
device?.let {
if (it != PaparazziPreviewDefaults.DEVICE_ID) {
add(it.substringAfterLast(":"))
}
}
widthDp?.let { add("w_$it") }
heightDp?.let { add("h_$it") }
locale?.let(::add)
backgroundColor?.let { add("bg_$it") }
}.takeIf { it.isNotEmpty() }
?.joinToString(",")
?: ""
}

public data class PreviewParameterData<T>(
val name: String,
val values: Sequence<T>,
val index: Int = 0
) {
override fun toString(): String = "$name$index"
}

/**
* Maps [fontScale] to enum values similar to Preview
* see:
https://android.googlesource.com/platform/tools/adt/idea/+/refs/heads/mirror-goog-studio-main/compose-designer/src/com/android/tools/idea/compose/pickers/preview/enumsupport/PsiEnumValues.kt
*/
internal fun Float.fontScale() =
FontScale.values().find { this == it.value } ?: FontScale.CUSTOM.apply { value = this@fontScale }

internal enum class FontScale(var value: Float?) {
DEFAULT(1f),
SMALL(0.85f),
LARGE(1.15f),
LARGEST(1.30f),
CUSTOM(null);

fun displayName() = when (this) {
CUSTOM -> "fs_$value"
else -> name
}
}

internal fun Int.lightDarkName() = when (this and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_NO -> "Light"
Configuration.UI_MODE_NIGHT_YES -> "Dark"
else -> null
}

internal fun Int.uiModeName() = when (this and Configuration.UI_MODE_TYPE_MASK) {
Configuration.UI_MODE_TYPE_NORMAL -> "Normal"
Configuration.UI_MODE_TYPE_CAR -> "Car"
Configuration.UI_MODE_TYPE_DESK -> "Desk"
Configuration.UI_MODE_TYPE_APPLIANCE -> "Appliance"
Configuration.UI_MODE_TYPE_WATCH -> "Watch"
Configuration.UI_MODE_TYPE_VR_HEADSET -> "VR_Headset"
else -> null
}
1 change: 1 addition & 0 deletions paparazzi-gradle-plugin/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ tasks.withType(Test).configureEach {
dependsOn(':paparazzi:publishMavenPublicationToProjectLocalMavenRepository')
dependsOn(':paparazzi-annotations:publishMavenPublicationToProjectLocalMavenRepository')
dependsOn(':paparazzi-preview-processor:publishMavenPublicationToProjectLocalMavenRepository')
dependsOn(':paparazzi-preview-test:publishMavenPublicationToProjectLocalMavenRepository')
}

// When cleaning this project, we also want to clean the test projects.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ public class PaparazziPlugin @Inject constructor(

project.addAnnotationsDependency()
project.addProcessorDependency()
project.addPreviewTestDependency()
project.registerGeneratePreviewTask(config, extension)

project.afterEvaluate {
Expand Down Expand Up @@ -343,6 +344,15 @@ public class PaparazziPlugin @Inject constructor(
configurations.getByName("ksp").dependencies.add(dependency)
}

private fun Project.addPreviewTestDependency() {
val dependency = if (isInternal()) {
dependencies.project(mapOf("path" to ":paparazzi-preview-test"))
} else {
dependencies.create("app.cash.paparazzi:paparazzi-preview-test:$VERSION")
}
configurations.getByName("testImplementation").dependencies.add(dependency)
}

private fun Project.isInternal(): Boolean =
providers.gradleProperty("app.cash.paparazzi.internal").orNull == "true"

Expand Down
Loading

0 comments on commit 563618f

Please sign in to comment.