DEV Community

loading...

Jetpack Compose — Reveal effect

bmonjoie profile image Benjamin Monjoie Originally published at xzan.Medium ・9 min read

Gif of the theme switching reveal effect in Jetnews

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 :

  1. Draw the current screen in a Bitmap
  2. Show the bitmap underneath the view
  3. Change the theme of the app
  4. 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
)
Enter fullscreen mode Exit fullscreen mode

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)

    // ...

}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

📺 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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

🔥 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())
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And here is the result:

Gif of CircularReveal effect without the offset

😅 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) }
Enter fullscreen mode Exit fullscreen mode

Then, we use it in our animation:

Box(Modifier.circularReveal(progress = progress, offset = offset)) {
    content(key)
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
    }
}
Enter fullscreen mode Exit fullscreen mode

📰 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 { {} }
Enter fullscreen mode Exit fullscreen mode

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,
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    )
}
Enter fullscreen mode Exit fullscreen mode

Well … Sort of !

Gif CircularReveal without state saved accross changes

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.

Discussion (1)

Collapse
mr3ytheprogrammer profile image
M R 3 Y • Edited

Why all that Unnecessary complexity? I've created the same effect in a much more simple implementation

@Composable
fun CircularRevealLayout(
    modifier: Modifier = Modifier,
    isLightTheme: Boolean = !isSystemInDarkTheme()
) {
    var isLight by remember { mutableStateOf(isLightTheme) }
    var radius by remember { mutableStateOf(0f) }
    Box(
        modifier = modifier
            .fillMaxSize()
            .background(Color.Transparent)
            .drawBehind {
                drawCircle(
                    color = if (isLight) Color.White else Color.Black.copy(0.7f),
                    radius = radius,
                    center = Offset(size.width, 0f),
                )
            },
        contentAlignment = Alignment.Center
    ) {
        SwitchButton(
            modifier = Modifier
                .size(72.dp, 48.dp)
                .semantics {
                    contentDescription =
                        if (isLight) "Switch to dark theme" else "Switch to light theme"
                },
            checked = !isLight,
            onCheckedChange = { isLight = !isLight }
        )
    }
    val animatedRadius = remember { Animatable(0f) }
    val (width, height) = with(LocalConfiguration.current) {
        with(LocalDensity.current) { screenWidthDp.dp.toPx() to screenHeightDp.dp.toPx() }
    }
    val maxRadiusPx = hypot(width, height)
    LaunchedEffect(isLight) {
        animatedRadius.animateTo(maxRadiusPx, animationSpec = tween()) {
            radius = value
        }
        // reset the initial value after finishing animation
        animatedRadius.snapTo(0f)
    }
}

@Composable
fun SwitchButton(
    checked: Boolean,
    modifier: Modifier = Modifier,
    onCheckedChange: () -> Unit
) {
    Switch(
        checked = checked,
        modifier = modifier,
        onCheckedChange = { onCheckedChange() }
    )
}

@Preview(widthDp = 360, heightDp = 640)
@Composable
fun CircularRevealLayoutPreview() {
    CircularRevealLayout()
}
Enter fullscreen mode Exit fullscreen mode

Produces the following Result:

Result

Also, it can be easily extended to change the colors of system bars (status bar & navigation bar)

Forem Open with the Forem app