Introduction to Jetpack Compose
Jetpack Compose is Google’s modern UI toolkit for Android, designed to simplify and accelerate UI development. It was introduced in May 2019 at Google I/O and officially released as stable in July 2021. Compose made a fundamental shift in Android UI development which implied a move away from the traditional XML-based approach to a fully declarative framework. Those practices of declarative UI were already established or were being introduced in the same time in other “frontends” – React in the web world, Swift UI on iOS and notable mentions like Flutter. There’s no doubt that declarative UIs, namely Compose is the future. That means, getting comfortable with it is of essence, if we want to develop forward-looking maintainable apps.
Three very important characteristic of Compose:
- Boilerplate Code – deals nicely with state management and makes findViewById calls, ViewBindings obsolete.
- Complex State Management – the first point brings us to the second, if something looks elegant, it usually it’s complex/hard to master
- Fragmentation & Theming Issues – Supporting different screen sizes, themes, and dark mode required additional effort.
How about animations? Yes, it’s true, there are a lot of praises for Jetpack Compose approach to creating beautiful and dynamic UI with built-in animation support. Whether we’re animating a button, transitioning between screens, or adding motion effects, Compose provides a declarative and flexible API. This simplifies animation handling. Yet, it’s a paradigm shift from the previously established practices and it has a learning curve. It takes some trial and error.
A very basic animations distinction from developer.android.com documentation:
Animate appearance and disappearance / Animate content size changes / Animate between different composables
The approach to creating chained animation
In this blog post I’m going to go through creating an animation component and utilizing in different manners, just to showcase the customization capabilities and modularity of Compose code.
The task is to create a chained animation (component) that can be used in Composables – each link of the chain can have its own animation. For this I’m going to use the basics – AnimatedVisibility and EnterTransition and ExitTransition, SDK’s defined methods, that will be modifiable parameters in the method.
Demo application to showcase the implementation
First, we define the ChainedAnimationComponent (full repo here):
const val DEFAULT_ANIMATION_DURATION = 1000
/**
* A composable function that provides a chained animation effect.
* The default effect mimics teleprompter-like animations.
*
* @param visible A Boolean value that determines whether the content is visible.
* @param onAnimationEnd A lambda function that is called when the animation ends. Default is an empty lambda.
* @param duration The duration of the enter animation in milliseconds. Default is [DEFAULT_ANIMATION_DURATION].
* @param enter The enter transition animation. Default is [defaultStartScreenInAnimation].
* @param exit The exit transition animation. Default is [defaultStartScreenOutAnimation].
* @param triggerNextAnimation A lambda function that triggers the next animation after a delay. Default is an empty lambda.
* @param content A composable function that defines the content to be animated.
*/
@SuppressLint("CoroutineCreationDuringComposition")
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun ChainedAnimationComponent(
visible: Boolean,
onAnimationEnd: () -> Unit = {},
duration: Int = DEFAULT_ANIMATION_DURATION,
enter: EnterTransition = defaultStartScreenInAnimation(duration),
exit: ExitTransition = defaultStartScreenOutAnimation(),
triggerNextAnimation: () -> Unit = {},
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
val coroutineScope = rememberCoroutineScope()
var hasAnimationEnded by remember { mutableStateOf(false) }
AnimatedVisibility(visible = visible, enter = enter, exit = exit) {
content()
if (hasAnimationEnded) {
coroutineScope.launch {
delay(300.milliseconds)
triggerNextAnimation()
}
}
with(transition) {
if (currentState == targetState) {
onAnimationEnd()
hasAnimationEnded = true
}
}
}
}
In the component, the default animations will create “teleprompter like effect”:
private fun defaultStartScreenInAnimation(duration: Int) =
slideInVertically(animationSpec = tween(duration), initialOffsetY = {
it / 2
}) + fadeIn()
private fun defaultStartScreenOutAnimation() = fadeOut()
Next step is to create a data class object that will hold the visibility states of the links in the chain. In respective methods when you change values – that’s how you animate the screen.
I define the following data class:
data class Demo1ScreenState(
val isTitle1Visible: Boolean = false,
val isSubTitle1Visible: Boolean = false,
val isP1Visible: Boolean = false,
val isP2Visible: Boolean = false,
val isP3Visible: Boolean = false
)
Now all that is left is make the chain and trigger the animation. The trigger is done in this manner:
@Composable
fun DemoScreen1() {
var demo1ScreenState by remember { mutableStateOf(Demo1ScreenState()) }
LaunchedEffect(!demo1ScreenState.isTitle1Visible) {
delay(1000.milliseconds)
demo1ScreenState = demo1ScreenState.copy(isTitle1Visible = true)
}
}
Now add the components:
@Composable
fun DemoScreen1() {
var demo1ScreenState by remember { mutableStateOf(Demo1ScreenState()) }
Box(
Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
) {
Column(
modifier = Modifier
.padding(16.dp)
.align(Alignment.TopCenter)
.verticalScroll(rememberScrollState())
.fillMaxSize(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(72.dp))
ChainedAnimationComponent(visible = demo1ScreenState.isTitle1Visible,
triggerNextAnimation = {
demo1ScreenState = demo1ScreenState.copy(isSubTitle1Visible = true)
}) {
Text(
text = R.string.demo_1_title.getString(),
style = MaterialTheme.typography.titleLarge
)
}
Spacer(modifier = Modifier.height(16.dp))
ChainedAnimationComponent(visible = demo1ScreenState.isSubTitle1Visible,
triggerNextAnimation = {
demo1ScreenState = demo1ScreenState.copy(isP1Visible = true)
}) {
Text(
text = R.string.demo_1_subtitle.getString(),
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(48.dp))
ChainedAnimationComponent(visible = demo1ScreenState.isP1Visible,
triggerNextAnimation = {
demo1ScreenState = demo1ScreenState.copy(isP2Visible = true)
}) {
Text(
text = R.string.demo_1_p1.getString(),
style = MaterialTheme.typography.bodyLarge
)
}
...
}
}
LaunchedEffect(!demo1ScreenState.isTitle1Visible) {
delay(1000.milliseconds)
demo1ScreenState = demo1ScreenState.copy(isTitle1Visible = true)
}
}
The final result, when “Demo 1” button is pressed:

Another approach is to make the elements disappear instead of stacking them and that’s achieved by changing the respective value in onAnimationEnd animation (see the full code here):
ChainedAnimationComponent(visible = demo2ScreenState.isText1Visible,
duration = ANIMATION_DURATION,
onAnimationEnd = {
demo2ScreenState = demo2ScreenState.copy(isText1Visible = false)
},
triggerNextAnimation = {
demo2ScreenState = demo2ScreenState.copy(isText2Visible = true)
}) {
Text(
text = R.string.demo_2_text1.getString(),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
}
ChainedAnimationComponent(visible = demo2ScreenState.isText2Visible,
duration = ANIMATION_DURATION,
onAnimationEnd = {
demo2ScreenState = demo2ScreenState.copy(
isText2Visible = false
)
},
triggerNextAnimation = {
demo2ScreenState = demo2ScreenState.copy(
isText3Visible = true
)
}) {
Text(
text = R.string.demo_2_text2.getString(),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
}
The final result, when “Demo 2” button is pressed:

Before you go
I would argue that is pretty simple to create complex and appealing animations with compose, this being one example.
For more, rout to Rebecca’s (I had the pleasure to attend her talk on Droidcon NYC in 2023) guide to Animations in Compose. If you found this subject interesting, also look at my repo on how to create and utilize HeaderToStickyText component.