A few days ago, I saw a post on Reddit that got me thinking: “How hard would it be to add a theme switcher reveal effect in a Jetpack Compose app?”
I first read about the very ingenious solution that was implemented by the Telegram folks. I was amazed at the amount of effort put into such a “simple” feature and it triggered me even more into trying to see how complex that would be with Jetpack Compose.
TL;DR: The code of the reveal effect can be found in a simple gist here.
❌ How I got it all wrong
My first idea was to replicate what was done by the Telegram app. So basically :
- Draw the current screen in a Bitmap
- Show the bitmap underneath the view
- Change the theme of the app
- Animate the View
So I started to search for how to draw a Composable on a Bitmap and I quickly hit a wall. To be fair, there is probably a way to do it (after all, we are rendering the UI on the screen so …) but as it didn’t come out quickly in my search, it got me thinking.
Jetpack Compose is a new take on the UI system so chances are that we need to take a new approach to this problem 🤔
🛣 Taking the Compose road
Let’s take a look at CrossFade
Since the idea was to transition between 2 states, I decided to take a look at how a simple transition was done in Jetpack Compose. For that I opened the source code of CrossFade
.
🖊 The signature
First, let’s have a look at CrossFade
's signature:
@Composable
fun <T> Crossfade(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: @Composable (T) -> Unit
)
The first parameter is the targetState
. It will help the CrossFade
function to detect the need for the transition to happen as well as a key for you to display your content.
Then, comes the usual suspect, the Modifier
. It’s good practice in Compose to allow to pass a Modifier
(as first default parameter) to a @Composable
function to allow changing its look & feel.
Following is the animationSpec
. You can kind of see this as your Interpolator
in the “traditional” Android animation system. By default, tween
is doing a “Fast-out Slow-in” animation with a duration of 300ms. Allowing to pass an animationSpec
makes your animation configurable by the caller.
Finally, we have a @Composable
function which will hold your content. As you can see, the lambda will receive a parameter which is the state to be rendered. As we’ll see in a moment, CrossFade
renders both views at the same time and therefore it will pass the state to render to your lambda in order for you to take actions accordingly.
🗃 Saving the state
Now, to the body of the function. It first starts by creating a few states for the animation:
@Composable
fun <T> Crossfade(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: @Composable CircularRevealScope.(T) -> Unit
) {
val items = remember { mutableStateListOf<CrossfadeAnimationItem<T>>() }
val transitionState = remember { MutableTransitionState(targetState) }
transitionState.targetState = targetState
val transition = updateTransition(transitionState)
// ...
}
The first is items
, it holds a list of the screens we are currently displaying/animating along with their respective keys. Indeed, items
is a list of CrossfadeAnimationItem
which is just a helper object to hold the key with the @Composable
function:
private data class CrossfadeAnimationItem<T>(
val key: T,
val content: @Composable () -> Unit
)
Then, comes the transitionState
, it’s used by updateTransion
and allows us to ask in which state of the transition we are in.
Finally, the transition
is a state that allows to tie multiple animations together.
🏗 Setting up the items to be displayed
Then it checks if it has to add new items to the list or remove the old ones when the animation is finished:
if (targetChanged || items.isEmpty()) {
// Only manipulate the list when the state is changed, or in the first run.
// ...
} else if (transitionState.currentState == transitionState.targetState) {
// Remove all the intermediate items from the list once the animation is finished.
items.removeAll { it.key != transitionState.targetState }
}
If it has to manipulate the list, it iterates over the states it had saved (and add the new one if applicable) and it adds the corresponding CrossfadeAnimationItem
to the list of items:
// It first creates a list of keys from the items list (all the states it is currently animating/displaying) and it adds the targetState if it's not in the list already
val keys = items.map { it.key }.run {
if (!contains(targetState)) {
toMutableList().also { it.add(targetState) }
} else {
this
}
}
// It removes all the saved items
items.clear()
// It maps the keys and store the result in the `items` variable
keys.mapIndexedTo(items) { index, key ->
// For each key, it creates a new CrossfadeAnimationItem which associates the key to a @Composable function.
// The @Composable associated is a new one which holds the animation and the content for the corresponding key
CrossfadeAnimationItem(key) {
// It creates an animation for each state
// As we can see, the animation is created from the transition in order to tie them together
val alpha by transition.animateFloat(
transitionSpec = { animationSpec }, label = ""
) {
if (it == key) 1f else 0f
}
// It puts our content into a Box with the animated alpha Modifier applied
Box(Modifier.alpha(alpha = alpha)) {
// "content" is the lambda passed to CrossFade to which it passes the key so the lambda knows how to properly display itself
content(key)
}
}
}
📺 Display all the active elements
Last but not least, it iterates over the items to display them:
@Composable
fun <T> CircularReveal(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: @Composable CircularRevealScope.(T) -> Unit
) {
// ...
Box {
items.forEach {
key(it.key) {
it.content()
}
}
}
}
🔥 Let’s implement the reveal effect
Based on how CrossFade
is implemented, it looked like the right candidate for the job. All I needed to do was to copy/paste the code and change the animation. So, let’s first build the animation!
Clipping
Since we want to show an expanding round shape starting from a specific point, I used the Modifier
clip
and made my own Shape
:
fun Modifier.circularReveal(@FloatRange(from = 0.0, to = 1.0) progress: Float, offset: Offset? = null) = clip(CircularRevealShape(progress, offset))
private class CircularRevealShape(
@FloatRange(from = 0.0, to = 1.0) private val progress: Float,
private val offset: Offset? = null
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
return Outline.Generic(Path().apply {
addCircle(
offset?.x ?: (size.width / 2f),
offset?.y ?: (size.height / 2f),
size.width.coerceAtLeast(size.height) * 2 * progress,
Path.Direction.CW
)
}.asComposePath())
}
}
As you can see, this creates a circle and puts its center at the given offset or in the middle of view if no offset is given.
The radius of the circle is computed using the biggest value between the width and the height of the view multiplied by 2 and is proportional to the current animation progress.
Yet, this was not ideal as the transition would seem quicker or slower (based on the position of the offset) to fill the screen. Therefore, I used Pythagoras’ hypotenuse formula to compute the longest distance from the offset to the corners of the view like this:
private fun longestDistanceToACorner(size: Size, offset: Offset?): Float {
if (offset == null) {
return hypot(size.width /2f, size.height / 2f)
}
val topLeft = hypot(offset.x, offset.y)
val topRight = hypot(size.width - offset.x, offset.y)
val bottomLeft = hypot(offset.x, size.height - offset.y)
val bottomRight = hypot(size.width - offset.x, size.height - offset.y)
return topLeft.coerceAtLeast(topRight).coerceAtLeast(bottomLeft).coerceAtLeast(bottomRight)
}
Adding the animation
OK, now that we can clip our view with a circular shape with an offset center at a given progression. We still have to animate it.
We already have an understanding of what we should be doing thanks to our understanding of CrossFade
so let’s put it all together:
@Composable
fun <T> CircularReveal(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: @Composable (T) -> Unit
) {
val items = remember { mutableStateListOf<CircularRevealAnimationItem<T>>() }
val transitionState = remember { MutableTransitionState(targetState) }
val targetChanged = (targetState != transitionState.targetState)
transitionState.targetState = targetState
val transition = updateTransition(transitionState, label = "transition")
if (targetChanged || items.isEmpty()) {
// Only manipulate the list when the state is changed, or in the first run.
val keys = items.map { it.key }.run {
if (!contains(targetState)) {
toMutableList().also { it.add(targetState) }
} else {
this
}
}
items.clear()
keys.mapIndexedTo(items) { index, key ->
CircularRevealAnimationItem(key) {
val progress by transition.animateFloat(
transitionSpec = { animationSpec }, label = ""
) {
if (index == keys.size - 1) {
if (it == key) 1f else 0f
} else 1f
}
Box(Modifier.circularReveal(progress = progress)) {
content(key)
}
}
}
} else if (transitionState.currentState == transitionState.targetState) {
// Remove all the intermediate items from the list once the animation is finished.
items.removeAll { it.key != transitionState.targetState }
}
Box {
items.forEach {
key(it.key) {
it.content()
}
}
}
}
And here is the result:
😅 Waiiiiit! What about the offset?!
OK, yeah, you may have noticed that the animation is not the same as the one at the top of the article. The reason for that is we didn't take into account the offset and, therefore, the animation starts from the center of the view.
So, how do we fix that ?
First, we add a new state to remember the last input from the user:
var offset: Offset? by remember { mutableStateOf(null) }
Then, we use it in our animation:
Box(Modifier.circularReveal(progress = progress, offset = offset)) {
content(key)
}
Finally, we add code to detect where was the last click from the user in our view, using the modifier pointerInteropFilter
:
Box(modifier.pointerInteropFilter {
offset = when (it.action) {
MotionEvent.ACTION_DOWN -> Offset(it.x, it.y)
else -> null
}
false
}) {
items.forEach {
key(it.key) {
it.content()
}
}
}
And the final result:
@Composable
fun <T> CircularReveal(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: @Composable (T) -> Unit
) {
val items = remember { mutableStateListOf<CircularRevealAnimationItem<T>>() }
val transitionState = remember { MutableTransitionState(targetState) }
val targetChanged = (targetState != transitionState.targetState)
var offset: Offset? by remember { mutableStateOf(null) }
transitionState.targetState = targetState
val transition = updateTransition(transitionState, label = "transition")
if (targetChanged || items.isEmpty()) {
// Only manipulate the list when the state is changed, or in the first run.
val keys = items.map { it.key }.run {
if (!contains(targetState)) {
toMutableList().also { it.add(targetState) }
} else {
this
}
}
items.clear()
keys.mapIndexedTo(items) { index, key ->
CircularRevealAnimationItem(key) {
val progress by transition.animateFloat(
transitionSpec = { animationSpec }, label = ""
) {
if (index == keys.size - 1) {
if (it == key) 1f else 0f
} else 1f
}
Box(Modifier.circularReveal(progress = progress, offset = offset)) {
content(key)
}
}
}
} else if (transitionState.currentState == transitionState.targetState) {
// Remove all the intermediate items from the list once the animation is finished.
items.removeAll { it.key != transitionState.targetState }
}
Box(modifier.pointerInteropFilter {
offset = when (it.action) {
MotionEvent.ACTION_DOWN -> Offset(it.x, it.y)
else -> null
}
false
}) {
items.forEach {
key(it.key) {
it.content()
}
}
}
}
private data class CircularRevealAnimationItem<T>(
val key: T,
val content: @Composable () -> Unit
)
fun Modifier.circularReveal(@FloatRange(from = 0.0, to = 1.0) progress: Float, offset: Offset? = null) = clip(CircularRevealShape(progress, offset))
private class CircularRevealShape(
@FloatRange(from = 0.0, to = 1.0) private val progress: Float,
private val offset: Offset? = null
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
return Outline.Generic(Path().apply {
addCircle(
offset?.x ?: (size.width / 2f),
offset?.y ?: (size.height / 2f),
size.width.coerceAtLeast(size.height) * 2 * progress,
Path.Direction.CW
)
}.asComposePath())
}
}
📰 Adding the effect to Jetnews
The app you are seeing in the captures is Jetnews, a sample app provided by Google. I have added the effect in this app because I wanted a nice looking app to demonstrate the transition.
LocalThemeToggle
In order to be able to toggle the theme from anywhere in the app without passing a method throughout the entire hierarchy, I created a CompositionLocalProvider
call LocalThemeToggle
like this:
val LocalThemeToggle: ProvidableCompositionLocal<() -> Unit> = staticCompositionLocalOf { {} }
Then, I added it around the JetnewsTheme
call in JetnewsApp
along with the CircularReveal
composable like so:
val isSystemDark = isSystemInDarkTheme()
var darkTheme: Boolean by remember { mutableStateOf(isSystemDark) }
CompositionLocalProvider(LocalThemeToggle provides { darkTheme = !darkTheme }) {
CircularReveal(darkTheme) { theme ->
JetnewsTheme(theme) {
AppContent(
navigationViewModel = navigationViewModel,
interestsRepository = appContainer.interestsRepository,
postsRepository = appContainer.postsRepository,
)
}
}
}
I added an Icon for the toggle in the Toolbar and it worked!
actions = {
Icon(
if (MaterialTheme.colors.isLight)
Icons.Default.ToggleOn
else
Icons.Default.ToggleOff,
"Toggle theme",
Modifier
.clickable(onClick = LocalThemeToggle.current)
.size(48.dp)
)
}
Well … Sort of !
The problem is that HomeScreen
uses the function produceUiState
directly from the repository and this triggers a refresh "everytime the coroutine restarts from producer or key changes" (as stated in the comment).
The solution was to move away from this and create a ViewModel
to store the UiState
in a LiveData
.
I'm not going to explain how here as this is not really part of this article.
🐛 A little bug slipped through
During the development of this feature, I found an interesting bug with the gesture navigation.
Feel free to check it out
🤔 Final thoughts
If you made it to here, first of all, congratulations! It must not have been easy.
This little experiment taught me a few things:
- Don't try to replicate what you know from the previous View system. It may not apply
- Take a step back before approaching a new Jetpack Compose problem and think if you can inspire yourself from existing code within Jetpack Compose itself!
- It can be very frustrating to start fresh, specially when you have years of experience with Android. The frustration is normal and it's going to take time before we feel as comfortable with Jetpack Compose as we are with the View system. Yet, the more I learn about Jetpack Compose and the more confident I am we are going toward a more flexible and more pleasant to use framework.
Top comments (2)
Why all that Unnecessary complexity? I've created the same effect in a much more simple implementation
Produces the following Result:
Also, it can be easily extended to change the colors of system bars (status bar & navigation bar)
Sorry, I was not notified about your comment and I have just seen it now.
You didn't produce the same effect at all.
With my code, your toggle would still be grey in light mode and become green only where the circle shape is already overlaying it.
It might not be that obvious but if you look closely to the GIF at the top of the article, the text doesn't transition to white entirely in one go but only the parts where the ripple as already reached it are white and the other parts are still black.
I'm not sure I'm making myself completely clear but feel free to ask if that is not the case