A few weeks ago I was building a budget tracker with Compose and needed a marquee text, or in other words a component that would scroll the text if the text didn’t fit inside its bounds.
Jetpack Compose doesn’t provide something like that so after doing some research, I found this component.
It works in most cases but the code is not that easy to understand. Today we’ll be going over the code to understand how it works and try to improve it.
Understanding the marquee
If the text fits inside its bounds then the MarqueeText
works like a normal Text
component, if it doesn’t, then another Text
component is created to give that cool scroll effect.
Understanding the code
We can see the MarqueeText
component has a lot of fields, it’s almost the same fields a normal Text
component has because the marquee is basically wrapping it. The only difference is gradientEdgeColor
, that defines the color on the edges of the component.
fun MarqueeText( | |
text: String, | |
modifier: Modifier = Modifier, | |
gradientEdgeColor: Color = Color.White, | |
color: Color = Color.Unspecified, | |
fontSize: TextUnit = TextUnit.Unspecified, | |
fontStyle: FontStyle? = null, | |
fontWeight: FontWeight? = null, | |
fontFamily: FontFamily? = null, | |
letterSpacing: TextUnit = TextUnit.Unspecified, | |
textDecoration: TextDecoration? = null, | |
textAlign: TextAlign? = null, | |
linHeight: TextUnit = TextUnit.Unspecified, | |
overflow: TextOverflow = TextOverflow.Clip, | |
softWrap: Boolean = true, | |
onTextLayout: (TextLayoutResult) -> Unit = {}, | |
style: TextStyle = LocalTextStyle.current | |
) |
The createText
lambda is used to create the Text
component, it’s defined as a lambda because multiple instance of the Text
component might be created.
val createText = @Composable { localModifier: Modifier -> | |
Text( | |
text = text, | |
textAlign = textAlign, | |
modifier = localModifier, | |
color = color, | |
fontSize = fontSize, | |
fontStyle = fontStyle, | |
fontWeight = fontWeight, | |
fontFamily = fontFamily, | |
letterSpacing = letterSpacing, | |
textDecoration = textDecoration, | |
lineHeight = linHeight, | |
overflow = overflow, | |
softWrap = softWrap, | |
maxLines = 1, | |
onTextLayout = onTextLayout, | |
style = style | |
) | |
} |
Next the offset
variable represents the x offset of the first text component, textLayoutInfoState
is used to store some information about the text and container width.
var offset by remember { mutableStateOf(0) } | |
val textLayoutInfoState = remember { mutableStateOf<TextLayoutInfo?>(null) } |
In the next part we have the code that performs the animation
LaunchedEffect(textLayoutInfoState.value) { | |
// textLayoutInfoState is only calculated if the text overflows | |
// its parent, if it doesn't, there's no need for the animation | |
val textLayoutInfo = textLayoutInfoState.value ?: return@LaunchedEffect | |
if (textLayoutInfo.textWidth <= textLayoutInfo.containerWidth) return@LaunchedEffect | |
// 7500 * 200 / 100 = 15s -- 2x container width | |
// 7500 * 500 / 250 = 15s -- 2x container width | |
// 7500 * 300 / 100 = 22.5s -- 3x container width | |
// 7500 * 110 / 100 = 8.25s -- 1.1x container width | |
// 7500 * 100 / 200 -- 1/2 container width, doesn't happen because of the if statement above | |
// the animation will always take at least 7.5s | |
val duration = 7500 * textLayoutInfo.textWidth / textLayoutInfo.containerWidth | |
// before the animation starts there's this delay, it's also taken | |
// into consideration between cycles | |
val delay = 1000L | |
do { | |
// simple animation that goes from 0 to negative text width, | |
// causes the text to scroll left because it's negative | |
val animation = TargetBasedAnimation( | |
animationSpec = infiniteRepeatable( | |
animation = tween( | |
durationMillis = duration, | |
delayMillis = 1000, | |
easing = LinearEasing | |
), | |
repeatMode = RepeatMode.Restart | |
), | |
typeConverter = Int.VectorConverter, | |
initialValue = 0, | |
targetValue = -textLayoutInfo.textWidth | |
) | |
// suspended function that is invoked when a new frame will be drawn | |
// returns the frame time in nanos | |
val startTime = withFrameNanos { it } | |
// this block will keep executing until the animation ends but given that | |
// the animation is `infiniteRepeatable` it never ends. | |
do { | |
// given that `withFrameNanos` is suspendable and runs only once | |
// per frame we don't need to worry about drawing too many times | |
val playTime = withFrameNanos { it } - startTime | |
offset = (animation.getValueFromNanos(playTime)) | |
} while (!animation.isFinishedFromNanos(playTime)) | |
// this code never runs | |
delay(delay) | |
} while (true) | |
} |
Job Offers
You might be asking “why was withFrameNanos
used here?” There are other ways to achieve a similar behavior but let’s understand why withFrameNanos
in particular. According to its documentation:
Suspends until a new frame is requested, immediately invokes
onFrame with the frame time in nanoseconds in the calling context of frame dispatch, then resumes with the result from onFrame.
frameTimeNanos should be used when calculating animation time deltas from frame to frame as it may be normalized to the target time for the frame, not necessarily a direct, “now” value.
As you can see this function suspends until a new frame is requested, meaning that you’ll get a chance to update your animation before a new frame is drawn.
If you have a 60fps display, that means the screen is refreshed 60 times per second. If you update your animation 80 times per seconds, you’ll be wasting resources because some updates will never be drawn. The same thing applies if you draw 15 times per second, the user will notice a janky animation.
withFrameNanos
solves that problem by suspending until a new frame is requested so you don’t have to worry about the display frame rate.
The next part of the code uses a SubcomposeLayout
to lay out the components. First it starts by sub composing the main text component and measures it as if there were no width constraints. We need to do this to know whether or not the text will fit inside the constraints.
val infiniteWidthConstraints = constraints.copy(maxWidth = Int.MAX_VALUE) | |
var mainText = subcompose(MarqueeLayers.MainText) { | |
createText(Modifier) | |
}.first().measure(infiniteWidthConstraints) |
Then the variables for the gradient and second text component are defined, they’re nullable because they might not be needed if the text fits inside its constraints.
var gradient: Placeable? = null | |
var secondPlaceableWithOffset: Pair<Placeable, Int>? = null |
If the text fits inside its constraints then mainText
is updated to fill max width. It also sets textLayoutInfoState
to null so the animation doesn’t run.
if (mainText.width <= constraints.maxWidth) { | |
mainText = subcompose(MarqueeLayers.SecondaryText) { | |
createText(Modifier.fillMaxWidth()) | |
}.first().measure(constraints) | |
textLayoutInfoState.value = null | |
} |
If the text doesn’t fit inside its constraints then the gradient and second text components are defined.
if (mainText.width <= constraints.maxWidth) { | |
.. | |
} else { | |
// horizontal spacing between the main and second text components | |
val spacing = constraints.maxWidth * 2 / 3 | |
textLayoutInfoState.value = TextLayoutInfo( | |
textWidth = mainText.width + spacing, | |
containerWidth = constraints.maxWidth | |
) | |
// this `offset` variable is the same as the one used by the main component | |
val secondTextOffset = mainText.width + offset + spacing | |
val secondTextSpace = constraints.maxWidth - secondTextOffset | |
// `secondTextSpace` is only positive if the second text component is | |
// inside bounds | |
// Notice this returns a Pair - Component to Offset | |
if (secondTextSpace > 0) { | |
secondPlaceableWithOffset = subcompose(MarqueeLayers.SecondaryText) { | |
createText(Modifier) | |
}.first().measure(infiniteWidthConstraints) to secondTextOffset | |
} | |
gradient = subcompose(MarqueeLayers.EdgesGradient) { | |
Row { | |
// GradientEdge code can be found below | |
GradientEdge(startColor = gradientEdgeColor, endColor = Color.Transparent) | |
Spacer(modifier = Modifier.weight(1f)) // space gradients apart | |
GradientEdge(startColor = Color.Transparent, endColor = gradientEdgeColor) | |
} | |
}.first() | |
// make grandient height the same as the main text height | |
.measure(constraints = constraints.copy(maxHeight = mainText.height)) | |
} | |
@Composable | |
private fun GradientEdge( | |
startColor: Color, | |
endColor: Color | |
) { | |
Box( | |
modifier = Modifier | |
.width(10.dp) | |
.fillMaxHeight() | |
.background( | |
brush = Brush.horizontalGradient( | |
0f to startColor, | |
1f to endColor | |
) | |
) | |
) | |
} |
Finally the components are laid out
layout( | |
width = constraints.maxWidth, | |
height = mainText.height | |
) { | |
mainText.place(offset, 0) | |
secondPlaceableWithOffset?.let { | |
it.first.place(it.second, 0) | |
} | |
gradient?.place(0, 0) | |
} |
Initially the marquee component looks complex but by analyzing it bit by bit we can see that it’s not that complex.
A few improvements
While doing this analysis I found some things that could be improved in the code. I’ll only mention some of the changes I made, if you want to check out all of them, you can find a link to the source code at the end of the article.
Inside the LaunchedEffect
block there are two while loops that look suspicious, the first one is while(true)
, it can be removed without affecting anything. The second one is while (!animation.isFinishedFromNanos(playTime))
, as we know the animation never finishes so I changed it to while (isActive)
, this returns true while the Coroutine is active.
I also removed the delay, I don’t think that’s needed for a marquee.
We can also change the MarqueeText
component to follow the slot pattern. I created a new component called Marquee
and changed its signature to accept any kind of content.
@Composable | |
fun Marquee( | |
modifier: Modifier = Modifier, | |
gradientEdgeColor: Color = Color.White, | |
content: @Composable (Modifier) -> Unit | |
) |
That way you can pass the Marquee
any content you want, even if it’s not text.
Marquee { | |
Row { | |
Text(text = "This is my 1st text") | |
Icon(Icons.Default.KeyboardArrowRight, contentDescription = "") | |
Text(text = "This is my 2nd text") | |
Box(modifier = Modifier.size(16.dp).background(Color.Black)) | |
Text(text = "This is my 3rd text") | |
} | |
} |
This is the final result
The way the gradient was implemented for this component doesn’t look good in all cases but that’s not something I wanted to dive into. Also I don’t know how the marquee should work in RTL languages so that might require changing some code.
If you want to check out the budget tracker I built using Jetpack Compose, you can find the article here.
You can also find the source code for the MarqueeText
and Marquee
component here.
See you in my next article 👋
Cover photo by Sven Brandsma on Unsplash
Originally published at victorbrandalise.com