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

KotlinConf'23: Automating UI Infrastructure in Jetpack Compose

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. @
    VinayGaba


    KotlinConf’23


    Amsterdam
    Automating UI
    Infrastructure in
    Jetpack Compose


    Vinay Gaba

    View Slide

  2. View Slide

  3. View Slide

  4. 🚀 Automating UI Infrastructure 🚀

    View Slide

  5. 2021
    2019
    2016
    2013 2023

    View Slide

  6. 2021
    2019
    2016
    2013 2023

    View Slide

  7. View Slide

  8. View Slide

  9. 🚀

    View Slide

  10. 🚀

    View Slide

  11. 200k+

    Github

    Stars ⭐
    🚀

    View Slide

  12. 2021
    2019
    2016
    2013 2023

    View Slide

  13. 2021
    2019
    2016
    2013 2023

    View Slide

  14. View Slide

  15. View Slide

  16. View Slide

  17. View Slide

  18. View Slide

  19. View Slide

  20. View Slide

  21. View Slide


  22. View Slide

  23. fun MyCustomTextComponent(displayString: String) {


    }

    View Slide

  24. 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


    )


    )


    }

    View Slide

  25. @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


    )


    )


    }

    View Slide

  26. @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


    )


    )


    }

    View Slide

  27. View Slide

  28. View Slide

  29. View Slide

  30. View Slide

  31. View Slide

  32. View Slide

  33. View Slide

  34. View Slide

  35. View Slide

  36. View Slide

  37. View Slide

  38. Discoverability

    View Slide

  39. View Slide

  40. Project Codename: Showkase

    View Slide

  41. Requirements

    View Slide

  42. 🌎 All components from the codebase
    Requirements

    View Slide

  43. 🌎 All components from the codebase
    Requirements 🗂 Components are neatly organized

    View Slide

  44. 🌎 All components from the codebase
    Requirements 🗂 Components are neatly organized
    🙅 No manual work

    View Slide

  45. @Composable


    fun MyCustomTextComponent(displayString: String) {


    ...

    }

    View Slide

  46. @Composable


    fun MyCustomTextComponent(displayString: String) {


    ...

    }


    @Preview(name = "MyCustomTextComponent", group = "SectionHeader")


    @Composables


    funsMyCustomTextComponentPreview() {


    MyCustomTextComponent("KotlinConf 2023")


    }s

    View Slide

  47. View Slide

  48. View Slide

  49. Revised Problem Statement:


    Collect all functions annotated with
    @Preview and show them in a component
    browser

    View Slide

  50. Kotlin Symbol Processing (KSP)
    https://kotlinlang.org/docs/ksp-overview.html

    View Slide

  51. KSFile


    packageName: KSName


    fileName: String


    annotations: List (File annotations)


    declarations: List


    KSClassDeclaration
    //
    class, interface, object


    simpleName: KSName


    qualifiedName: KSName


    containingFile: String


    typeParameters: KSTypeParameter


    parentDeclaration: KSDeclaration


    classKind: ClassKind


    primaryConstructor: KSFunctionDeclaration


    superTypes: List


    //
    contains inner classes, member functions, properties, etc.


    declarations: List


    KSFunctionDeclaration
    //
    top level function


    simpleName: KSName


    qualifiedName: KSName


    containingFile: String


    typeParameters: KSTypeParameter


    parentDeclaration: KSDeclaration


    functionKind: FunctionKind


    extensionReceiver: KSTypeReference?


    returnType: KSTypeReference


    parameters: List


    //
    contains local classes, local functions, local variables, etc.


    declarations: List

    View Slide

  52. View Slide

  53. View Slide

  54. Component Name

    View Slide

  55. Component Name
    Component Group

    View Slide

  56. Component Name
    Component Group
    @Preview Function

    View Slide

  57. data class ShowkaseBrowserComponent(


    val name: String,


    val group: String,


    val component: @Composable () → Unit


    )

    View Slide

  58. data class ShowkaseBrowserComponent(


    val name: String,


    val group: String,


    val component: @Composable () → Unit


    )


    interface ShowkaseProvider {


    fun componentList() : List


    }

    View Slide

  59. class GeneratedCode: ShowkaseProvider {


    override fun componentList(): List {


    return listOf(


    ShowkaseBrowserComponent(
    ...
    ),


    ShowkaseBrowserComponent(
    ...
    ),





    ShowkaseBrowserComponent(
    ...
    ),


    )


    }


    }

    View Slide

  60. class GeneratedCode: ShowkaseProvider {


    override fun componentList(): List {


    return listOf(


    ShowkaseBrowserComponent(
    ...
    ),


    ShowkaseBrowserComponent(
    ...
    ),





    ShowkaseBrowserComponent(
    ...
    ),


    )


    }


    }
    Module 1

    View Slide

  61. class GeneratedCode: ShowkaseProvider {


    override fun componentList(): List {


    return listOf(


    ShowkaseBrowserComponent(
    ...
    ),


    ShowkaseBrowserComponent(
    ...
    ),





    ShowkaseBrowserComponent(
    ...
    ),


    )


    }


    }
    Module 2
    Module n
    .


    .


    .
    Module 1

    View Slide

  62. Which module has all the
    dependencies?

    View Slide

  63. @Retention(AnnotationRetention.SOURCE)
    @Target(AnnotationTarget.CLASS)
    annotation class ShowkaseRoot

    View Slide

  64. @Retention(AnnotationRetention.SOURCE)
    @Target(AnnotationTarget.CLASS)
    annotation class ShowkaseRoot
    //
    Setup needed from the user of this library


    @ShowkaseRoot


    class MyShowkaseRoot

    View Slide

  65. Which module has all the
    dependencies?

    View Slide

  66. Which module has all the
    dependencies?
    How can the root module access
    previews from other modules?

    View Slide

  67. module1
    module2
    module3
    module4
    module5

    View Slide

  68. module1
    module2
    module3
    module4
    module5
    > Task :module1:kspDebugKotlin

    View Slide

  69. > Task :module1:kspDebugKotlin
    module1
    module2
    module3
    module4
    module5

    View Slide

  70. > Task :module1:kspDebugKotlin
    > Task :module2:kspDebugKotlin
    module1
    module2
    module3
    module4
    module5

    View Slide

  71. > Task :module1:kspDebugKotlin
    > Task :module2:kspDebugKotlin
    module1
    module2
    module3
    module4
    module5

    View Slide

  72. > Task :module1:kspDebugKotlin
    > Task :module2:kspDebugKotlin
    > Task :module3:kspDebugKotlin
    module1
    module2
    module3
    module4
    module5

    View Slide

  73. > Task :module1:kspDebugKotlin
    > Task :module2:kspDebugKotlin
    > Task :module3:kspDebugKotlin
    module1
    module2
    module3
    module4
    module5

    View Slide

  74. > Task :module1:kspDebugKotlin
    > Task :module2:kspDebugKotlin
    > Task :module3:kspDebugKotlin
    module1
    module2
    module3
    module4
    module5

    View Slide

  75. Fixed package location


    com.airbnb.android.showkase
    module1
    module2
    module3
    module4
    module5

    View Slide

  76. Fixed package location


    com.airbnb.android.showkase
    module1
    module2
    module3
    module4
    module5

    View Slide

  77. Fixed package location


    com.airbnb.android.showkase
    Metadata of previews from module 1
    module1
    module2
    module3
    module4
    module5

    View Slide

  78. Fixed package location


    com.airbnb.android.showkase
    Metadata of previews from module 1
    module1
    module2
    module3
    module4
    module5

    View Slide

  79. Fixed package location


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

    View Slide

  80. Fixed package location


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

    View Slide

  81. 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

    View Slide

  82. com.airbnb.android.showkase

    View Slide

  83. 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

    View Slide

  84. 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)

    View Slide

  85. 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)

    View Slide

  86. Writing the Annotation Processor

    View Slide

  87. class ShowkaseProcessor(


    val environment: SymbolProcessorEnvironment


    ) : SymbolProcessor {


    override fun process(resolver: Resolver): List {


    return emptyList()


    }


    }

    View Slide

  88. Find Symbols with
    Annotation
    Validate Generate Code Use Generated
    Code

    View Slide

  89. Find Symbols with
    Annotation
    Validate Generate Code Use Generated
    Code

    View Slide

  90. class ShowkaseProcessor(


    val environment: SymbolProcessorEnvironment


    ) : SymbolProcessor {


    override fun process(resolver: Resolver): List {


    //
    TODO: Step1 - Find elements with annotation


    return emptyList()


    }


    }

    View Slide

  91. //
    Find all preview functions in the current module


    resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview")

    View Slide

  92. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview")


    .map { function
    ->



    }

    View Slide

  93. @Preview(


    name = "ImageRow",


    group = "Rows"


    )


    @Composable


    fun ImageRowPreview() {


    ImageRow(
    ...
    )


    }
    resolver.getSymbolsWithAnnotation(
    ...
    )


    .map { function
    ->



    }

    View Slide

  94. @Preview(...)
    @Composable
    fun ImageRowPreview() {
    ImageRow(...)
    }
    object MyPreviewsObject {
    @Preview(...)
    @Composable
    fun ImageRowPreview() {
    ImageRow(...)
    }
    }
    class MyClass {
    @Preview(...)
    @Composable
    fun ImageRowPreview() {
    ImageRow(...)
    }
    }
    resolver.getSymbolsWithAnnotation(
    ...
    )


    .map { function
    ->



    }

    View Slide

  95. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview")


    .map { function
    ->

    val previewAnnotation = function.annotations.first {


    it.shortName.asString()
    ==
    "Preview"


    }


    }

    View Slide

  96. 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


    }

    View Slide

  97. 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


    }

    View Slide

  98. 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


    }

    View Slide

  99. 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


    }

    View Slide

  100. 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

    View Slide

  101. 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

    View Slide

  102. 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

    View Slide

  103. 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

    View Slide

  104. resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview")


    .map { function
    ->

    ...

    Metadata(


    symbol = function,


    previewName = name,


    previewGroup = group,


    wrapperDeclarationName = wrapperClassMetadata.first,


    classKind = wrapperClassMetadata.second


    )


    }

    View Slide

  105. @Retention(AnnotationRetention.SOURCE)


    @Target(AnnotationTarget.FUNCTION)


    annotation class ShowkaseComposable(


    val name: String = "",


    val group: String = "",


    )

    View Slide

  106. @ShowkaseComposable(name = "MyCustomComponent", group = "Custom")


    @Preview(name = "MyCustomComponent", group = "SectionHeader")


    @Composables


    fun MyCustomComponentPreview() {


    MyCustomComponent("KotlinConf 2023")


    }

    View Slide

  107. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )

    View Slide

  108. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )


    val consolidatedPreviewList = (showkasePreviewList + previewList)

    View Slide

  109. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )


    val consolidatedPreviewList = (showkasePreviewList + previewList)


    .distinctBy {


    it.declaration.packageName + it.wrapperDeclarationName +


    it.declaration.simpleName


    }

    View Slide

  110. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )


    val consolidatedPreviewList = (showkasePreviewList + previewList)


    .distinctBy {


    it.declaration.packageName + it.wrapperDeclarationName +


    it.declaration.simpleName


    }


    .distinctBy {


    it.previewGroup + it.previewName


    }


    View Slide

  111. //
    Check if the current module is the root module


    val rootModule = resolver.getSymbolsWithAnnotation(


    ShowkaseRoot
    ::
    class.java.name


    ).firstOrNull()

    View Slide

  112. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )


    val consolidatedPreviewList = (showkasePreviewList + previewList)


    .distinctBy {


    it.declaration.packageName + it.wrapperDeclarationName +


    it.declaration.simpleName


    }


    .distinctBy {


    it.previewGroup + it.previewName


    }


    val rootModule: KSAnnotated? = processRootAnnotation(
    ...
    )

    View Slide

  113. Find Elements with
    Annotation
    Validate Generate Code Use Generated
    Code

    View Slide

  114. Validation

    View Slide

  115. 🕵 Preview functions aren’t private
    Validation

    View Slide

  116. 🕵 Preview functions aren’t private
    Validation 👌 Preview functions have no non-default
    parameters

    View Slide

  117. 🕵 Preview functions aren’t private
    Validation 👌 Preview functions have no non-default
    parameters
    🧩 Only @Composable functions can be
    annotated with @ShowkaseComposable

    View Slide

  118. Find Elements with
    Annotation
    Validate Generate Code Use Generated
    Code

    View Slide

  119. Square/KotlinPoet
    https://github.com/square/kotlinpoet

    View Slide

  120. val consolidatePreviewList: List

    View Slide

  121. val consolidatePreviewList: List

    View Slide

  122. val consolidatePreviewList: List

    View Slide

  123. val consolidatePreviewList: List
    Previews from current module that’s


    being processed by KSP

    View Slide

  124. Metadata

    View Slide

  125. Metadata

    View Slide

  126. Metadata
    Top-level Property

    View Slide

  127. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent =


    ShowkaseBrowserComponent(


    group = “Rows”,


    name = “ImageRow”,


    composable = @Composable {


    ImageRowPreview()


    }


    )
    Top-level Property

    View Slide

  128. 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

    View Slide

  129. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent =


    ShowkaseBrowserComponent(


    group = “Rows”,


    name = “ImageRow”,


    composable = @Composable {


    ImageRowPreview()


    }


    )
    Top-level Property

    View Slide

  130. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent =


    ShowkaseBrowserComponent(


    group = “Rows”,


    name = “ImageRow”,


    composable = @Composable {


    MyClass().ImageRowPreview()


    }


    )
    Top-level Property

    View Slide

  131. val comexamplepackagename_Rows_ImageRow: ShowkaseBrowserComponent =


    ShowkaseBrowserComponent(


    group = “Rows”,


    name = “ImageRow”,


    composable = @Composable {


    MyObject.ImageRowPreview()


    }


    )
    Top-level Property

    View Slide

  132. Metadata
    Top-level Property

    View Slide

  133. Metadata
    Top-level Property

    View Slide

  134. Metadata
    Top-level Property Class in fixed


    package

    View Slide

  135. package com.airbnb.android.showkase


    @ShowkaseCodegenMetadata(


    packageName = “com.example.packagename”,


    generatedPropertyName = “comexamplepackagename_Rows_ImageRow”,


    )


    class ShowkaseMetadata_comexamplepackagename_Rows_ImageRow {}
    Class in fixed package

    View Slide

  136. package com.airbnb.android.showkase


    @ShowkaseCodegenMetadata(


    packageName = “com.example.packagename”,


    generatedPropertyName = “comexamplepackagename_Rows_ImageRow”,


    )


    class ShowkaseMetadata_comexamplepackagename_Rows_ImageRow {}
    Class in fixed package

    View Slide

  137. package com.airbnb.android.showkase


    @ShowkaseCodegenMetadata(


    packageName = “com.example.packagename”,


    generatedPropertyName = “comexamplepackagename_Rows_ImageRow”,


    )


    class ShowkaseMetadata_comexamplepackagename_Rows_ImageRow {}
    Class in fixed package

    View Slide

  138. package com.airbnb.android.showkase


    @ShowkaseCodegenMetadata(


    packageName = “com.example.packagename”,


    generatedPropertyName = “comexamplepackagename_Rows_ImageRow”,


    )


    class ShowkaseMetadata_comexamplepackagename_Rows_ImageRow {}
    Class in fixed package

    View Slide

  139. package com.airbnb.android.showkase


    @ShowkaseCodegenMetadata(


    packageName = “com.example.packagename”,


    generatedPropertyName = “comexamplepackagename_Rows_ImageRow”,


    )


    class ShowkaseMetadata_comexamplepackagename_Rows_ImageRow {}
    Class in fixed package

    View Slide

  140. com.airbnb.android.showkase
    ShowkaseMetadata_comexamplepackagename_Rows_DisclosureRow


    ShowkaseMetadata_comexamplepackagename_Rows_ActionRow


    ShowkaseMetadata_comexamplepackagename_Rows_ImageRow


    ShowkaseMetadata_comexamplepackagename_Inputs_TextInput


    ShowkaseMetadata_comexamplepackagename_Inputs_SelectInput


    ShowkaseMetadata_comexamplepackagename2_Navigation_NavBar


    ShowkaseMetadata_comexamplepackagename2_Navigation_ActionFooter
    C
    C
    C
    C
    C
    C
    C

    View Slide

  141. val rootModule: KSAnnotated? = processRootAnnotation(…)

    View Slide

  142. val rootModule: KSAnnotated? = processRootAnnotation(…)


    if (rootModule
    !=
    null) {


    //
    This means that we are currently processing


    //
    the root module


    }

    View Slide

  143. Root Module

    View Slide

  144. Root Module

    View Slide

  145. Root Module
    Aggregator Class

    View Slide

  146. resolver.getDeclarationsFromPackage(“com.airbnb.android.showkase")
    Aggregator Class

    View Slide

  147. resolver.getDeclarationsFromPackage(“com.airbnb.android.showkase")


    .filter { it is KSClassDeclaration }
    Aggregator Class

    View Slide

  148. resolver.getDeclarationsFromPackage(“com.airbnb.android.showkase")


    .filter { it is KSClassDeclaration }


    .map {




    }
    Aggregator Class

    View Slide

  149. resolver.getDeclarationsFromPackage(“com.airbnb.android.showkase")


    .filter { it is KSClassDeclaration }


    .map {


    val annotations = it.getAnnotationsByType(


    ShowkaseCodegenMetadata
    ::
    class


    )


    }
    Aggregator Class

    View Slide

  150. 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

    View Slide

  151. 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

    View Slide

  152. val rootModule: KSAnnotated? = processRootAnnotation(
    ...
    )


    if (rootModule
    !=
    null) {


    }
    Aggregator Class

    View Slide

  153. val rootModule: KSAnnotated? = processRootAnnotation(
    ...
    )


    if (rootModule
    !=
    null) {


    val fixedPackageMetadataList = getFixedPackageMetadata(
    ...
    )


    }
    Aggregator Class

    View Slide

  154. val rootModule: KSAnnotated? = processRootAnnotation(
    ...
    )


    if (rootModule
    !=
    null) {


    val fixedPackageMetadataList = getFixedPackageMetadata(
    ...
    )


    val aggregatedMetadata = fixedPackageMetadataList +


    currentPreviewList.map { it.toGeneratedMetadata() }


    }
    Aggregator Class

    View Slide

  155. val rootModule: KSAnnotated? = processRootAnnotation(
    ...
    )


    if (rootModule
    !=
    null) {


    val fixedPackageMetadataList = getFixedPackageMetadata(
    ...
    )


    val aggregatedMetadata = fixedPackageMetadataList +


    currentPreviewList.map { it.toGeneratedMetadata() }


    writeAggregatedFile(aggregatedMetadata)


    }
    Aggregator Class

    View Slide

  156. 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

    View Slide

  157. 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

    View Slide

  158. val comexamplepackagename_Rows_ImageRow = ShowkaseBrowserComponent(


    group = "Rows",


    name = "ImageRow",


    composable = @Composable {


    ImageRowPreview()


    }


    )

    View Slide

  159. Root Module
    Aggregator Class

    View Slide

  160. Root Module
    Aggregator Class

    View Slide

  161. Root Module
    Aggregator Class Browser Intent

    View Slide

  162. object Showkase
    Browser Intent

    View Slide

  163. fun Showkase.getBrowserIntent(context: Context): Intent {


    }
    Browser Intent

    View Slide

  164. fun Showkase.getBrowserIntent(context: Context): Intent {


    val intent = Intent(context, ShowkaseBrowserActivity
    ::
    class.java)


    }
    Browser Intent

    View Slide

  165. fun Showkase.getBrowserIntent(context: Context): Intent {


    val intent = Intent(context, ShowkaseBrowserActivity
    ::
    class.java)


    intent.putExtra(“SHOWKASE_AGGREGATOR_FILE”,


    “com.yourpackage.MyRootModule_ShowkaseCodegen”)


    }
    Browser Intent

    View Slide

  166. 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

    View Slide

  167. 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

    View Slide

  168. 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

    View Slide

  169. Find Elements with
    Annotation
    Validate Generate Code Use Generated
    Code

    View Slide

  170. class ShowkaseBrowserActivity: ComponentActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {


    }


    }

    View Slide

  171. class ShowkaseBrowserActivity: ComponentActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {


    val classKey = intent.extras.getString(“SHOWKASE_AGGREGATOR_FILE”)


    }


    }

    View Slide

  172. class ShowkaseBrowserActivity: ComponentActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {


    val classKey = intent.extras.getString(“SHOWKASE_AGGREGATOR_FILE”)


    val provider = Class.forName(classKey)


    .newInstance()


    }


    }

    View Slide

  173. 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()


    }


    }

    View Slide

  174. 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


    }


    }

    View Slide

  175. View Slide

  176. View Slide

  177. View Slide

  178. View Slide

  179. View Slide

  180. View Slide

  181. View Slide

  182. View Slide

  183. View Slide

  184. I would like to link the
    style variants of a
    component together 💅

    View Slide

  185. @Retention(AnnotationRetention.SOURCE)


    @Target(AnnotationTarget.FUNCTION)


    annotation class ShowkaseComposable(


    val name: String = "",


    val group: String = "",


    )

    View Slide

  186. @Retention(AnnotationRetention.SOURCE)


    @Target(AnnotationTarget.FUNCTION)


    annotation class ShowkaseComposable(


    val name: String = "",


    val group: String = "",


    val styleName: String = "",


    val defaultStyle: Boolean = false


    )

    View Slide

  187. @ShowkaseComposable(name = "Button", group = “Inputs”, defaultStyle = true)


    @Composable


    fun Preview_Button_Default() {


    ...

    }

    View Slide

  188. @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() {


    ...

    }


    View Slide

  189. View Slide

  190. View Slide

  191. I would like to document
    other UI Elements

    as well 🌈

    View Slide

  192. @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 = "",


    )

    View Slide

  193. @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


    )

    View Slide

  194. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )

    View Slide

  195. val previewList: List = processPreviewFunctions(
    ...
    )


    val showkasePreviewList: List = processPreviewFunctions(
    ...
    )


    val colorList: List = processColors(
    ...
    )


    val typography: List = processTypography(
    ...
    )

    View Slide

  196. interface ShowkaseProvider {


    fun componentList(): List


    }

    View Slide

  197. interface ShowkaseProvider {


    fun componentList(): List


    fun colors(): List


    fun typography: List


    }

    View Slide

  198. View Slide

  199. View Slide

  200. Quality

    View Slide

  201. interface ShowkaseProvider {


    fun componentList(): List


    fun colors(): List


    fun typography: List


    }

    View Slide

  202. interface ShowkaseProvider {


    fun componentList(): List


    fun colors(): List


    fun typography: List


    fun metadata(): ShowkaseElementsMetadata {


    val componentList = componentList()


    val colorList = colors()


    val typographyList = typography()


    return ShowkaseElementsMetadata(componentList, colorList, typographyList)


    }


    }

    View Slide

  203. fun Showkase.getMetadata(): ShowkaseElementsMetadata {


    }

    View Slide

  204. 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
    ...
    ")


    }


    }

    View Slide

  205. val showkaseMetadata = Showkase.metadata()

    View Slide

  206. val showkaseMetadata = Showkase.metadata()


    val components = showkaseMetadata.componentList()


    val colors = showkaseMetadata.colorList()


    val typography = showkaseMetadata.typographyList()

    View Slide

  207. Having access to all UI
    elements that you care
    about is really
    POWERFUL 💪

    View Slide

  208. Use case:


    Screenshot Testing

    View Slide

  209. CashApp/Paparazzi
    https://github.com/CashApp/Paparazzi

    View Slide

  210. @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 {


    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 = deviceConfigs()


    }


    private object PaparazziShowkaseLayoutDirectionProvider :


    TestParameter.TestParameterValuesProvider {


    override fun provideValues(): List = layoutDirections()


    }


    private object PaparazziShowkaseUIModeProvider : TestParameter.TestParameterValuesProvider {


    override fun provideValues(): List = uiModes()


    }


    }

    View Slide

  211. @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)




    View Slide

  212. @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 {



    View Slide



  213. @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 {


    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 = deviceConfigs()


    }



    View Slide

  214. Wouldn’t it be amazing if
    I didn’t have to write all
    this code that you
    showed me today 🧘

    View Slide

  215. Showkase
    https://github.com/airbnb/Showkase

    View Slide

  216. Showkase
    https://github.com/airbnb/Showkase

    View Slide

  217. View Slide

  218. View Slide

  219. View Slide

  220. Summary

    View Slide

  221. 🔨 KSP needs to be in every engineer’s arsenal
    Summary

    View Slide

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

    View Slide

  223. 🔨 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

    View Slide

  224. Thank you,

    and don’t forget

    to vote
    @
    VinayGaba


    KotlinConf’23


    Amsterdam

    View Slide