Background Image

2024-07-23

Shearing the Segmented Display

Ryan Scott image

Introduction

This is article 4 in the series about creating a Compose Multiplatform UI library. The previous article was a guided example of planning and executing a non-trivial canvas drawing. If you haven't been through that article, please read through it before you continue with this article. This article builds on top of that by adding the ability to shear the characters in the segmented display. We'll first show a naive approach to shearing the characters and then implement a much better approach that is applicable to any Compose view.

At the end of this article, you'll take your previous code and add the ability to shear the characters in the segmented display:

Demonstration of what you'll have at the end

TLDR

  1. Add a shearPct: Float argument to the SingleLineSegmentedDisplay composable function.
  2. When the shearPct argument is non-zero, apply a matrix transformation to the Canvas that shears the characters.
  3. Use the inset function to re-center and scale the characters so that they fit within the canvas.

The Approach

Essentially, shearing the font amounts to applying a function to the x-position of each pixel based upon its y-position. In other words, as the y-position increases, the x-position is shifted less and less. Thus, we need to apply some transformation function to each x value.

How do we do this? Below, we'll discuss two approaches:

The Naive Approach

One could imagine injection of a transformation function to our drawClassic7SegmentChar function. This function would take the x and y position of any point as well as the drawable width and drawable height and return the new x position such as the below:

fun DrawScope.drawClassic7SegmentChar(
    origin: Offset,
    activatedSegments: Int,
    width: Float,
    height: Float,
    topLeftPadding: Offset,
    bottomRightPadding: Offset,
    topAreaPercentage: Float,
    thicknessMultiplier: Float,
    gapSizeMultiplier: Float,
    activatedColor: Color,
    deactivatedColor: Color = activatedColor.copy(alpha = 0.05F),
    debuggingEnabled: Boolean = true,
    angledSegmentEndsOf: (Int) -> AngledSegmentEnds = ::symmetricEvenAngledSegmentEnds,
    transformX: (absoluteX: Float, relativeY: Float, drawableWidth: Float, drawableHeight: Float) -> Float
) {
    /* ... */
}
Note

The distinction between "absolute" and "relative" is important. "absolute" means the value on the Canvas and "relative" means the value in the drawable area for the present character. Since we're looking for the absolute transformed x-position, we need the absolute x-position as input. Since we need to calculate how much to transform the x-position based upon the distance from the first drawable y-position, the relativeY is a necessary input.

Then you could call the function like so:

val shearPct = 0.2F // <-- just an example value
drawClassic7SegmentChar(
    origin = Offset(0F, 0F),
    activatedSegments = 0b1111110,
    width = 100F,
    height = 100F,
    topLeftPadding = Offset(0F, 0F),
    bottomRightPadding = Offset(0F, 0F),
    topAreaPercentage = 0.2F,
    thicknessMultiplier = 0.1F,
    gapSizeMultiplier = 0.1F,
    activatedColor = Color.Red,
    transformX = { absoluteX, relativeY, drawableWidth, drawableHeight ->
        val pctHeight = relativeY / drawableHeight                  // how far down the character we are
        val offset = drawableWidth * (1 - pctHeight) * shearPct     // how much to shear
        absoluteX + offset                                          // the new absolute x-position
    }
)

Then, we would need to go to each place in the drawClassic7SegmentChar function where we calculate the x-position and replace it with a call to transformX. While this is straightforward to understand (relative to the next approach) and it's pretty flexible, it's got some serious drawbacks:

  1. The transform function applies only to this function, so we'd have to duplicate it for every other function that we want to shear.
  2. It's error-prone because it requires a lot of effort to ensure that the transform function is applied to every x-position calculation.
  3. Should we want to use this approach on a curve, it may not work as we expect.
Note

There are other problems that need to be solved, such as the fact that shearing the characters results in widening the view. However, we'll skip solving those issues because there is a better approach.

Wouldn't it be better if we could draw each of our characters assuming no shearing and then apply the shearing transformation at a level of abstraction above our drawing code? This would solve all of the above problems. It turns out, yes, we can do this. And it also turns out that we don't have to write much code to make it happen. Moreover, we can apply this kind of transformation to any Compose view.

Transforming the Canvas

One of the most powerful features of the Canvas is to apply transformations to it. DrawScope has a withTransform extension function. Using withTransform, we can supply a function that takes a DrawTransform receiver, enabling us to transform the Canvas using a Matrix.

A Matrix is a 4x4 matrix of floats that will be used to apply transformations to the canvas. If you're an application developer (like me) and you're not familiar with graphics programming, then I don't blame you if you feel a little intimidated. Since we have the Compose Preview, however, we have a really quick way to get feedback on the changes we make.

First Transformation Attempt

First, lets add a shearPct: Float argument to our SingleLineSegmentedDisplay composable function as well as our Classic7SegmentDisplay composable function, setting the default value to 0F, passing the value through from Classic7SegmentDisplay to SingleLineSegmentedDisplay. Here's Classic7SegmentDisplay

@Composable
fun Classic7SegmentDisplay(
    modifier: Modifier = Modifier,
    text: String,
    shearPct: Float = 0F,
    paddingValues: PaddingValues = PaddingValues(4.dp),
    topAreaPercentage: Float = 0.495F,
    thicknessMultiplier: Float = 1F,
    gapSizeMultiplier: Float = 1F,
    activatedColor: Color = Color.Black,
    deactivatedColor: Color = activatedColor.copy(alpha = 0.05F),
    debuggingEnabled: Boolean = false,
    angledSegmentEndsOf: (Int) -> AngledSegmentEnds = ::symmetricEvenAngledSegmentEnds,
    charToActivatedSegments: (Char) -> Int = ::transformCharToActiveSegments
) {
    val density = LocalDensity.current.density
    val layoutDirection = LocalLayoutDirection.current
    val topLeftPadding = Offset(
        x = paddingValues.calculateLeftPadding(layoutDirection).value * density,
        y = paddingValues.calculateTopPadding().value * density
    )
    val bottomRightPadding = Offset(
        x = paddingValues.calculateRightPadding(layoutDirection).value * density,
        y = paddingValues.calculateBottomPadding().value * density
    )
    SingleLineSegmentedDisplay(modifier = modifier, text = text, shearPct = shearPct) { _, char, origin, charWidth, charHeight ->
        drawClassic7SegmentChar(
            activatedSegments = charToActivatedSegments(char),
            origin = origin,
            width = charWidth,
            height = charHeight,
            gapSizeMultiplier = gapSizeMultiplier,
            topLeftPadding = topLeftPadding,
            topAreaPercentage = topAreaPercentage,
            bottomRightPadding = bottomRightPadding,
            thicknessMultiplier = thicknessMultiplier,
            activatedColor = activatedColor,
            deactivatedColor = deactivatedColor,
            debuggingEnabled = debuggingEnabled,
            angledSegmentEndsOf = angledSegmentEndsOf
        )
    }
}

And here's SingleLineSegmentedDisplay, having drawn with the :

@Composable
fun SingleLineSegmentedDisplay(
    modifier: Modifier = Modifier,
    text: String,
    shearPct: Float = 0F,
    renderCharOnCanvas: DrawScope.(idx: Int, char: Char, offset: Offset, charWidth: Float, charHeight: Float) -> Unit
) {
    Canvas(modifier = modifier.fillMaxSize()) {
        if (shearPct == 0F) {
            val charWidth = size.width / text.length
            text.forEachIndexed { idx, char ->
                val offset = Offset(x = idx * charWidth, y = 0F)
                renderCharOnCanvas(idx, char, offset, charWidth, size.height)
            }
        } else {
            // TODO
        }
    }
}

The above will just draw nothing when the shearPct is not 0F. Now we'll create a new preview file that we'll use to test our code that shears the Canvas. Let's call this file Classic7SegmentDisplayShearedPreview.kt and put it in the same directory as Classic7SegmentDisplayPreview.kt:

package com.fsryan.ui.segments

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Preview(widthDp = 800)
@Composable
fun Classic7SegmentAsymmetricHexCharactersSheared() {
    val angledSegmentEnds = createAsymmetricAngled7SegmentEndsFun()
    Classic7SegmentDisplay(
        modifier = Modifier
            .background(Color.LightGray)
            .width(800.dp)
            .height(200.dp),
        text = "89ABCDEF",
        shearPct = 0.5F,
        thicknessMultiplier = 1.25F,
        gapSizeMultiplier = 1F,
        activatedColor = Color.Black,
        angledSegmentEndsOf = angledSegmentEnds
    )
}

Presently this draws nothing, but you should be able to rebuild your project here.

Now lets try to get the shearing to work by updating the SingleLineSegmentedDisplay function:

@Composable
fun SingleLineSegmentedDisplay(
    modifier: Modifier = Modifier,
    text: String,
    shearPct: Float = 0F,
    renderCharOnCanvas: DrawScope.(idx: Int, char: Char, offset: Offset, charWidth: Float, charHeight: Float) -> Unit
) {
    Canvas(modifier = modifier.fillMaxSize()) {
        if (shearPct == 0F) {
            val charWidth = size.width / text.length
            text.forEachIndexed { idx, char ->
                val offset = Offset(x = idx * charWidth, y = 0F)
                renderCharOnCanvas(idx, char, offset, charWidth, size.height)
            }
        } else {
            withTransform(
                transformBlock = {
                    transform(
                        matrix = Matrix(
                            floatArrayOf(
                                1F,         0F, 0F, 0F,
                                shearPct,   1F, 0F, 0F,
                                0F,         0F, 1F, 0F,
                                0F,         0F, 0F, 1F
                            )
                        )
                    )
                }
            ) {
                val charWidth = size.width / text.length
                text.forEachIndexed { idx, char ->
                    val offset = Offset(x = idx * charWidth, y = 0F)
                    renderCharOnCanvas(idx, char, offset, charWidth, size.height)
                }
            }
        }
    }
}

Here, we just draw the same way we did before when the shearPct is 0F. Otherwise, we attempt to shear the canvas by applying a matrix transformation that shears the X values.

That results in the following:

Shearing the wrong way

Surprisingly, this is remarkably close to what we wanted to achieve. The characters are sheared the wrong direction, and the last character is getting cut off, but we can probably tweak some values to make things work.

Note

I recommend spending some time playing around with some of these values (you'll sometimes crash the preview, depending upon what you change). However, I didn't figure out what value to change that would shear the canvas just by randomly checking values. I found this article on 2D shearing matrix transformations, which helped me understand how shearing works in two dimensions. Then I read through the reference doc on Matrix to figure out which one of those floats would control how the X shearing works (called "SkewX").

Fixing the Shearing Direction

So it appears that a positive value for the SkewX position (index 4) of the transformation matrix will shear the characters to the right as the y-position increases. However, we want to shear the characters to the right as the y-position DECREASES. The fix is pretty simple. Just negate the shearPct in the matrix:

withTransform(
    transformBlock = {
        transform(
            matrix = Matrix(
                floatArrayOf(
                    1F,         0F, 0F, 0F,
                    -shearPct,  1F, 0F, 0F,
                    0F,         0F, 1F, 0F,
                    0F,         0F, 0F, 1F
                )
            )
        )
    }
) {
    /* the same code we had before */
}

Now check our preview:

Shearing the right way

That's the correct direction, but now we need to solve how to prevent the characters from getting clipped.

Re-centering and Scaling the Characters

There are actually two issues:

  1. We're not drawing in the center of the canvas
  2. The characters are getting clipped

When the characters get clipped, they're clipped on the left side when shearPct is positive and on the right side when shearPct is negative. The inset function both resizes the contents to fit the Canvas and offsets the drawing area by the amount you specify:

The following approach solves both issues with a single transformation:

withTransform(
    transformBlock = {
        transform(
            matrix = Matrix(
                floatArrayOf(
                    1F,        0F, 0F, 0F,
                    -shearPct, 1F, 0F, 0F,
                    0F,        0F, 1F, 0F,
                    0F,        0F, 0F, 1F
                )
            )
        )
        inset(
            left = if (shearPct < 0) 0F else shearPx,
            top = 0F,
            right = if (shearPct < 0) shearPx else 0F,
            bottom = 0F
        )
    }
) {
    val charWidth = size.width / text.length
    text.forEachIndexed { idx, char ->
        val offset = Offset(x = idx * charWidth, y = 0F)
        renderCharOnCanvas(idx, char, offset, charWidth, size.height)
    }
}

This both re-centers the characters and scales them down such that they fit horizontally within the canvas:

Positive Shear: Re-centered and scaled characters positive shear

Negative Shear: Re-centered and scaled characters negative shear

That's it! This is all we need to shear the characters. Pretty slick, huh?

Reusing the Transformation

Now that we have a reliable way to shear the characters, and because we're applying the transformation to the Canvas, we can shear the Rect7SegmentDisplay as well--just bypassing in the appropriate arguments. Let's start by creating a preview called Rect7SegmentDisplayShearedPreview.kt in the same directory as Rect7SegmentDisplayPreview.kt:

package com.fsryan.ui.segments

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

@Preview(widthDp = 800)
@Composable
fun Rect7SegmentAsymmetricHexCharactersSheared() {
    Rect7SegmentDisplay(
        modifier = Modifier
            .background(Color.LightGray)
            .width(800.dp)
            .height(200.dp),
        text = "89ABCDE8",
        shearPct = 0.5F,
        thicknessMultiplier = 0.75F,
        gapSizeMultiplier = 2F,
        activatedColor = Color.Black
    )
}

Notice that the shearPct argument won't compile because it doesn't exist yet. Lets add it to Rect7SegmentDisplay now:

@Composable
fun Rect7SegmentDisplay(
    modifier: Modifier = Modifier,
    text: String,
    shearPct: Float = 0F,
    paddingValues: PaddingValues = PaddingValues(4.dp),
    topAreaPercentage: Float = 0.495F,
    thicknessMultiplier: Float = 1F,
    gapSizeMultiplier: Float = 1F,
    activatedColor: Color = Color.Black,
    deactivatedColor: Color = activatedColor.copy(alpha = 0.05F),
    charToActivatedSegments: (Char) -> Int = ::transformCharToActiveSegments
) {
    val density = LocalDensity.current.density
    val layoutDirection = LocalLayoutDirection.current
    val topLeftPadding = Offset(
        x = paddingValues.calculateLeftPadding(layoutDirection).value * density,
        y = paddingValues.calculateTopPadding().value * density
    )
    val bottomRightPadding = Offset(
        x = paddingValues.calculateRightPadding(layoutDirection).value * density,
        y = paddingValues.calculateBottomPadding().value * density
    )
    SingleLineSegmentedDisplay(modifier = modifier, text = text, shearPct = shearPct) { _, char, origin, charWidth, charHeight ->
        drawRect7SegmentChar(
            activatedSegments = charToActivatedSegments(char),
            origin = origin,
            width = charWidth,
            height = charHeight,
            gapSizeMultiplier = gapSizeMultiplier,
            topLeftPadding = topLeftPadding,
            topAreaPercentage = topAreaPercentage,
            bottomRightPadding = bottomRightPadding,
            thicknessMultiplier = thicknessMultiplier,
            activatedColor = activatedColor,
            deactivatedColor = deactivatedColor,
            debuggingEnabled = false
        )
    }
}

Now if we look at the preview, we see that we get a perfectly sheared rectangular 7-segment display:

Sheared Rect7SegmentDisplay

The fact that we can now add shearing to any segmented display in this way is quite powerful, and should we add different kinds of segmented displays (such as some of the others on Posy's segmented display sheet), we can shear the characters in those displays as well.

Potential Performance Issue (Optional)

The grizzled code review-veterans among you will probably have noticed something about the SingleLineSegmentedDisplay code that could be a performance issue. The issue is that we're recreating the transformation matrix and the array of floats for every recomposition. There are multiple approaches to solving this, however, the one I'm going to propose is to use a global pool of matrices that gets used to store commonly-used Matrix instances. This global pool is possible because we are not creating any Matrix instance based upon the properties of any specific Canvas.

In the SingleLineSegmentedDisplay.kt file add the following:

// access should be bound to a single thread
private fun getOrCreateShearingMatrix(shearPct: Float): Matrix {
    return shearingMatrixPool[shearPct] ?: Matrix(
        floatArrayOf(
            1F,         0F, 0F, 0F,
            -shearPct,  1F, 0F, 0F,
            0F,         0F, 1F, 0F,
            0F,         0F, 0F, 1F
        )
    ).also {
        shearingMatrixPool[shearPct] = it
        shearingMatrixKeyList.add(shearPct)
        while (shearingMatrixKeyList.size > 10) {
            val toRemove = shearingMatrixKeyList.removeFirst()
            shearingMatrixPool.remove(toRemove)
        }
    }
}
private val shearingMatrixKeyList = mutableListOf<Float>()
private val shearingMatrixPool = mutableMapOf<Float, Matrix>()

The getOrCreateShearingMatrix function will return a Matrix from the pool if it exists for the input shearPct, otherwise it will create a new Matrix and add it to the pool. The pool is limited to 10 Matrix instances, and so the function will remove the oldest Matrix instances (by order of insertion) that exist in the pool if the pool exceeds 10 Matrix instances. This may be a naive choice, but chances are that the consumer of the library has a specific shear percentage in mind for their application.

Then update the SingleLineSegmentedDisplay function to use the getOrCreateShearingMatrix function:

val shearingMatrix = getOrCreateShearingMatrix(shearPct)
val shearPx = abs(shearPct) * size.height
withTransform(
    transformBlock = {
        transform(shearingMatrix)
        inset(
            left = if (shearPct < 0) 0F else shearPx,
            top = 0F,
            right = if (shearPct < 0) shearPx else 0F,
            bottom = 0F
        )
    }
) {
    /* the same code we had before */
}

At this point, check that your previews still look the same. If they do, then you've successfully optimized the code.

Note

This only works because the properties of the Matrix instances do not depend upon the properties of the Canvas. If they did, then it would be better to create some remembered state that stored the Matrix instance.

Adding Shearing to the Demo App

Now that we have shearing working, let's add a slider to the demo app that allows us to change the shearing percentage.

Slider for shearing

Back in our App.kt file in the composeApp module, we just need to add a slider to the ControlAssembly that will enable us to control the shearPct.

@Composable
fun ControlAssembly(
    modifier: Modifier = Modifier,
    shearPctState: MutableFloatState,
    thicknessMultiplierState: MutableFloatState,
    gapSizeMultiplierState: MutableFloatState,
    topAreaPercentageState: MutableFloatState,
    activatedColorState: MutableState<Color>,
    debuggingEnabledState: MutableState<Boolean>,
    angledSegmentEndsFunState: MutableState<Pair<String, (Int) -> AngledSegmentEnds>>
) {
    Column(
        modifier = modifier.fillMaxWidth()
            .verticalScroll(rememberScrollState())
            .padding(all = 16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        /* ... */
        FloatValueSliderControl(
            valueRange = -1F..1F,
            steps = 2000,
            state = shearPctState
        ) { value ->
            "Shear: ${value.roundToDecimals(3) * 100}%"
        }
        /* ... */
    }
}

Applying the shearing to the segmented displays

Then update our ShowDisplays composable function to take the shearPctState:

@Composable
fun ShowDisplays(
    modifier: Modifier,
    gapSizeMultiplier: Float,
    shearPct: Float,
    angledSegmentEnds: (index: Int) -> AngledSegmentEnds,
    topAreaPercentage: Float,
    activatedColor: Color,
    thicknessMultiplier: Float,
    debuggingEnabled: Boolean
) {
    BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
        val width = maxWidth
        Column(
            modifier = Modifier.fillMaxWidth()
                .verticalScroll(rememberScrollState())
                .background(Color.LightGray),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Classic7SegmentDisplay(
                // aspect ratio of each character is 1:2
                modifier = Modifier.fillMaxWidth().height(width / 4),
                text = "01234567",
                shearPct = shearPct,
                topAreaPercentage = topAreaPercentage,
                thicknessMultiplier = thicknessMultiplier,
                gapSizeMultiplier = gapSizeMultiplier,
                activatedColor = activatedColor,
                debuggingEnabled = debuggingEnabled,
                angledSegmentEndsOf = angledSegmentEnds
            )
            Classic7SegmentDisplay(
                // aspect ratio of each character is 1:2
                modifier = Modifier.fillMaxWidth().height(width / 4),
                text = "89ABCDEF",
                shearPct = shearPct,
                topAreaPercentage = topAreaPercentage,
                thicknessMultiplier = thicknessMultiplier,
                gapSizeMultiplier = gapSizeMultiplier,
                activatedColor = activatedColor,
                debuggingEnabled = debuggingEnabled,
                angledSegmentEndsOf = angledSegmentEnds
            )
        }
    }
}

Then update our App composable function to pass the shearPctState to the ControlAssembly and shearPctState.value to ShowDisplays:

@Composable
@Preview
fun App() {
    MaterialTheme {
        Column(
            Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            val debuggingEnabledState = remember { mutableStateOf(false) }
            val angledSegmentEndsFunState = remember {
                mutableStateOf("Classic Symmetric" to createSymmetricAngled7SegmentEndsFun())
            }
            val shearPctState = remember { mutableFloatStateOf(0F) }
            val thicknessMultiplierState = remember { mutableFloatStateOf(1F) }
            val topAreaPercentageState = remember { mutableFloatStateOf(0.495F) }
            val gapSizeMultiplierState = remember { mutableFloatStateOf(1F) }
            val activatedColorState = remember { mutableStateOf(Color.Red) }
            ShowDisplays(
                modifier = Modifier.weight(2F),
                gapSizeMultiplier = gapSizeMultiplierState.value,
                angledSegmentEnds = angledSegmentEndsFunState.value.second,
                topAreaPercentage = topAreaPercentageState.value,
                activatedColor = activatedColorState.value,
                thicknessMultiplier = thicknessMultiplierState.value,
                debuggingEnabled = debuggingEnabledState.value
            )
            ControlAssembly(
                modifier = Modifier.weight(1F),
                shearPctState = shearPctState,
                thicknessMultiplierState = thicknessMultiplierState,
                gapSizeMultiplierState = gapSizeMultiplierState,
                topAreaPercentageState = topAreaPercentageState,
                activatedColorState = activatedColorState,
                debuggingEnabledState = debuggingEnabledState,
                angledSegmentEndsFunState = angledSegmentEndsFunState
            )
        }
    }
}

When you're done, you should be able to run your app on each platform to test that it works.

Here's what the app looks like on desktop:

Sheared Classic7SegmentDisplay on desktop

Conclusion

In this article, we've added the ability to shear the characters of a segmented display by applying a Matrix transformation to a Canvas. This helps us to achieve something that Posy says "is almost always done" in his video on segmented displays. This method of applying the shearing is great because:

  1. It's easy to implement
  2. It allows us to focus on drawing our segmented characters without considering shearing
  3. It's applicable to any segmented display

We now have a pretty useful abstraction for showing 7-segment displays in our compose application. In the next article, we'll depart from improving our 7-segment display to preparing our library for distribution, which will polish our library to the point that we'll be ready to publish to Maven Central.

If you got this far. Thank you! And congratulations! I'd love it if you'd leave your feedback below. If you find any issues with any of the code, then let me know.