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 24, 2024
1 parent 0f837db commit 4a9f598
Show file tree
Hide file tree
Showing 23 changed files with 713 additions and 113 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
```
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,80 @@ 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
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package app.cash.paparazzi.gradle

private const val PREVIEW_TEST_SOURCE = """
internal const val PREVIEW_TEST_SOURCE = """
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.annotations.PaparazziPreviewData
import app.cash.paparazzi.preview.DefaultLocaleRule
import app.cash.paparazzi.preview.PaparazziPreviewData
import app.cash.paparazzi.preview.PaparazziValuesProvider
import app.cash.paparazzi.preview.deviceConfig
import app.cash.paparazzi.preview.locale
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.cash.paparazzi.gradle.utils

import app.cash.paparazzi.gradle.PREVIEW_TEST_SOURCE
import app.cash.paparazzi.gradle.PaparazziExtension
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.HasUnitTest
Expand Down Expand Up @@ -75,36 +76,3 @@ internal fun Project.registerGeneratePreviewTask(

private fun String.capitalize() =
replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }

private const val PREVIEW_TEST_SOURCE = """
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.annotations.PaparazziPreviewData
import app.cash.paparazzi.preview.PaparazziValuesProvider
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.Assume.assumeTrue
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(
renderingMode = SHRINK,
)
@Test
fun preview() {
assumeTrue(preview !is PaparazziPreviewData.Empty)
paparazzi.snapshot(preview)
}
}
"""
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ fun HelloPaparazziPreviewConfig() {
Text("Hello Paparazzi Preview Config!")
}

object PreviewData : PreviewParameterProvider<String> {
class PreviewData : PreviewParameterProvider<String> {
override val values: Sequence<String> = sequenceOf(
"Hello, Paparazzi One!",
"Hello, Paparazzi Two!"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 4a9f598

Please sign in to comment.