Skip to content

Google’s account switcher with Jetpack Compose

In this article I’ll show you how to build the account switcher Google uses in its applications.

If you have multiple Google accounts you can simply switch them by swiping on the account’s image.

Understanding the component

First let’s analyze how the component works. This is how it looks on Gmail but on other apps like Drive or Calendar it may look a little different, however, the functionality is the same.

Here’s the same animation in slow motion.

We can see that the current account’s image slides out and the new account’s image scales in. If you swipe up, the current image slides down, if you swipe down, the current image slides up.

Code

For simplicity I created an Account class that uses a drawable as the image but in a real application that would likely be the image url.

data class Account(@DrawableRes val image: Int)

private val accounts = listOf(
    Account(image = R.drawable.goat),
    Account(image = R.drawable.horse),
    Account(image = R.drawable.monkey)
)

After that I created the AccountSwitcher component that receives a list of accounts, the current account and a callback that’s called when the account changes.

@Composable
fun AccountSwitcher(
    accounts: List<Account>,
    currentAccount: Account,
    onAccountChanged: (Account) -> Unit,
    modifier: Modifier = Modifier
) {
    ...
}

Inside AccountSwitcher I defined a few variables.

val imageSize = 36.dp
val imageSizePx = with(LocalDensity.current) { imageSize.toPx() }

val currentAccountIndex = accounts.indexOf(currentAccount)
var nextAccountIndex by remember { mutableStateOf<Int?>(null) }

var delta by remember(currentAccountIndex) { mutableStateOf(0f) }
val draggableState = rememberDraggableState(onDelta = { delta = it })

val targetAnimation = remember { Animatable(0f) }

imageSize is just the size you want the image to be. You could also take that as a parameter in the constructor if you want the component to be more reusable. imageSizePx is the same size but in pixels, animations work with pixels instead of dips.

currentAccountIndex is the index of the current account, nextAccountIndex contains the index of the next account, it’s null by default because it only contains a value when the component is animating.

delta is the draggable delta, it’s provided by the rememberDraggableState that is created below.

targetAnimation is a value that we’ll use for the animation, it ranges from 0 to -1 and from 0 to +1. It goes to -1 if you scroll up and to +1 if you scroll down.

LaunchedEffect(key1 = currentAccountIndex) {
    snapshotFlow { delta }
        .filter { nextAccountIndex == null }
        .filter { it.absoluteValue > 1f }
        .throttleFirst(300)
        .map { delta ->
            if (delta < 0) { // Scroll down (Bottom -> Top)
                if (currentAccountIndex < accounts.size - 1) 1 else 0
            } else { // Scroll up (Top -> Bottom)
                if (currentAccountIndex > 0) -1 else 0
            }
        }
        .filter { it != 0 }
        .collect { change ->
            nextAccountIndex = currentAccountIndex + change

            targetAnimation.animateTo(
                change.toFloat(),
                animationSpec = tween(easing = LinearEasing, durationMillis = 200)
            )

            onAccountChanged(accounts[nextAccountIndex!!])
            nextAccountIndex = null
            targetAnimation.snapTo(0f)
        }
}

The code inside LaunchedEffect is what makes the animation happen. I’ll explain it line by line.

First we use snapshotFlow to observe the MutableState as a Flow. Then we check if nextAccountIndex is null, that means no animation is happening. We don’t want to start another animation if there’s one already happening.

After that I use filter to only keep delta values bigger than 1, that helps ignore accidental scrolls. Then I use throttleFirst to only receive 1 value every 300ms, that’s because delta changes a lot when you swipe and I’m only interested in the first value.

I use map to check if the scroll is possible and return +1 if the user scrolled down and we need to animate to the next account, -1 if the user scrolled up and we need to animate to the previous account or 0 if there’s no account before or after.

I then filter only values if the change is different from 0, we don’t need to animate anything if nothing changed.

Finally in the collect block the animation happens. I start by setting nextAccountIndex to be the current account index +1 or -1. That causes the composition to recompose and display the next account’s image below the current account’s image as you’ll see below. I then call targetAnimation.animateTo that’s a blocking call that only returns when the animation ends.

After the animation ends, I call the onAccountChanged to notify the parent that the account changed, set nextAccountIndex to null because there’s no anything happening anymore and reset targetAnimation to 0.

fun <T> Flow<T>.throttleFirst(periodMillis: Long): Flow<T> {
    require(periodMillis > 0) { "period should be positive" }
    return flow {
        var lastTime = 0L
        collect { value ->
            val currentTime = System.currentTimeMillis()
            if (currentTime - lastTime >= periodMillis) {
                lastTime = currentTime
                emit(value)
            }
        }
    }
}

I mentioned throttleFirst in the previous paragraph but it is a custom extension function, I found in this article. You’ll have to copy/paste it in your project too.

Now let’s talk about the UI for the component.

    Box(modifier = Modifier.size(imageSize)) {
        nextAccountIndex?.let { index ->
            Image(
                painter = painterResource(id = accounts[index].image),
                contentScale = ContentScale.Crop,
                contentDescription = "Account image",
                modifier = Modifier
                    .graphicsLayer {
                        scaleX = abs(targetAnimation.value)
                        scaleY = abs(targetAnimation.value)
                    }
                    .clip(CircleShape)
            )
        }

        Image(
            painter = painterResource(id = accounts[currentAccountIndex].image),
            contentScale = ContentScale.Crop,
            contentDescription = "Account image",
            modifier = Modifier
                .draggable(
                    state = draggableState,
                    orientation = Orientation.Vertical,
                )
                .graphicsLayer {
                    this.translationY = targetAnimation.value * imageSizePx * -1.5f
                }
                .clip(CircleShape)
        )
    }

nextAccountIndex will be different from null if we’re animating to a new account. In that case we can to display the next account’s image below the current image because that’s how the animation works.

As I said before, targetAnimation changes from 0 to -1 and from 0 to +1. scaleX and scaleY have to be positive numbers between 0 and 1 so we get the absolute value of the animation.

The current image just slides out, for the whole image to slide out, we’d have to move it by its size. For this animation, it’s 1 * imageSizePx. I’m multiplying it by a negative value because when the transition goes from 0 to 1, I want translationY to go from 0 to -imageSizePx. I’m multiplying it by 1.5 because I want this animation to end first than the scale animation.

Make sure you apply clip(CircleShape) after graphicsLayer, otherwise it won’t work.

We only need to attach the draggable modifier to the current image given the next image becomes the current image when the composable recomposes.

That’s all for the AccountSwitcher component. If you want to use it, you just need to store the selected account in a variable and update it.

var selectedAccount by remember { mutableStateOf(accounts[0]) }

AccountSwitcher(
    accounts = accounts,
    currentAccount = selectedAccount,
    onAccountChanged = { selectedAccount = it }
)

Here’s how this component ended up looking:

It doesn’t animate exactly like the Google’s component does but that was a personal choice. If you want you can tweak the animation a bit to make it animate however you want.


If you want to check the source code, you can find it here.

If you have any comments or suggestions, please reach out to me on Twitter.

Photo by Dim Gunger on Unsplash