In today’s article we will learn how to create a ticker board, reminiscent of the boards that used to adorn train stations and airports, and that can still be seen in some of those today. The GIF below shows what we will end up implementing today:
Let’s get started.
The issue with text
Working with text can be a bit challenging because font families have to account for the different sizes (width and height) or each letter. We can simplify this a bit by using a monospaced font, which will ensure all letters have the same width. but height can be somewhat of a challenge. To illustrate this, I’ll draw a simple letter in a
Box container, and I’ll draw a line across the middle of the
Box, the code is shown here:
This is very simple, just a
Box containing a 1 letter
Text with monospaced font. We use the
drawBehind modifier extension to draw a red line in the middle of the box. If we run, we get this result
We can clearly see that there is significantly more body of the letter below the middle line than above it. If we were to use this letter as is for our ticker, the Ticker would be misaligned — when the top half folds over the bottom it would be offset. We’ll need to fix that.
Ascend, Descend, Baseline
Before we can fix this, we need to understand the different attributes of text, a line of text has these properties: top, ascend, baseline, descend and bottom. The image below illustrates them (excuse my poor artistic skills, I’m an engineer, not a designer):
To make sure that the letter is centered in the box, with as much body above the middle line as below it, we will need to get these text properties and then shift the letter in order to center it.
The text properties can be obtained from the
Text composable, we can use the
onTextLayout parameter to pass a lambda that will be called when the text is ready to be laid out, and here we can calculate these properties. This is how we can do this:
- In the
onTextLayoutlambda, we get the
layoutInputobject from the
- We calculate the size of the font, in pixels.
- The baseline can be obtained directly from the
- Same for the
topproperty, we get the top of the 1st (and unique) line of text.
- And we do the same for the
ascendis defined as the bottom property minus the font size.
- And the
descendis a bit more complex, it’s based on all other previous properties.
Now that we have this, we have to figure out how to offset the letter so that it becomes centered in its box. If we look at the drawing of the different properties above, we can determine that what we need to do is ensure that the distance from the
top to the
ascend is the same as from the
baseline to the
bottom, so the amount we need to shift the letter by can be calculated as shown in this formula
val delta = ((bottom - baseline) - (ascent - top)) / 2
Once we have this, we can apply an offset to the letter, using the
Offset Modifier. Let’s see the code:
- We define a set of observable properties to hold the text attributes.
- We calculate the delta based on the formula we deduced above. We make this a
derivedStateOf, so that it only triggers a recomposition if the base text attributes this is based on change.
- We apply an offset to the text, to center it in its container.
If we display this, next to the original text, we can see the difference:
Now that we have our centered letter, we can proceed to the next step on our Ticker.
A tale of two halves
Now we have the basic block for our Ticker, a centered text composable. Next we have build the 2 halves of the Ticker, the top half that folds down, and the bottom half. Because we need to fold only half of the letter, and we want to have only the other half showing below it, we need to find a way to split our letter into 2 distinct parts, so that we can manipulate them individually. There are probably multiple ways to tackle this problem, but the solution I’ve come up with involves using a
Layout composable. This is a basic building block that allows us to measure the content and then place it within the constraints of the
Layout composable. To render only the top half of the letter, we will measure it as we would always do, but when it comes to laying it out, we will display only half and apply a truncation — we will do this by halving the height we specify as required to draw the content. Let’s see the code, it will be easier to explain that way:
- We use the
Layoutcomposable as the root element.
- We use the
clipToBoundsto ensure the child of this
Layoutdoes not spill outside the parent bounds.
- We measure the child with the incoming constraints.
- The allowed height will be 1/2 the required height of the child.
- We now specify the layout dimensions, using the required width but only half the required height.
- We lay the child at the top left coordinate.
If we now wrap our
CenteredTextView in this container, we get this result:
And we can do the same with the bottom half, but for the bottom half we will need to offset the child by its height, so that only the bottom shows. This is how we would do it:
- The only difference here is the offset we apply when laying out the content, we shift it up so that we only display the bottom half.
And this is the result:
Ok, so now we have the basic pieces we need to build our Ticker. Let’s move to the next step.
Laying out the pieces
The 3D model
The Ticker needs to be built from 3 pieces:
- A background letter, which will contain the letter we are animating to (if we are currently showing the letter A, the background will be the letter B)
- A top half showing the current letter, if it is animating between 0° and 90°, or the next letter if it’s animating between 90° and 180°
- A bottom half showing the bottom of the current letter
The tricky part is the top half, which needs to bend over the bottom half, and change what it displays once it crosses the half mark threshold of the animation.
It may be easier to visualize this if we see the 3 elements overlaid next to a 3D model of where they sit relative to each other:
On the left side we have the front view of the ticker, while on the right side we can see the 3 elements, the background with a B, the top half with a partially folded 1/2 top A or 1/2 top B, and the 1/2 bottom A.
So we want to
- display a full size background with the next letter
- on the top half, display a top half current letter or a bottom half next letter
- on the bottom half, display a bottom half of the current letter
We can achieve this with this incomplete Ticker composable:
- We get hold of the current and next letters.
- We want to overlay the 3 elements of the Ticker, so we use a
Boxto achieve that.
- This is the background letter, static — always the next letter.
- Next we want to display the 2 halves, the animating top half and the static bottom half, in a column.
- The animated half is actually 2 pieces, it’s a top half of the current letter if we are in the 1/2 half of the animation, or a bottom half of the next letter if we’re on the 2nd half of the animation, which are mutually exclusive, so we use a
- The top half will fold over the bottom half and needs to remain on top (on the Z axis), so we use the
zIndexto ensure it renders above the bottom half.
- Now we render the top half of the letter, if we’re in the 1/2 half of the animation, using the current letter or the bottom half if we’re in the 2nd part of the animation, using the next letter in the alphabet. Note that, because we will be flipping the content, we need to apply a corresponding rotation to restore the content to the correct orientation.
- And this is the static bottom half, showing the current letter.
We will define an Alphabet of supported letters, as we have a finite set of letters we can render in the Ticker, our alphabet will consist of these letters
The last letter on this alphabet is a “catch-all”, if we receive a letter outside the alphabet we will default to displaying this dot.
Now we have to decide how to animate the Ticker. To do so, we will use an
animatable object from Jetpack Compose, which allows us to animate a
float between 2 values. The values that this
animatable will represent are the index in our alphabet of the letter to display in the Ticker. So, for our alphabet, the letter A will be index 0, letter B will be index 1 and so on. Float values between
1f will represent the folding state of the current letter, as we move from A to B. In other words, a value of
0f means the letter A is fully displayed,
0.5f means the letter A is folded at 90° (perpendicular to the viewer), and a value of
1f means we are now on the letter B.
We want to kick the animation every time a letter changes, so we will use a
LaunchedEffect with the key being the letter to display. To determine how to run the animation, we need to calculate these values:
- What value is currently being displayed (the letter index)
- What index we want to animate to
- How long to run the animation for
The value currently displayed is easy, it’s just the current value of the animation, truncated to an Int.
The value we want to animate to is not just the index of the letter we want to display — if we are on letter Z (index 25) and we want to go to letter C (index 2), we can’t animate from 25 to 2 because this would run our animation backwards (the cards will flip upwards instead of downwards), so if the target index is lower than the current index, we need to add the size of the alphabet, so that we loop over all the characters. In other words, to go from Z to C we would actually go from Z to • and then start over at the beginning, from A to C.
The animation duration will depend on how many letters we need to tick over. We can’t use a fixed animation duration, otherwise going from A to B (1 tick) would take as long as going from A to Z (25 ticks), which would look odd. So we will define a base unit for a single tick, and then the animation duration will be the number of ticks to execute, times the base tick unit of time.
The careful reader may notice that, as we keep ticking over letters, the indices will keep moving upwards as we need to add the size of the Alphabet for an index that is lower than the current index so, to avoid precision issues with large floats we will reset the indices once the animation has completed.
Let’s look at the code now, it will all become much clearer. First, we will abstract the Alphabet logic to a class:
- This is our alphabet, the list of supported characters.
- We have a getter method, given an index, we return the letter at that index. We accept indices larger than the alphabet size, we loop over for indices exceeding the size.
- And this is the counterpart to the previous method, given a letter, it returns its index (if found), or defaults to the • letter.
Now that we have our alphabet, we can create the Ticker animation:
Let’s have a look at the changes:
- We define an
animatableto control the animation, this is a low level animation API that gives us full control over the animation.
- Whenever the letter changes we will kick off the animation, so we use a
LaunchedEffectwith the key being the letter we want to display.
- The first thing we do is get the index of the letter currently being displayed.
- Then we calculate the index we want to animate to, using the alphabet class we described earlier.
- Here is where we check if the target index is lower than the current index, in which case we add as many times the alphabet size as necessary to get the target index to be larger than the current index (so that we always animate forwards).
- Now that we have our parameters, we can trigger the animation. We animate towards the index of the letter to display, with a duration determined by how many ticks we need to execute in order to reach the target letter.
- If the animation is successful, we reset it by snapping to the current letter index (we effectively remove all the additional alphabet sizes that we had to add in step 5).
- Here we get the fractional part of the animation, which we will use to determine the rotation animation of the top letter half.
- The rotation is in degrees, and to rotate towards the user, we need to rotate from 0° to -180°.
- Here we use our alphabet class to get the current and next letters, to drive the 3 components of our Ticker.
- And the last piece is to do the actual rotation of the top half, using the angle from step 9. Worth of note is that we have to use a
TransformOriginto set the pivot point for the rotation, we want the rotation “hinge” to be at the bottom, so we use a
1fwhich corresponds to the height of the object rotating.
And this is pretty much it, our Ticker is complete. There is some additional clean-up we can do, like extracting the state to a state holder class, but that’s just some refactoring that does not change the principle of the Ticker. The refactored code is linked at the end of this article.
Rows and Boards
Now we have a single Ticker, but we may want to display more than a single letter, so we can build a Row of tickers, and a Board of tickers. Let’s start with the row:
This is the beauty of Jetpack Compose, once you have a basic block (the Ticker in our case), it’s very easy to compose it to build larger, more complex pieces with just a few lines of code,
- Our row accepts a String, as we will display multiple letters at once.
- We also accept the number of Ticker cells to display.
- We use a
Rowcomposable to render the Tickers.
- And here we simply loop over the number of Tickers to display, passing to each the letter in the input text corresponding to the Ticker index.
And just like we built a Ticker row from a set of Tickers, we can build a Ticker board from a set of Ticker rows:
- Our board accepts a text to display.
- We take the number of Ticker columns to render.
- As well as the number of Tickers per row.
- As we will be chunking the text to pass to each row, we ensure it’s long enough by padding it at the end.
- A board is just a column of Ticker Rows.
- We loop over the number of rows, and use the previous
TickerRowto render each row of the board, passing in the corresponding substring for the row (note that the substrings are longer than strictly necessary, there is no need to conform them to the number of columns).
Here we can see the board in action:
The full code, with some clean-up and refactor, is available on this gist.