Drawing with Compose : The Zebra Spacer
Compose is a powerful toolkit: it alleviates the headache caused by the back and forth between (hopefully) the Kotlin logic and the XML views. It aids in decoupling UI logic from the Android framework, thereby enhancing the testability and maintainability of the app. And it speeds-up a lot the UI development.
Thanks to 👏 at the end of this article if you learn something. And do not hesitate to comment or keep in touch for any comment/suggestion.
Today, I will use Compose to implement a component I found on https://randoma11y.com/ : The Zebra Spacer.
TL;DR : Jump to the end of the article
Randoma11y is a tool designed to identify strong-contrasted color combinations, particularly useful when striving to create an accessible product.
Let’s start creating an environment to help us develop the spacer.
I suppose you already have worked (at least played) with Compose and know how to set it up.
Otherwise you can check the Compose Quick Start on Android Developer.
The minimum to make this code work is to add this in your app build.gradle.
android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2023.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui") // You won't need this if you decide to use Material*
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
}
First the spacer, we know it will accept a Color and a Modifier (modifier should always be the first optional parameter).
@Composable
fun TheZebraSpacer(
color: Color,
modifier: Modifier = Modifier
) {
// For now we put a border to visualise the spacer in the preview
Spacer(modifier = modifier.border(width = 1.dp, color = color))
}
Then we create two previews : a vertical and an horizontal spacer.
// A strong-contrasted color combination
private val backgroundColor = Color(0xFF593072)
private val primaryColor = Color(0xFFFFFF00)
@Preview
@Composable
fun TheVerticalZebraSpacerPreview() {
Column(modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(16.dp)
) {
Text(
stringResource(R.string.lorem_1),
color = primaryColor)
TheZebraSpacer(
color = primaryColor,
modifier = Modifier
.height(100.dp)
.padding(10.dp)
.fillMaxWidth()
)
Text(
stringResource(R.string.lorem_2),
color = primaryColor
)
}
}
@Preview
@Composable
fun TheHorizontalZebraSpacerPreview() {
Row(modifier = Modifier
.background(backgroundColor)
.height(IntrinsicSize.Max)
) {
Icon(
imageVector = Icons.Default.Face,
contentDescription = "Face icon",
modifier = Modifier.size(100.dp),
tint = primaryColor
)
TheZebraSpacer(
color = primaryColor,
modifier = Modifier
.width(100.dp)
.padding(10.dp)
.fillMaxHeight()
)
Icon(
imageVector = Icons.Default.Home,
contentDescription = "Home icon",
modifier = Modifier.size(100.dp),
tint = primaryColor
)
}
}
You should now see this on Android Studio preview (you might have to build the preview a first time, then it updates and renders by itself).
Perfect ! We are all set-up and we can start to implement the Zebra Spacer. We won’t have to modify the preview anymore.
How are we going to tackle the problem ? For one question there are an infinity of answers, thousands of valid ones, and hundreds of good ones.
We could — for example — draw each line from where it should starts to where it should ends, but it will ask us to calculate the exact position of each point. I love to do maths, but what I like more is being pragmatic. Here is how we are going to do.
First, we will draw vertical lines all over the spacer zone, then we are going to rotate everything by 45 degrees, finally we will clip the drawing to fit in the spacer bounds.
We are going to use the drawWithContent modifier, which helps to draw things before or after a composable content (in our case it doesn’t matter as the spacer has no contents). This modifier accepts a lambda scoped on ContentDrawScope which have a size, a center and tons of helpful functions to draw. We are also going to use the CompositionLocal LocalDensity because drawWithContent use pixels but we want to use Dp to adapt to screens size.
Note : We also could have used Canvas(modifier: Modifier) { /* draw here */ } which is a wrapper around the drawBehind() modifier.
@Composable
fun TheZebraSpacer(
color: Color,
modifier: Modifier = Modifier
) {
with(LocalDensity.current) {
Spacer(modifier = modifier
.border(width = 1.dp, color = color) // We keep the border for now to help us visualise the spacer boundaries
.drawWithContent {
// This is were the magic happens
}
)
}
}
Drawing vertical lines
We can start adding two optional parameters which are the space between two lines and line width. From that we calculate the gap between the left side of a line and the left side of the following one, in order to have the total line count.
We calculate also the line length. As our spacer is a rectangle, the maximum size a line can have is the hypothenuse (which is the square root of the sum of its squared sides 🤓). As we are going to clip everything : no need to overthink, we use the max size to draw every line.
We are going to center everything around the drawing zone center (which luckily lives in the ContentDrawScope).
Ready ?? Boom !
@Composable
fun TheZebraSpacer(
color: Color,
modifier: Modifier = Modifier,
lineSpace: Dp = 10.dp,
strokeWidth: Dp = 3.dp
) {
with(LocalDensity.current) {
val gapBetweenLines = (lineSpace + strokeWidth).toPx()
Spacer(modifier = modifier
.border(width = 1.dp, color = color)
.drawWithContent {
val lineCount = (size.width / gapBetweenLines).toInt()
val lineLength = sqrt(size.height.pow(2) + size.width.pow(2))
val startY = center.y - lineLength / 2f
val endY = center.y + lineLength / 2f
(0..lineCount)
.map { it * gapBetweenLines }
.map { x ->
// We are creating an array containing all the positions along the x-axis
// Then each position we draw a line :
drawLine(
color = color,
start = Offset(x, startY),
end = Offset(x, endY),
strokeWidth = strokeWidth.toPx()
)
}
}
)
}
}
Pretty simple right ?? Now let’s look at the preview :
Now will you believe me if I tell you the hard part is behind ?
No ???
Ok, let’s rotate.
Rotating
@Composable
fun TheZebraSpacer(
color: Color,
modifier: Modifier = Modifier,
lineSpace: Dp = 10.dp,
strokeWidth: Dp = 3.dp
) {
with(LocalDensity.current) {
val gapBetweenLines = (lineSpace + strokeWidth).toPx()
Spacer(modifier = modifier
.border(width = 1.dp, color = color)
.drawWithContent {
val lineCount = (size.width / gapBetweenLines).toInt()
val lineLength = sqrt(size.height.pow(2) + size.width.pow(2))
val startY = center.y - lineLength / 2f
val endY = center.y + lineLength / 2f
rotate(45f, center) {
(0..lineCount)
.map { it * gapBetweenLines }
.map { x ->
drawLine(
color = color,
start = Offset(x, startY),
end = Offset(x, endY),
strokeWidth = strokeWidth.toPx()
)
}
}
}
)
}
}
Looking for the diff? Let me help you, we just added rotate(45f, center) {…}.
And a part of it is not even mandatory, we could just have written rotate(45f) {…}. But I’d rather add the second parameter in this article because it is very important to keep in mind a rotation is always around a point : the pivot. The rotate function has its default to center, that’s why it is not necessary to rewrite it. But if you change it to Offset(0f, 0f) you will see the difference.
Let’s add one line before and one line after in order to cover the whole spacer area, the second preview seems a little bit short.
@Composable
fun TheZebraSpacer(
color: Color,
modifier: Modifier = Modifier,
lineSpace: Dp = 10.dp,
strokeWidth: Dp = 3.dp
) {
with(LocalDensity.current) {
val gapBetweenLines = (lineSpace + strokeWidth).toPx()
Spacer(modifier = modifier
.border(width = 1.dp, color = color)
.drawWithContent {
val lineCount = ceil(size.width / gapBetweenLines).toInt()
val lineLength = sqrt(size.height.pow(2) + size.width.pow(2))
val startY = center.y - lineLength / 2f
val endY = center.y + lineLength / 2f
rotate(45f, center) {
(-1..lineCount)
.map { it * gapBetweenLines }
.map { x ->
drawLine(
color = color,
start = Offset(x, startY),
end = Offset(x, endY),
strokeWidth = strokeWidth.toPx()
)
}
}
}
)
}
}
The only difference is the call to ceil() in the lineCount definition and the IntRange starting at -1 instead of 0. (Ceil will round up instead of down)
Last but not least, let’s clip everything to the spacer area.
Clipping
@Composable
fun TheZebraSpacer(
color: Color,
modifier: Modifier = Modifier,
lineSpace: Dp = 10.dp,
strokeWidth: Dp = 3.dp
) {
with(LocalDensity.current) {
val gapBetweenLines = (lineSpace + strokeWidth).toPx()
Spacer(modifier = modifier
.border(width = 1.dp, color = color)
.drawWithContent {
val lineCount = ceil(size.width / gapBetweenLines).toInt()
val lineLength = sqrt(size.height.pow(2) + size.width.pow(2))
val startY = center.y - lineLength / 2f
val endY = center.y + lineLength / 2f
clipRect(
left = 0f,
top = 0f,
right = size.width,
bottom = size.height
) {
rotate(45f, center) {
(-1..lineCount)
.map { it * gapBetweenLines }
.map { x ->
drawLine(
color = color,
start = Offset(x, startY),
end = Offset(x, endY),
strokeWidth = strokeWidth.toPx()
)
}
}
}
}
)
}
}
Finally we remove the border modifier and…
The result
You can find the snippet with the final code here ! It wasn’t that hard right ?
Don’t forget to clap if you liked this article, or if you like zebras, or even the weird choice of color. And do no hesitate to comment or keep in touch.