Material Motion for Jetpack Compose

Introduce Material Motion library that can be used in Jetpack Compose. 🎭

These days, there are many people who are interested in Jetpack Compose. Most of them seem to be learning through articles or code labs organized in Android Developers, or by making simple samples. The same goes for me.

However, there are some things I can’t know in detail, so I’m going to tackle one of those things. Apps usually divide UI tasks into screen units. Today, I will look at how to implement transition effects in Jetpack Compose.

Many apps use bottom tab UX.

Writing with Jetpack Compose is simple.

Scaffold(bottomBar = { ... }) { innerPadding ->
BottomTabsContents(selectedTab)
}

When I select a tab, the content changes.
Can’t I give a transition animation at that time?

Crossfade ✨

Fortunately, there was a high-level API to provide transition animation. Using Crossfade, I was able to add a simple fade transition.

Scaffold(bottomBar = { ... }) { innerPadding ->
Crossfade(
targetState = selectedTab,
modifier = Modifier.padding(innerPadding)
) { currentTab ->
BottomTabsContents(currentTab)
}
}

Suddenly, I wondered how Crossfade works. 🤔

It’s simple if you look at the picture. (1) Set the part where the content is drawn as Box, and draw the contents of the previous/current tab in the child Box. (2) Then, control the alpha property of each child Box.

Whenever the state of the previous/current tab changes, alpha animation is performed through CrossfadeAnimationItem class.

@Composable
fun <T> Crossfade(
targetState: T,
...
content: @Composable (T) -> Unit
) {
...
if (targetChanged || items.isEmpty()) {
...
items.clear()
keys.mapTo(items) { key ->
CrossfadeAnimationItem(key) {
val alpha by transition.animateFloat(
transitionSpec = { animationSpec }
) { if (it == key) 1f else 0f }
Box(Modifier.graphicsLayer { this.alpha = alpha }) {
content(key)
}
}
}
} else if (transitionState.currentState ==
transitionState.targetState) {
items.removeAll { it.key != transitionState.targetState }
}
Box(modifier) {
items.fastForEach {
key(it.key) {
it.content()
}
}
}
}

Box(modifier) {
Box(Modifier.graphicsLayer { this.alpha = 1f -> 0f }) {
BottomTabsContents(previousTab)
}
Box(Modifier.graphicsLayer { this.alpha = 0f -> 1f }) {
BottomTabsContents(currentTab)
}
}

If convert to a simpler code, it looks like above.

But looking at the applied result, isn’t it a little awkward? 👀
We know the internal structure of Crossfade, so we can try to implement a better transition animation.

Material Motion in MDC ☄️

To create a transition effect, I think we need to have a sense of design. But fortunately, Motion System is already defined in Material Design.

There are 4 transition patterns as shown below.

Shall we implement one of these? 🙋‍♂️
Let’s apply fade through pattern, instead of Crossfade!

Fade Through

Remember CrossfadeAnimationItem?
Except for CrossfadeAnimationItem part, the rest of the code in Crossfade can be used equally for other transition animations.

So, if write below part differently, we can implement FadeThrough.

MaterialAnimationItem(key) {
...
val disappearAnimationSpec = tween(
delayMillis = 0,
durationMillis = outgoingDurationMillis
)
val appearAnimationSpec = tween(
delayMillis = outgoingDurationMillis,
durationMillis = incomingDurationMillis
)
val alpha by transition.animateFloat(
transitionSpec = { ... }
) { if (it == key) 1f else 0f }
val scale by transition.animateFloat(
transitionSpec = { ... }
) { if (it == key) 1f else 0.92f }
Box(Modifier.alpha(alpha = alpha)
.scale(scale = scale)) {
content(key)
}
}

And then, if just change the calling part…

Scaffold(bottomBar = { ... }) { innerPadding ->
MaterialFadeThrough( // from Crossfade
targetState = selectedTab,
modifier = Modifier.padding(innerPadding)
) { currentTab ->
BottomTabsContents(currentTab)
}
}

It is complete! How easy is it? 😉

material-motion-compose 📚

It’s not difficult to implement specs, but cumbersome and time-consuming.
So I created a library. 🙌

I will introduce how to use it briefly.
Use MaterialMotion instead of Crossfade, then you just need to define MotionSpec of the transition animation you want in enter/exit.

MaterialMotion(
targetState = state,
enterMotionSpec = ...,
exitMotionSpec = ...,
pop = false or true
) { newState ->
// composable according to screen
}

MotionSpec available are:

MaterialSharedAxis

  • materialSharedAxis(Axis.X, forward = true or false)
  • materialSharedAxis(Axis.Y, forward = true or false)
  • materialSharedAxis(Axis.Z, forward = true or false)

MaterialFadeThrough / MaterialFade

  • materialFadeThrough()
  • materialFade()

ElevationScale / Hold

  • materialElevationScale(growing = true or false)
  • hold()

Navigation Demo 🧑‍💻

Previously, we implemented a transition animation on tab content changes.
In this time, let’s add a transition animation between screens.

It consists two screens: Library and Album. And on the Library screen, you can change list type to Linear or Grid, or change the order of the list in reverse order. (Refer to the DemoScreen code. 🔖)

Implementation is simple. Wrap the part that needs a transition animation in MaterialMotion. Here, I use hold() to keep showing the previous screen while transition.

@Composable
fun DemoScreen() {
val (state, onStateChanged) = remember { ... }
MaterialMotion(
targetState = state,
enterMotionSpec = translateY(offset, 0f),
exitMotionSpec = hold(),
pop = state == null
) { currentId ->
if (currentId != null) {
AlbumScreen(currentId)
} else {
LibraryScreen(onItemClick = { onStateChanged(it.id) })
}
}
}

The same. Wrap list with MaterialMotion. Here, we use materialFadeThrough() when changing the list type, and materialSharedAxis() when changing the order of the list.

@Composable
fun LibraryScreen(...) {
val (state, onStateChanged) = remember {
mutableStateOf(LibraryState(SortType.A_TO_Z, ListType.Grid))
}
Scaffold(...) {
MaterialMotion(
targetState = state,
motionSpec = materialSharedAxis(Axis.Y, forward = true),
modifier = Modifier.padding(innerPadding)
) { currentDestination ->
LibraryContents(currentDestination, ...)
}
}
}

Save composer states ♻️

Regardless of MaterialMotion, you can see the state is reset when return to the previous screen. The reason is that the previous screen is treated as Initial composition. In this case, we can use SaveableStateHolder to restore the state of the previous screen. ✅

@Composable
fun DemoScreen() {
val saveableStateHolder = rememberSaveableStateHolder()
val (state, onStateChanged) = remember { ... }
MaterialMotion(...) { currentId ->
saveableStateHolder.SaveableStateProvider(currentId.toString()){
if (currentId != null) {
AlbumScreen(currentId)
} else {
LibraryScreen(onItemClick = { onStateChanged(it.id) })
}
}
}
}

See also the official sample: SimpleNavigationWithSaveableStateSample

Next Step…

However, there is still something missing. 😅

Support ContainerTransform

In Material Motion based on the View system, ContainerTransform was implemented using shared elements. So in Jetpack Compose without shared elements, we need to implement ContainerTransform in a different way.

I think, either get @Composable separately or need a little trick. But so far, I haven’t gotten any good idea. 🤯

Support Navigation 🔁

Also, unfortunately, you cannot use material-motion-compose in navigation-compose. It may be possible in the future, but until then, I think you will have to implement the navigation yourself. So currently I’m thinking about whether there is any part of the library that can be provided.

After searching through the issue tracker, it seems that there is a feature request to support Transition on navigation-compose and it may being developed. If you think to need this feature like me, please click ⭐️ on the related issue tracker. It will help that AndroidX team determine priorities. 🙇

So far, we have looked at how to implement Material Motion in Jetpack Compose and how to use the library.

I started implementing it with the desire to apply transition patterns to the new UI Toolkit. After building the library, I feel a little closer to Jetpack Compose. I think, it will be fun if one day I can use Jetpack Compose for real production apps.

There are still parts that are not enough to apply the transition. If interested, try to use the library and create an issue in the Github repository with any questions or requests. 🧑‍💻

Android Developer in South Korea.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store