2024-07-23
Shearing the Segmented Display
Series: Creating A Compose Multiplatform UI Library
Table of Contents
• Introduction
• TLDR
• The Approach
• The Naive Approach
• Transforming the Canvas
• First Transformation Attempt
• Fixing the Shearing Direction
• Re-centering and Scaling the Characters
• Reusing the Transformation
• Potential Performance Issue (Optional)
• Adding Shearing to the Demo App
• Slider for shearing
• Applying the shearing to the segmented displays
• Conclusion
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:
shearPct: Float
argument to the SingleLineSegmentedDisplay
composable function.shearPct
argument is non-zero, apply a matrix transformation to the Canvas
that shears the characters.inset
function to re-center and scale the characters so that they fit within the canvas.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:
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
) {
/* ... */
}
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:
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.
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, 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:
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.
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").
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:
That's the correct direction, but now we need to solve how to prevent the characters from getting clipped.
There are actually two issues:
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:
Negative Shear:
That's it! This is all we need to shear the characters. Pretty slick, huh?
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:
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.
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.
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.
Now that we have shearing working, let's add a slider to the demo app that allows us to change the shearing percentage.
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}%"
}
/* ... */
}
}
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:
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:
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.