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) } }
Job Offers
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 -imageSizeP
x. 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
This article was previously published on proandroiddev.com