Creating a Split Flap Display in Compose
What is a Split Flap
A split-flap display, or sometimes simply a flap display, is a digital electromechanical display device that presents changeable alphanumeric text. Usually each character position has a collection of flaps on which the characters or graphics are painted or silkscreened. These flaps are precisely rotated to show the desired character or graphic.
How to simulate it using Compose
Obviously create a bunch of flaps is a no-go, instead we can split a character in 2 sections bottom and top.
For that we can start with a piece that we can split later.
@Composable
private fun SplitFlapPiece(
displayText: Char,
color: Color,
modifier: Modifier
) {
Column(
modifier = Modifier
.fillMaxHeight()
.then(modifier)
) {
Box(
Modifier
.weight(1f)
.fillMaxWidth()
) {}
Text(
displayText.toString(),
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
fontSize = 190.sp
)
Box(
Modifier
.weight(1f)
.fillMaxWidth()
) {}
}
}
Basically is a Text inside a Column with a couple of boxes to center it vertically while the size is maintained.
That can be split in two parts top and bottom.
@Composable
fun SplitFlapComposable(
character: Char,
color: Color,
modifier: Modifier = Modifier
) {
Box(
modifier
.background(Color(0xFF222222))
) {
SplitFlapPiece(displayText = 'A', color = Color.White, modifier = Modifier
.graphicsLayer {
clip = true
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2 - 2, width = size.width)
.toRect()
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
SplitFlapPiece(displayText = 'A', color = Color.White, modifier = Modifier
.graphicsLayer {
clip = true
rotationX = -180f
rotationY = 180f
rotationZ = 180f
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2, width = size.width)
.toRect()
.translate(0f, size.height / 2)
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
}
}
Time to do the animation itself. Basically create a third piece that will rotate on the X plane, covering the top piece until it overlaps the bottom one. While we are rotating it behind should be the next character. And at the moment that rotate more than 90º it should show the next character.
@Composable
fun SplitFlapComposable(
character: Char,
color: Color,
modifier: Modifier = Modifier
) {
var pastChar by remember { mutableStateOf(' ') }
var finalChar by remember { mutableStateOf(character) }
var indicatorRotation by remember { mutableStateOf(0f) }
val animationDuration = 200
val animationSpec =
tween<Float>(animationDuration, easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.8f))
LaunchedEffect(character) {
finalChar = character
animate(
initialValue = 0f,
targetValue = -180f,
animationSpec = animationSpec
) { value, _ ->
indicatorRotation = value
if (value <= -175f) {
pastChar = character
}
}
}
}
Box(
modifier
.background(Color(0xFF222222))
) {
SplitFlapPiece(displayText = finalChar, color = color, modifier = Modifier
.graphicsLayer {
clip = true
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2 - 2, width = size.width)
.toRect()
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
SplitFlapPiece(displayText = pastChar, color = color, modifier = Modifier
.graphicsLayer {
clip = true
rotationX = -180f
rotationY = 180f
rotationZ = 180f
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2, width = size.width)
.toRect()
.translate(0f, size.height / 2)
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
if (indicatorRotation >= -90f) {
SplitFlapPiece(displayText = pastChar, color = color, modifier = Modifier
.graphicsLayer {
rotationX = indicatorRotation
clip = true
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2 - 2, width = size.width)
.toRect()
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
} else {
SplitFlapPiece(displayText = finalChar, color = color, modifier = Modifier
.graphicsLayer {
clip = true
rotationX = indicatorRotation
rotationY = 180f
rotationZ = 180f
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2, width = size.width)
.toRect()
.translate(0f, size.height / 2)
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
}
}
}
Let’s see the code, there are 3 states
var pastChar by remember { mutableStateOf(' ') }
var finalChar by remember { mutableStateOf(character) }
var indicatorRotation by remember { mutableStateOf(0f) }
The ulterior / past character, the current character and a state to save the current rotation of the central piece
To show a different character when the rotation is higher than 90º basically we render another piece with the final character.
As we want to animate it only when a new character is provided we use it as the key to fire the LaunchedEffect
LaunchedEffect(character) {
finalChar = character
animate(
initialValue = 0f,
targetValue = -180f,
animationSpec = animationSpec
) { value, _ ->
indicatorRotation = value
if (value <= -175f) {
pastChar = character
}
}
}
}
Adding details and create a complete display
We have the split flap, an animation that somehow mimics the real behavior. But it isn’t complete. Let’s add some details.
First this flat display lacks their distinctive marks on both sides where each flap is attached. This is easy to add. Just modifying the SplitFlapPiece Composable.
@Composable
private fun SplitFlapPiece(displayText: Char, color: Color, modifier: Modifier) {
val boltGradient = Brush.verticalGradient(
colors = listOf(Color.DarkGray, Color.Black,Color.DarkGray, Color.Black,Color.DarkGray, Color.Black,Color.DarkGray, Color.Black,Color.DarkGray, Color.Black),
tileMode = TileMode.Repeated,
)
BoxWithConstraints {
Column(
modifier = Modifier
.fillMaxHeight()
.then(modifier)
) {
Box(
Modifier
.weight(1f)
.fillMaxWidth()
) {}
Text(
displayText.toString(),
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
fontSize = 38.sp
)
Box(
Modifier
.weight(1f)
.fillMaxWidth()
) {}
}
Box(
Modifier
.size(maxWidth / 15, maxHeight / 5)
.background(Color(0xFF222222))
.align(Alignment.CenterStart))
Box(
Modifier
.size(maxWidth / 15 - 1.dp, maxHeight / 5 - 2.dp)
.offset(x = 0.5.dp)
.background(boltGradient)
.align(Alignment.CenterStart))
Box(
Modifier
.size(maxWidth / 15, maxHeight / 5)
.background(Color(0xFF222222))
.align(Alignment.CenterEnd))
Box(
Modifier
.size(maxWidth / 15 - 1.dp, maxHeight / 5 - 2.dp)
.offset(x = -0.5.dp)
.background(boltGradient)
.align(Alignment.CenterEnd))
}
}
That looks like this.
A small change but add more detail to the view.
Another detail is that a split flap iterates through all the characters until it reaches the desired one.
That can be done creating a list of characters and iterating them chaining animations.
So the final code is:
private const val characters = " -1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ#$€:,."
@Composable
fun SplitFlapComposable(
character: Char,
color: Color,
modifier: Modifier = Modifier
) {
var pastChar by remember { mutableStateOf(characters.first()) }
var finalChar by remember { mutableStateOf(character) }
var indicatorRotation by remember { mutableStateOf(0f) }
val animationDuration = 200
val animationSpec =
tween<Float>(animationDuration, easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.8f))
LaunchedEffect(character) {
val index = characters.indexOf(character)
if (index >= 0) {
(0..index).forEach {
finalChar = characters[it]
animate(
initialValue = 0f,
targetValue = -180f,
animationSpec = animationSpec
) { value, _ ->
indicatorRotation = value
if (value <= -175f) {
pastChar = characters[it]
}
}
}
}
}
Box(
modifier
.background(Color(0xFF222222))
) {
SplitFlapPiece(displayText = finalChar, color = color, modifier = Modifier
.graphicsLayer {
clip = true
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2 - 2, width = size.width)
.toRect()
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
SplitFlapPiece(displayText = pastChar, color = color, modifier = Modifier
.graphicsLayer {
clip = true
rotationX = -180f
rotationY = 180f
rotationZ = 180f
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2, width = size.width)
.toRect()
.translate(0f, size.height / 2)
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
if (indicatorRotation >= -90f) {
SplitFlapPiece(displayText = pastChar, color = color, modifier = Modifier
.graphicsLayer {
rotationX = indicatorRotation
clip = true
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2 - 2, width = size.width)
.toRect()
)
}
}
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
} else {
SplitFlapPiece(displayText = finalChar, color = color, modifier = Modifier
.graphicsLayer {
clip = true
rotationX = indicatorRotation
rotationY = 180f
rotationZ = 180f
shape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Rectangle(
size
.copy(height = size.height / 2, width = size.width)
.toRect()
.translate(0f, size.height / 2)
)
}
}
shadowElevation = 1f
}
.background(Color.DarkGray)
.border(BorderStroke(Dp.Hairline, Color(0xFF222222)))
)
}
}
}
@Composable
private fun SplitFlapPiece(displayText: Char, color: Color, modifier: Modifier) {
val boltGradient = Brush.verticalGradient(
colors = listOf(Color.DarkGray, Color.Black,Color.DarkGray, Color.Black,Color.DarkGray, Color.Black,Color.DarkGray, Color.Black,Color.DarkGray, Color.Black),
tileMode = TileMode.Repeated,
)
BoxWithConstraints {
Column(
modifier = Modifier
.fillMaxHeight()
.then(modifier)
) {
Box(
Modifier
.weight(1f)
.fillMaxWidth()
) {}
Text(
displayText.toString(),
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
fontSize = 19.sp
)
Box(
Modifier
.weight(1f)
.fillMaxWidth()
) {}
}
Box(
Modifier
.size(maxWidth / 15, maxHeight / 5)
.background(Color(0xFF222222))
.align(Alignment.CenterStart))
Box(
Modifier
.size(maxWidth / 15 - 1.dp, maxHeight / 5 - 2.dp)
.offset(x = 0.5.dp)
.background(boltGradient)
.align(Alignment.CenterStart))
Box(
Modifier
.size(maxWidth / 15, maxHeight / 5)
.background(Color(0xFF222222))
.align(Alignment.CenterEnd))
Box(
Modifier
.size(maxWidth / 15 - 1.dp, maxHeight / 5 - 2.dp)
.offset(x = -0.5.dp)
.background(boltGradient)
.align(Alignment.CenterEnd))
}
}
@Preview
@Composable()
fun TrainBoard() {
val text = "01 PARIS-BERLIN"
Column(modifier = Modifier
.fillMaxSize()
.background(Color.DarkGray)
.padding(12.dp)) {
Row{
Text("VIA", color = Color.White, fontSize = 10.sp)
Spacer(modifier = Modifier.width(56.dp))
Text("Route", color = Color.White, fontSize = 10.sp)
}
Row() {
text.forEach {
SplitFlapComposable(
character = it,
color = Color.White,
modifier = Modifier
.size(24.dp, 32.dp)
)
}
}
}
}
This is the output of the Preview. Pretty neat, but only works for an specific screen width.
Final notes
For me it is fun to play along with Compose. Creating beautiful animations is so fast compared with legacy XML. Is a shame that MotionLayout never worked as intended
Warning. Beware. Although part of this Composable is production ready, it needs a few changes to be added into an app.
- Create anonymous shapes affect the performance
- The gradient to simulate a dented gear isn’t the best approach.
- Chained animations are a good recipe for disaster.
So just be careful in which code you just copy and reuse.