Upgrade to Pro — share decks privately, control downloads, hide ads and more …

KotlinConf'23: Automating UI Infrastructure in ...

KotlinConf'23: Automating UI Infrastructure in Jetpack Compose

Declarative UI frameworks encourage the creation of reusable components that can be used in different parts of the app. We all know that reusability is a good engineering practice but what often ends up happening is a little more nuanced:

- As developers add new UI components, the codebase often ends up with hundreds of components that are hard to visualize.
- Discovery is hard and there is no easy way to search for all your components. As a result, your codebase often ends up with duplicate components that offer similar functionality.
- The same problems extend to other aspects of your design system like colors, typography, icons, etc.
- In order to get around this, most mobile teams build their version of a “Component Browser” that lets you visualize your design system. This is often maintained manually with little to no tooling around it.

In order to solve these problems for our team and for the broader Android community, we (Airbnb) built and open-sourced Showkase. Showkase is an annotation-processor based Android library that helps you organize, discover, visualize and automatically screenshot test Jetpack Compose UI elements.

In this talk, we will dive deeper into how we used a KSP based system to solve the problems listed above. We will look into the internals of Showkase and how all this "magic" works. Lastly, we'll look at what to expect from the next few versions of Showkase and how we plan to extend it.

Github - https://github.com/airbnb/Showkase

vinaygaba

May 06, 2023
Tweet

More Decks by vinaygaba

Other Decks in Programming

Transcript

  1. fun MyCustomTextComponent(displayString: String) { Text( text = displayString, style =

    TextStyle( fontWeight = FontWeight.W900, fontFamily = FontFamily.Monospace, fontSize = 32.sp, fontStyle = FontStyle.Italic, color = Color.Magenta, background = Color.Black ) ) }
  2. @Composable fun MyCustomTextComponent(displayString: String) { Text( text = displayString, style

    = TextStyle( fontWeight = FontWeight.W900, fontFamily = FontFamily.Monospace, fontSize = 32.sp, fontStyle = FontStyle.Italic, color = Color.Magenta, background = Color.Black ) ) }
  3. @Composable fun MyCustomTextComponent(displayString: String) { Text( text = displayString, style

    = TextStyle( fontWeight = FontWeight.W900, fontFamily = FontFamily.Monospace, fontSize = 32.sp, fontStyle = FontStyle.Italic, color = Color.Magenta, background = Color.Black ) ) }
  4. @Composable fun MyCustomTextComponent(displayString: String) { ... } @Preview(name = "MyCustomTextComponent",

    group = "SectionHeader") @Composables funsMyCustomTextComponentPreview() { MyCustomTextComponent("KotlinConf 2023") }s
  5. KSFile packageName: KSName fileName: String annotations: List<KSAnnotation> (File annotations) declarations:

    List<KSDeclaration> KSClassDeclaration // class, interface, object simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration classKind: ClassKind primaryConstructor: KSFunctionDeclaration superTypes: List<KSTypeReference> // contains inner classes, member functions, properties, etc. declarations: List<KSDeclaration> KSFunctionDeclaration // top level function simpleName: KSName qualifiedName: KSName containingFile: String typeParameters: KSTypeParameter parentDeclaration: KSDeclaration functionKind: FunctionKind extensionReceiver: KSTypeReference? returnType: KSTypeReference parameters: List<KSValueParameter> // contains local classes, local functions, local variables, etc. declarations: List<KSDeclaration>
  6. data class ShowkaseBrowserComponent( val name: String, val group: String, val

    component: @Composable () → Unit ) interface ShowkaseProvider { fun componentList() : List<ShowkaseBrowserComponent> }
  7. class GeneratedCode: ShowkaseProvider { override fun componentList(): List<ShowkaseBrowserComponent> { return

    listOf( ShowkaseBrowserComponent( ... ), ShowkaseBrowserComponent( ... ), … ShowkaseBrowserComponent( ... ), ) } }
  8. class GeneratedCode: ShowkaseProvider { override fun componentList(): List<ShowkaseBrowserComponent> { return

    listOf( ShowkaseBrowserComponent( ... ), ShowkaseBrowserComponent( ... ), … ShowkaseBrowserComponent( ... ), ) } } Module 1
  9. class GeneratedCode: ShowkaseProvider { override fun componentList(): List<ShowkaseBrowserComponent> { return

    listOf( ShowkaseBrowserComponent( ... ), ShowkaseBrowserComponent( ... ), … ShowkaseBrowserComponent( ... ), ) } } Module 2 Module n . . . Module 1
  10. Which module has all the dependencies? How can the root

    module access previews from other modules?
  11. Fixed package location com.airbnb.android.showkase Metadata of previews from module 1

    module1 module2 module3 module4 module5 Metadata of previews from module 2
  12. Fixed package location com.airbnb.android.showkase Metadata of previews from module 1

    module1 module2 module3 module4 module5 Metadata of previews from module 2
  13. Fixed package location com.airbnb.android.showkase Metadata of previews from module 1

    module1 module2 module3 module4 module5 Metadata of previews from module 2 Metadata of previews from module 3
  14. Fixed package location com.airbnb.showkase Metadata of previews from module 1

    module1 module2 module3 module4 module5 Metadata of previews from module 2 Metadata of previews from module 3
  15. Fixed package location com.airbnb.showkase Metadata of previews from module 1

    module1 module2 module3 module4 module5 Metadata of previews from module 2 Metadata of previews from module 3 (Root Module)
  16. Fixed package location com.airbnb.showkase Metadata of previews from module 1

    module1 module2 module3 module4 module5 Metadata of previews from module 2 Metadata of previews from module 3 (Root Module)
  17. class ShowkaseProcessor( val environment: SymbolProcessorEnvironment ) : SymbolProcessor { override

    fun process(resolver: Resolver): List<KSAnnotated> { return emptyList() } }
  18. class ShowkaseProcessor( val environment: SymbolProcessorEnvironment ) : SymbolProcessor { override

    fun process(resolver: Resolver): List<KSAnnotated> { // TODO: Step1 - Find elements with annotation return emptyList() } }
  19. @Preview( name = "ImageRow", group = "Rows" ) @Composable fun

    ImageRowPreview() { ImageRow( ... ) } resolver.getSymbolsWithAnnotation( ... ) .map { function -> }
  20. @Preview(...) @Composable fun ImageRowPreview() { ImageRow(...) } object MyPreviewsObject {

    @Preview(...) @Composable fun ImageRowPreview() { ImageRow(...) } } class MyClass { @Preview(...) @Composable fun ImageRowPreview() { ImageRow(...) } } resolver.getSymbolsWithAnnotation( ... ) .map { function -> }
  21. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value }
  22. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null }
  23. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null }
  24. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null }
  25. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null } MyClass
  26. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null } MyClass
  27. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null } MyClass
  28. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> val previewAnnotation = function.annotations.first {

    it.shortName.asString() == "Preview" } val name = previewAnnotation.arguments.first { it.name.asString() == "name" }.value val group = previewAnnotation.arguments.first { it.name.asString() == "group" }.value val wrapperClassMetadata = if (function.parentDeclaration is KSClassDeclaration){ val wrappingClass = (function.parentDeclaration as KSClassDeclaration) wrappingClass.simpleName to wrappingClass.classKind } else null to null } MyClass ClassKind.Class or ClassKind.Object
  29. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") .map { function -> ... Metadata( symbol = function,

    previewName = name, previewGroup = group, wrapperDeclarationName = wrapperClassMetadata.first, classKind = wrapperClassMetadata.second ) }
  30. @ShowkaseComposable(name = "MyCustomComponent", group = "Custom") @Preview(name = "MyCustomComponent", group

    = "SectionHeader") @Composables fun MyCustomComponentPreview() { MyCustomComponent("KotlinConf 2023") }
  31. val previewList: List<Metadata> = processPreviewFunctions( ... ) val showkasePreviewList: List<Metadata>

    = processPreviewFunctions( ... ) val consolidatedPreviewList = (showkasePreviewList + previewList)
  32. val previewList: List<Metadata> = processPreviewFunctions( ... ) val showkasePreviewList: List<Metadata>

    = processPreviewFunctions( ... ) val consolidatedPreviewList = (showkasePreviewList + previewList) .distinctBy { it.declaration.packageName + it.wrapperDeclarationName + it.declaration.simpleName }
  33. val previewList: List<Metadata> = processPreviewFunctions( ... ) val showkasePreviewList: List<Metadata>

    = processPreviewFunctions( ... ) val consolidatedPreviewList = (showkasePreviewList + previewList) .distinctBy { it.declaration.packageName + it.wrapperDeclarationName + it.declaration.simpleName } .distinctBy { it.previewGroup + it.previewName }
  34. // Check if the current module is the root module

    val rootModule = resolver.getSymbolsWithAnnotation( ShowkaseRoot :: class.java.name ).firstOrNull()
  35. val previewList: List<Metadata> = processPreviewFunctions( ... ) val showkasePreviewList: List<Metadata>

    = processPreviewFunctions( ... ) val consolidatedPreviewList = (showkasePreviewList + previewList) .distinctBy { it.declaration.packageName + it.wrapperDeclarationName + it.declaration.simpleName } .distinctBy { it.previewGroup + it.previewName } val rootModule: KSAnnotated? = processRootAnnotation( ... )
  36. 🕵 Preview functions aren’t private Validation 👌 Preview functions have

    no non-default parameters 🧩 Only @Composable functions can be annotated with @ShowkaseComposable
  37. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent = ShowkaseBrowserComponent( group = “Rows”, name =

    “ImageRow”, composable = @Composable { ImageRowPreview() } ) Top-level Property
  38. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent = ShowkaseBrowserComponent( group = “Rows”, name =

    “ImageRow”, composable = @Composable { ImageRowPreview() } ) Top-level Property Package name of preview function Preview Name Preview Group
  39. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent = ShowkaseBrowserComponent( group = “Rows”, name =

    “ImageRow”, composable = @Composable { ImageRowPreview() } ) Top-level Property
  40. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent = ShowkaseBrowserComponent( group = “Rows”, name =

    “ImageRow”, composable = @Composable { MyClass().ImageRowPreview() } ) Top-level Property
  41. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent = ShowkaseBrowserComponent( group = “Rows”, name =

    “ImageRow”, composable = @Composable { MyObject.ImageRowPreview() } ) Top-level Property
  42. val rootModule: KSAnnotated? = processRootAnnotation(…) if (rootModule != null) {

    // This means that we are currently processing // the root module }
  43. resolver.getDeclarationsFromPackage(“com.airbnb.android.showkase") .filter { it is KSClassDeclaration } .map { val

    annotations = it.getAnnotationsByType( ShowkaseCodegenMetadata :: class ) } Aggregator Class
  44. resolver.getDeclarationsFromPackage( ... ) .filter { it is KSClassDeclaration } .map

    { val annotations = it.getAnnotationsByType( ShowkaseCodegenMetadata :: class ) annotations.firstOrNull() ?. let { annotation -> GeneratedMetadata( propertyName = annotation.generatedPropertyName, propertyPackage = annotation.packageName, classKind = ClassKind.CLASS, originatingNode = it ) } } Aggregator Class
  45. resolver.getDeclarationsFromPackage( ... ) .filter { it is KSClassDeclaration } .map

    { val annotations = it.getAnnotationsByType( ShowkaseCodegenMetadata :: class ) annotations.firstOrNull() ?. let { annotation -> GeneratedMetadata( propertyName = annotation.generatedPropertyName, propertyPackage = annotation.packageName, classKind = ClassKind.CLASS, originatingNode = it ) } } .filterNotNull() .toList() Aggregator Class
  46. val rootModule: KSAnnotated? = processRootAnnotation( ... ) if (rootModule !=

    null) { val fixedPackageMetadataList = getFixedPackageMetadata( ... ) } Aggregator Class
  47. val rootModule: KSAnnotated? = processRootAnnotation( ... ) if (rootModule !=

    null) { val fixedPackageMetadataList = getFixedPackageMetadata( ... ) val aggregatedMetadata = fixedPackageMetadataList + currentPreviewList.map { it.toGeneratedMetadata() } } Aggregator Class
  48. val rootModule: KSAnnotated? = processRootAnnotation( ... ) if (rootModule !=

    null) { val fixedPackageMetadataList = getFixedPackageMetadata( ... ) val aggregatedMetadata = fixedPackageMetadataList + currentPreviewList.map { it.toGeneratedMetadata() } writeAggregatedFile(aggregatedMetadata) } Aggregator Class
  49. class MyShowkaseRoot_ShowkaseCodegen : ShowkaseProvider { override fun getShowkaseComponents() = listOf(

    comexamplepackagename_Rows_DisclosureRow, comexamplepackagename_Rows_ActionRow, comexamplepackagename_Rows_ImageRow, comexamplepackagename_Inputs_TextInput, comexamplepackagename_Inputs_SelectInput, comexamplepackagename2_Navigation_NavBar, comexamplepackagename2_Navigation_ActionFooter, ) } Aggregator Class
  50. class MyShowkaseRoot_ShowkaseCodegen : ShowkaseProvider { override fun getShowkaseComponents() = listOf(

    comexamplepackagename_Rows_DisclosureRow, comexamplepackagename_Rows_ActionRow, comexamplepackagename_Rows_ImageRow, comexamplepackagename_Inputs_TextInput, comexamplepackagename_Inputs_SelectInput, comexamplepackagename2_Navigation_NavBar, comexamplepackagename2_Navigation_ActionFooter, ) } Aggregator Class
  51. fun Showkase.getBrowserIntent(context: Context): Intent { val intent = Intent(context, ShowkaseBrowserActivity

    :: class.java) intent.putExtra(“SHOWKASE_AGGREGATOR_FILE”, “com.yourpackage.MyRootModule_ShowkaseCodegen”) } Browser Intent
  52. fun Showkase.getBrowserIntent(context: Context): Intent { val intent = Intent(context, ShowkaseBrowserActivity

    :: class.java) intent.putExtra(“SHOWKASE_AGGREGATOR_FILE”, “com.yourpackage.MyRootModule_ShowkaseCodegen”) return intent } Browser Intent
  53. fun Showkase.getBrowserIntent(context: Context): Intent { val intent = Intent(context, ShowkaseBrowserActivity

    :: class.java) intent.putExtra(“SHOWKASE_AGGREGATOR_FILE”, “com.yourpackage.MyRootModule_ShowkaseCodegen”) return intent } Browser Intent
  54. fun Showkase.getBrowserIntent(context: Context): Intent { val intent = Intent(context, ShowkaseBrowserActivity

    :: class.java) intent.putExtra(“SHOWKASE_AGGREGATOR_FILE”, “com.yourpackage.MyRootModule_ShowkaseCodegen”) return intent } // Usage startActivity(Showkase.getBrowserIntent(requireContext())) Browser Intent
  55. class ShowkaseBrowserActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val

    classKey = intent.extras.getString(“SHOWKASE_AGGREGATOR_FILE”) } }
  56. class ShowkaseBrowserActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val

    classKey = intent.extras.getString(“SHOWKASE_AGGREGATOR_FILE”) val provider = Class.forName(classKey) .newInstance() } }
  57. class ShowkaseBrowserActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val

    classKey = intent.extras.getString(“SHOWKASE_AGGREGATOR_FILE”) val provider = Class.forName(classKey) .newInstance() val showkaseMetadata = (provider as ShowkaseProvider) .componentList() } }
  58. class ShowkaseBrowserActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val

    classKey = intent.extras.getString(“SHOWKASE_AGGREGATOR_FILE”) val provider = Class.forName(classKey) .newInstance() val showkaseMetadata = (provider as ShowkaseProvider) .componentList() // Regular app from this point onwards } }
  59. @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.FUNCTION) annotation class ShowkaseComposable( val name: String = "",

    val group: String = "", val styleName: String = "", val defaultStyle: Boolean = false )
  60. @ShowkaseComposable(name = "Button", defaultStyle = true) @Composable fun Preview_Button_Default() {

    ... } @ShowkaseComposable(name = "Button", group = “Inputs”, styleName = "Plus") @Composable fun Preview_Button_PlusStyle() { ... } @ShowkaseComposable(name = "Button", group = “Inputs”, styleName = "Lux") @Composable fun Preview_Button_LuxStyle() { ... }
  61. @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.FIELD) annotation class ShowkaseTypography( val name: String = "",

    val group: String = "", ) @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.FIELD) annotation class ShowkaseColor( val name: String = "", val group: String = "", )
  62. @ShowkaseColor(name = "Primary Color", group = "Material Design") val primaryColor

    = Color(0xFF6200EE) @ShowkaseTypography(name = "Heading1", group = "Material Design") val h1 = TextStyle( fontWeight = FontWeight.Light, fontSize = 96.sp, letterSpacing = (-1.5).sp )
  63. val previewList: List<Metadata> = processPreviewFunctions( ... ) val showkasePreviewList: List<Metadata>

    = processPreviewFunctions( ... ) val colorList: List<Metadata> = processColors( ... ) val typography: List<Metadata> = processTypography( ... )
  64. interface ShowkaseProvider { fun componentList(): List<BrowserComponent> fun colors(): List<BrowserColor> fun

    typography: List<BrowserTypography> fun metadata(): ShowkaseElementsMetadata { val componentList = componentList() val colorList = colors() val typographyList = typography() return ShowkaseElementsMetadata(componentList, colorList, typographyList) } }
  65. fun Showkase.getMetadata(): ShowkaseElementsMetadata { try { val provider = Class.forName(

    "com.example.packagename.RootModule_ShowkaseCodegen" ).newInstance() as ShowkaseProvider return showkaseComponentProvider.metadata() } catch(exception: ClassNotFoundException) { error("Make sure that you have setup Showkase correctly ... ") } }
  66. val showkaseMetadata = Showkase.metadata() val components = showkaseMetadata.componentList() val colors

    = showkaseMetadata.colorList() val typography = showkaseMetadata.typographyList()
  67. @RunWith(TestParameterInjector :: class) class MyPaparazziShowkaseScreenshotTest_PaparazziShowkaseTest :MyScreenshotTest() { @get:Rule val paparazzi:

    Paparazzi = providePaparazzi() @Test fun test_previews( @TestParameter(valuesProvider = PaparazziShowkasePreviewProvider : : class) elementPreview: PaparazziShowkaseTestPreview, @TestParameter(valuesProvider = PaparazziShowkaseDeviceConfigProvider : : class) config: PaparazziShowkaseDeviceConfig, @TestParameter(valuesProvider = PaparazziShowkaseLayoutDirectionProvider : : class) direction: LayoutDirection, @TestParameter(valuesProvider = PaparazziShowkaseUIModeProvider :: class) uiMode: PaparazziShowkaseUIMode, ): Unit { paparazzi.unsafeUpdateConfig(config.deviceConfig.copy(softButtons = false)) takePaparazziSnapshot(paparazzi, elementPreview, direction, uiMode) } private object PaparazziShowkasePreviewProvider : TestParameter.TestParameterValuesProvider { override fun provideValues(): List<PaparazziShowkaseTestPreview> { val metadata = Showkase.getMetadata() val components = metadata.componentList.map( :: ComponentTestPreview) val colors = metadata.colorList.map( : : ColorTestPreview) val typography = metadata.typographyList.map( : : TypographyTestPreview) return components + colors + typography } } private object PaparazziShowkaseDeviceConfigProvider : TestParameter.TestParameterValuesProvider { override fun provideValues(): List<PaparazziShowkaseDeviceConfig> = deviceConfigs() } private object PaparazziShowkaseLayoutDirectionProvider : TestParameter.TestParameterValuesProvider { override fun provideValues(): List<LayoutDirection> = layoutDirections() } private object PaparazziShowkaseUIModeProvider : TestParameter.TestParameterValuesProvider { override fun provideValues(): List<PaparazziShowkaseUIMode> = uiModes() } }
  68. @RunWith(TestParameterInjector :: class) class MyPaparazziShowkaseScreenshotTest_PaparazziShowkaseTest: MyScreenshotTest() { @get:Rule val paparazzi:

    Paparazzi = providePaparazzi() @Test fun test_previews( @TestParameter(valuesProvider = PaparazziShowkasePreviewProvider :: class) elementPreview: PaparazziShowkaseTestPreview, @TestParameter(valuesProvider = PaparazziShowkaseDeviceConfigProvider :: class) config: PaparazziShowkaseDeviceConfig, @TestParameter(valuesProvider = PaparazziShowkaseLayoutDirectionProvider :: class) direction: LayoutDirection, @TestParameter(valuesProvider = PaparazziShowkaseUIModeProvider :: class) uiMode: PaparazziShowkaseUIMode, ): Unit { paparazzi.unsafeUpdateConfig(config.deviceConfig.copy(softButtons = false)) takePaparazziSnapshot(paparazzi, elementPreview, direction, uiMode)
  69. @RunWith(TestParameterInjector :: class) class MyPaparazziShowkaseScreenshotTest_PaparazziShowkaseTest: MyScreenshotTest() { @get:Rule val paparazzi:

    Paparazzi = providePaparazzi() @Test fun test_previews( @TestParameter(valuesProvider = PaparazziShowkasePreviewProvider :: class) elementPreview: PaparazziShowkaseTestPreview, @TestParameter(valuesProvider = PaparazziShowkaseDeviceConfigProvider :: class) config: PaparazziShowkaseDeviceConfig, @TestParameter(valuesProvider = PaparazziShowkaseLayoutDirectionProvider :: class) direction: LayoutDirection, @TestParameter(valuesProvider = PaparazziShowkaseUIModeProvider :: class) uiMode: PaparazziShowkaseUIMode, ): Unit { paparazzi.unsafeUpdateConfig(config.deviceConfig.copy(softButtons = false)) takePaparazziSnapshot(paparazzi, elementPreview, direction, uiMode) } private object PaparazziShowkasePreviewProvider : TestParameter.TestParameterValuesProvider {
  70. @TestParameter(valuesProvider = PaparazziShowkaseUIModeProvider :: class) uiMode: PaparazziShowkaseUIMode, ): Unit {

    paparazzi.unsafeUpdateConfig(config.deviceConfig.copy(softButtons = false)) takePaparazziSnapshot(paparazzi, elementPreview, direction, uiMode) } private object PaparazziShowkasePreviewProvider : TestParameter.TestParameterValuesProvider { override fun provideValues(): List<PaparazziShowkaseTestPreview> { val metadata = Showkase.getMetadata() val components = metadata.componentList.map( :: ComponentTestPreview) val colors = metadata.colorList.map( :: ColorTestPreview) val typography = metadata.typographyList.map( :: TypographyTestPreview) return components + colors + typography } } private object PaparazziShowkaseDeviceConfigProvider : TestParameter.TestParameterValuesProvider { override fun provideValues(): List<PaparazziShowkaseDeviceConfig> = deviceConfigs() }
  71. Wouldn’t it be amazing if I didn’t have to write

    all this code that you showed me today 🧘
  72. 🔨 KSP needs to be in every engineer’s arsenal Summary

    🧩 Keep composability in mind when building UI infrastructure
  73. 🔨 KSP needs to be in every engineer’s arsenal Summary

    🧩 Keep composability in mind when building UI infrastructure 😉 Don’t reinvent the wheel, use Showkase
  74. Thank you, 
 and don’t forget 
 to vote @

    VinayGaba KotlinConf’23 Amsterdam