Blog Infos
Author
Published
Topics
Published

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
)
view raw MarqueeText.kt hosted with ❤ by GitHub

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
)
}
view raw CreateText.kt hosted with ❤ by GitHub

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) }
view raw Marquee3.kt hosted with ❤ by GitHub

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)
}
view raw Marquee4.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

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)
view raw Marquee5.kt hosted with ❤ by GitHub

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
view raw Marquee6.kt hosted with ❤ by GitHub

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
}
view raw Marquee7.kt hosted with ❤ by GitHub

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
)
)
)
}
view raw Marquee8.kt hosted with ❤ by GitHub

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)
}
view raw Marquee9.kt hosted with ❤ by GitHub

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
)
view raw Marquee.kt hosted with ❤ by GitHub

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")
}
}
view raw MarqueeUsage.kt hosted with ❤ by GitHub

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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu