Jetpack Compose — Reveal effect
Tags: android, jetpackcompose
Published: 2021-05-04 12:56:33

![Gif of the theme switching reveal effect in Jetnews](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ot1jewsjjxwv3k1ziwdp.gif)

A few days ago, I saw [a post on Reddit](https://www.reddit.com/r/androiddev/comments/mh3hbo/how_could_i_achieve_this_effect_in_xml_or_compose/) 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](https://proandroiddev.com/change-theme-dynamically-with-circular-reveal-animation-on-android-8cd574f5f0d8) that was implemented by the [Telegram](https://telegram.org/) 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](https://gist.github.com/bmonjoie/8506040b2ea534eac931378348622725).

# ❌ 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
1. Show the bitmap underneath the view
1. Change the theme of the app
1. 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`](https://foso.github.io/Jetpack-Compose-Playground/animation/crossfade/).

## 🖊 The signature

First, let’s have a look at `CrossFade`'s signature:

```kotlin
@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`](https://developer.android.com/reference/android/view/animation/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:

```kotlin
@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:

```kotlin
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:

```kotlin
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:

```kotlin
// 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:

```kotlin
@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`](https://foso.github.io/Jetpack-Compose-Playground/foundation/shape/) and made my own `Shape` :

```kotlin
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:

```kotlin
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:

```kotlin
@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:

![Gif of CircularReveal effect without the offset](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m9vrwhguifno176ai7fm.gif)

# 😅 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:

```kotlin
var offset: Offset? by remember { mutableStateOf(null) }
```

Then, we use it in our animation:

```kotlin
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`:

```kotlin
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:

```kotlin
@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](https://medium.com/r/?url=https%3A%2F%2Fgithub.com%2Fandroid%2Fcompose-samples%2Ftree%2Fmain%2FJetNews), 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`](https://medium.com/r/?url=https%3A%2F%2Ffoso.github.io%2FJetpack-Compose-Playground%2Fgeneral%2Fcompositionlocal%2F) call `LocalThemeToggle` like this:

```kotlin
val LocalThemeToggle: ProvidableCompositionLocal<() -> Unit> = staticCompositionLocalOf { {} }
```

Then, I added it around the `JetnewsTheme` call in `JetnewsApp` along with the `CircularReveal` composable like so:

```kotlin
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!

```kotlin
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 !

![Gif CircularReveal without state saved accross changes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hxdpqm3hqn04mhi5n12z.gif)

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](https://issuetracker.google.com/issues/185228754)

# 🤔 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.