Blog Infos
Author
Published
Topics
, , , ,
Published
Photo by Compare Fibre on Unsplash

 

We often need to automate processes that can be routine and time-consuming if done manually. In a previous project, we had a White-Label solution that was based on a single codebase and included various UI customizations for clients. In this post, we will discuss different approaches and tools that helped us start writing code immediately, without wasting extra time searching for the changes from designers.

FIGMA/WHATEVER DSM

Let’s start with the basics. Designers, like programmers, also are trying to follow the DRY principle and come up with excellent tools like Design System Manager for this purpose. First, they identify components that may be repeated in the project and develop a structured set of colors and styles. Therefore, if any color or style changes in one place, it automatically changes in the entire project. Convenient, right? In Figma or any other tool, it is possible to export such configurations into a special file, which we will then analyze and use to generate code based on it.

If you work with Jetpack Compose, you might know classes like ColorScheme and TextStyle. With the Material philosophy, we use ColorScheme to describe colors and TextStyle to describe the appearance of text, which are used by components for rendering.

Code Generation

If you’ve used libraries like RoomDagger2, or any others that require code generation, this scheme might already be somewhat familiar to you.

In java, this concept is known as annotation processing, while in Kotlin, it’s referred to as kapt or ksp. Essentially, it’s a compiler feature that helps us analyze code and generate additional files. However, since we can’t attach annotations to JSON files and they are not Kotlin files, a different solution is needed here. Therefore, we will learn how to develop a plugin that takes input data, searches for the required file, and generates the necessary classes.

Gradle Plugin

There are many ways to create a gradle plugin, ranging from writing tasks in the main gradle files to placing code in buildSrc. However, we will follow the recommendations and use composite builds. The idea is simple: the plugins are kept separate from the application code, and then everything is combined. You can find here many examples of how to use this mechanism with AGP, and I recommend exploring it as you might find answers to questions you’ve had for a long time.

basic module structure

 

There’s nothing extraordinary here; the difference lies in how the plugin is connected at the highest level — by using includeBuild():

 

pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
include(":plugins")
rootProject.name = "build-logic"

In the build.gradle.kts file of your plugin project, you need to specify the dependencies required for the plugin to work properly, along with some basic configurations. Here’s an example setup for your plugin:

plugins {
`java-gradle-plugin`
alias(libs.plugins.kotlin.jvm)
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
dependencies {
implementation(libs.android.gradlePlugin.api)
compileOnly(gradleKotlinDsl())
implementation(libs.kotlinpoet)
implementation(libs.json)
}
gradlePlugin {
plugins {
create("DSMGeneratorPlugin") {
id = "DSMGeneratorPlugin"
implementationClass = "com.azazellj.dsm.DSMGeneratorPlugin"
}
}
}
Gradle Plugin Implementation

To achieve our goal of automating the generation of files from Figma for Jetpack Compose using a gradle plugin, we need to follow these steps:

  • analyze the structure of DSM tokens and prepare corresponding models
  • find all possible flavors that contain tokens describing each client’s design in the project
  • somehow analyze this data and learn to generate the necessary files
  • make it convenient and wrap it in gradle tasks
  • write code!

As we can see, the file contains color descriptions, which can be cascaded and composed of multiple logical levels, where the last one contains the information we are interested in. Typically, resources have a similar structure, where their type and value are specified.

{
  "color": {
    "m3": {
      "white": {
        "description": "",
        "type": "color",
        "value": "#ffffffff",
        "blendMode": "normal"
      },
      "black": {...},
      "sys": {
        "light": {...},
        "dark": {...}
      },
      "ref": {...},
      "key-colors": {...},
      "source": {...},
      "surfaces": {...},
      "state-layers": {...}
    }
  },
  "font": {...},
  "typography": {...}
}

Regarding color, we are interested in its value, so the corresponding class will contain its hexadecimal representation.

internal data class ComposeColor(val value: String) : GeneratedType
// replace color from #ffffffff to 0xffffffff
internal val ComposeColor.valueFormatted: String get() = value.replace("#", "0x")
view raw ComposeColor.kt hosted with ❤ by GitHub
Gradle tasks
internal abstract class AbstractGeneratorTask<T : GeneratedType> : DefaultTask() {
@get:InputFile
abstract val dsmFile: RegularFileProperty
@get:OutputDirectory
abstract val flavorDirectory: DirectoryProperty
@get:Input
abstract val `package`: Property<String>
abstract val componentClassName: ClassName
abstract val generatedFileName: String
abstract val rootDSMKey: String
@TaskAction
fun taskAction() { ... }
private fun searchTypeTokens(
jsonObject: JSONObject,
parentKey: String,
result: MutableMap<String, T>,
) { ... }
abstract fun isTypeToken(field: Any): Boolean
abstract fun convert(field: Any): T
abstract fun writeTypeToken(key: String, type: T): CodeBlock
}
if you need flexibility in development, you’ll typically programmatically describe tasks

During the task registration, we need to set all the fields marked by annotations, namely — the path to the JSON file, the path to the folder of our flavor, and the final package of the generated file.

private fun AbstractGeneratorTask<*>.configure(flavor: String) {
val android = project.extensions.getByType(CommonExtension::class.java)
// app/src/international/assets/design-tokens.json
val dsmTokensFile = project.layout.projectDirectory
.dir("src")
.dir(flavor)
.dir("assets")
.file("design-tokens.json")
// app/src/international/java
val flavorDirectory = project.layout.projectDirectory
.dir("src")
.dir(flavor)
.dir("java")
this.group = "dsm"
this.dsmFile.set(dsmTokensFile)
this.`package`.set("${android.namespace}.ui.theme")
this.flavorDirectory.set(flavorDirectory.asFile)
}

For easing our lives, we’ll hardcode some things, but if more flexibility is needed, it’s worth looking into gradle extensions.

Since this task is abstract, some configurations are left to the implementation. Therefore, the structure for the task that will generate a file with colors will look like this:

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

internal abstract class ColorGeneratorTask : AbstractGeneratorTask<ComposeColor>() {
companion object {
private val COLOR = ClassName("androidx.compose.ui.graphics", "Color")
}
override val componentClassName: ClassName @Internal get() = COLOR
override val generatedFileName: String @Internal get() = "ThemeColors"
override val rootDSMKey: String @Internal get() = "color"
override fun isTypeToken(field: Any): Boolean = field is JSONObject && field.has("value")
override fun convert(field: Any): ComposeColor {
field as JSONObject
return ComposeColor(value = field.getString("value"))
}
override fun writeTypeToken(key: String, type: ComposeColor): CodeBlock {
return CodeBlock.of("%T(${type.valueFormatted})", componentClassName)
}
}

To make our tasks runnable, we need to register them in the project.

Firstly, using the plugin, we need to determine what build variants exist in the project and register a task for each type.

open class DSMGeneratorPlugin : Plugin<Project> {
override fun apply(project: Project) {
// registers a callback on the application of the Android Application plugin
project.plugins.withType(AppPlugin::class.java) { _ ->
// look up the generic android component
val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
// run through all variants and create generator tasks
androidComponents.onVariants(
selector = androidComponents.selector().withBuildType("release"),
) { variant ->
val flavor = variant.flavorName!!
val generateDSMColorsTask = project.tasks.register<ColorGeneratorTask>(
name = "generate${flavor.capitalized()}DSMColors",
configuration = { configure(flavor) },
)
val generateDSMTextStylesTask = project.tasks.register<TextStyleGeneratorTask>(
name = "generate${flavor.capitalized()}DSMTextStyles",
configuration = { configure(flavor) },
)
val generateDSMTypographyTask = project.tasks.register<TypographyGeneratorTask>(
name = "generate${flavor.capitalized()}DSMTypography",
configuration = { configure(flavor) },
)
// create single task that runs all generator tasks
project.tasks.register<DefaultTask>(
name = "generate${flavor.capitalized()}DSM",
) {
this.group = "dsm"
dependsOn(generateDSMColorsTask, generateDSMTextStylesTask, generateDSMTypographyTask)
}
}
}
}
}

Now, let’s get to the most interesting part — how file generation actually happens.

For this, we need to use a tool called KotlinPoet. It allows us to easily create the files we need. The entire mechanism consists of various builders, with which we add file types, their properties, values, and so on.

@TaskAction
fun taskAction() {
val typeMap = mutableMapOf<String, T>()
// read file
val jsonObject = JSONObject(dsmFile.get().asFile.readText())
// find all type tokens
searchTypeTokens(jsonObject.getJSONObject(rootDSMKey), rootDSMKey, typeMap)
val fileBuilder = FileSpec.builder(
packageName = `package`.get(),
fileName = generatedFileName,
)
val objectBuilder = TypeSpec
.objectBuilder(
name = generatedFileName,
)
.addModifiers(KModifier.INTERNAL)
// write all properties
for ((key, type) in typeMap) {
objectBuilder.addProperty(
PropertySpec.builder(
// some keys have dots in name as opacity
name = key.replace(".", "_"),
type = componentClassName,
modifiers = arrayOf(KModifier.INTERNAL),
)
.initializer(writeTypeToken(key, type))
.build(),
)
}
fileBuilder.addType(objectBuilder.build())
fileBuilder.build().writeTo(flavorDirectory.get().asFile)
}

The essence of our task lies in parsing JSON, obtaining the necessary data, and writing it to the corresponding files.

fun searchTypeTokens(
jsonObject: JSONObject,
parentKey: String,
result: MutableMap<String, T>,
) {
for (key in jsonObject.keys()) {
// 'medium - prominent' to 'mediumProminent'
val keyFormatted = key.replace(" ", "").split("-").joinToString(
separator = "",
transform = { it.capitalized().trim() },
)
val fullKey = if (parentKey.isEmpty()) key else "$parentKey$keyFormatted"
when (val value = jsonObject.get(key)) {
is JSONObject -> {
if (isTypeToken(value)) {
result[fullKey] = convert(field = value)
continue
}
searchTypeTokens(value, fullKey, result)
}
}
}
}

Since we’re familiar with the structure of the JSON file, we can write a function that will iterate through it and collect the data. In the end, it will be a map with keys as field names and values as models.

Once we have the list of all fields, we use KotlinPoet to describe what we want to create in the file, which will contain an object with the fields that were read. As a result, we should have files that look like this:

If needed, we can directly use styles and colors.

And that’s it! Our initial task is complete. Now we have the ability to upload the file to the project, run the task, and get the files that can be used in development. What used to be done manually and could contain errors due to human factors now works smoothly and doesn’t require additional dependencies (the previous Android and current iOS solutions required installing a ton of Node.js modules to run a script that didn’t fully accomplish its tasks and still requires developer intervention).

Non-obvious challenges

Developing a plugin is half the battle; there will always be something that goes wrong:

  • due to the complexity of the project, the designers didn’t use Material Design properly and created dozens, if not hundreds, of custom-named colors and styles; as a result, using the standard MaterialTheme was not possible, and our plugin generated custom ColorScheme and Typography classes
  • a considerable amount of time was spent on initial development and migration; however, if you have a clean Material setup and don’t need to spend hours resolving token naming issues with designers, this shouldn’t be a problem
  • all @Preview annotations must be wrapped in our custom theme
  • designs themselves can be a problem; one of the clients had a button with a gradient, although a color should have been used for this token; this was elegantly resolved with a workaround solution, but we strongly urged designers not to allow such conflicts in the future
  • configuration complexity — while the process itself is straightforward, issues like the previous one may require significant changes to the plugin’s logic or model structure

That’s all. Share in the comments if you’ve built similar solutions or if you know how to avoid writing a custom theme every time for previewing. If anyone needs the code, the entire project is available here.

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
With JCenter sunsetted, distributing public Kotlin Multiplatform libraries now often relies on Maven Central…
READ MORE
Menu