DEV Community

Cover image for Creating a Kotlin DSL for Jetpack Compose Components
Rafael Henrique
Rafael Henrique

Posted on

Creating a Kotlin DSL for Jetpack Compose Components

Introduction to Kotlin DSL

Kotlin DSL (Domain-Specific Language) is a powerful feature of Kotlin that allows you to create declarative and intuitive APIs. It is widely used in the Kotlin ecosystem, with the Gradle Kotlin DSL being a classic example. With Kotlin DSL, you can transform complex configurations into simple and organized blocks.

But what if we could bring the same concept to Jetpack Compose? Imagine defining your UI components using a declarative and flexible approach. This article explores how to build a custom DSL for a Compose component, like CustomButton.
CustomButton

This article was originally published on Medium: Creating a Kotlin DSL for Jetpack Compose Components

Why Create a DSL for Compose Components?

The declarative approach of Jetpack Compose already makes UI definitions more intuitive. However, when dealing with reusable and highly configurable components, a DSL provides additional advantages:

  • Clear Readability: Configurations are organized into logical blocks, making them easier to understand.
  • Reusability: You can encapsulate repetitive logic and create simple APIs for reuse.
  • Extensibility: Adding new configurations is straightforward and intuitive.

Let’s build a practical example to better understand how this works.

Building a DSL from Scratch

Defining the Configuration Class

First, we create a class to hold the configurations for our component:

class CustomButtonConfig {
    var text: String = ""
    var icon: ImageVector? = null
    var onClick: (() -> Unit)? = null
    var backgroundColor: Color = MaterialTheme.colors.primary
    var textColor: Color = Color.White
    val shapes = mutableListOf<ComposedShape>()
}
Enter fullscreen mode Exit fullscreen mode

This class defines the properties that users can configure in the component, including composed shapes.

Creating the @DslMarker Annotation

To isolate the scope of the DSL and avoid conflicts between different blocks, we create a custom annotation:

@DslMarker
annotation class CustomButtonDSL
Enter fullscreen mode Exit fullscreen mode

We update the relevant classes to apply this annotation:

@CustomButtonDSL
class ComposedShape {
    val shapes = mutableListOf<ShapeConfig>()
}

@CustomButtonDSL
sealed class ShapeConfig {
    data class TriangleConfig(var base: Float, var height: Float, var color: Color) : ShapeConfig()
    data class RhombusConfig(var sideLength: Float, var angle: Float, var color: Color) : ShapeConfig()
}
Enter fullscreen mode Exit fullscreen mode

Creating DSL Functions for Shapes in the Button Context

We add functions to configure shapes within the button DSL:

fun CustomButtonConfig.composedShape(block: ComposedShape.() -> Unit) {
    shapes.add(ComposedShape().apply(block))
}

fun ComposedShape.triangle(base: Float, height: Float, color: Color) {
    shapes.add(ShapeConfig.TriangleConfig(base, height, color))
}

fun ComposedShape.rhombus(sideLength: Float, angle: Float, color: Color) {
    shapes.add(ShapeConfig.RhombusConfig(sideLength, angle, color))
}

Enter fullscreen mode Exit fullscreen mode

Updating the CustomButton Component

We modify the customButton function to render composed shapes:

@Composable
fun customButton(configure: CustomButtonConfig.() -> Unit) {
    val config = CustomButtonConfig().apply(configure)

    Button(
        onClick = config.onClick ?: {},
        colors = ButtonDefaults.buttonColors(backgroundColor = config.backgroundColor),
        modifier = Modifier.padding(8.dp)
    ) {
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // Render Shapes
            config.shapes.forEach { composedShape ->
                composedShape.shapes.forEach { shape ->
                    when (shape) {
                        is ShapeConfig.TriangleConfig -> {
                            Text(
                                text = "\u25B2", // Triangle representation
                                color = shape.color,
                                modifier = Modifier.size(shape.base.dp, shape.height.dp)
                            )
                        }

                        is ShapeConfig.RhombusConfig -> {
                            Text(
                                text = "\u25C6", // Rhombus representation
                                color = shape.color,
                                modifier = Modifier.size(shape.sideLength.dp)
                            )
                        }
                    }
                }
            }
            // Render Text and Icon
            Row(
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                config.icon?.let {
                    Icon(imageVector = it, contentDescription = null, tint = config.textColor)
                }
                if (config.icon != null) Spacer(modifier = Modifier.width(8.dp))
                Text(text = config.text, color = config.textColor)
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Practical Examples

Example 1: Button with Triangle and Rhombus

customButton {
    text = "With Shapes"
    textColor = Color.Black
    backgroundColor = Color.LightGray
    onClick = { println("Button with shapes clicked!") }

    composedShape {
        triangle(base = 50f, height = 30f, color = Color.Red)
        rhombus(sideLength = 40f, angle = 45f, color = Color.Blue)
    }
}

Enter fullscreen mode Exit fullscreen mode

Example 2: Simple Button

customButton {
    text = "Click Here"
    textColor = Color.Black
    onClick = { println("Button clicked!") }
}

Enter fullscreen mode Exit fullscreen mode

Example 3: Button with Icon

customButton {
    text = "Favorite"
    icon = Icons.Default.Favorite
    textColor = Color.Black
    onClick = { println("Favorite clicked!") }
}

Enter fullscreen mode Exit fullscreen mode

Example 4: Button with Custom Style

customButton {
    text = "Send"
    icon = Icons.Default.Send
    onClick = { println("Send clicked!") }
    backgroundColor = Color.Green
    textColor = Color.White
}

Enter fullscreen mode Exit fullscreen mode

Benefits of Kotlin DSL

  1. Improved Readability: Configurations are organized into clear, logical blocks.
  2. Reduced Boilerplate: Less repetitive code, more focus on what matters.
  3. Extensibility: New features can be added to the DSL without altering existing code.
  4. Productivity: The declarative API speeds up development and enhances the developer experience.

Examples

component image example

Conclusion

Creating a Kotlin DSL for Compose components allows you to leverage the power of Kotlin to simplify and organize your UI in a declarative way. Besides making the code more readable, this approach promotes reusability and scalability of components.

Why not give it a try and create your own DSL? With Kotlin, the possibilities are endless. Let’s get to work!

Top comments (0)