Implementing a fully custom UI with complex animations: Moving between screens
At Exyte we try to contribute to open-source as much as we can, from releasing libraries and components to writing articles and tutorials. One type of tutorials we do is replicating — taking a complex UI component, implementing it using new frameworks and writing a tutorial alongside. We started with SwiftUI some years ago, but this time we finally foray into Android, using Google’s declarative UI framework: Jetpack Compose.
This is the third article of the dribbble Replicating series. The previous post demonstrated implementing the collapsing header. In this part we will discuss transtions between app views.
Shared Element Transition
In the old AndroidView we could use shared element transition as part of the Transition Framework. But in Compose there is no standard solution. In general, it’s not so hard to write them yourself — let’s describe what we need to do with shared element:
- Animate screen position.
- Animate size.
- Animare corner size.
So, we need to know all these parameters in initial and target states. Let’s start with writing a data class:
data class SharedElementParams( val initialOffset: Offset, val targetOffset: Offset, val initialSize: Dp, val targetSize: Dp, val initialCornerRadius: Dp, val targetCornerRadius: Dp, )
Initial corner radius we can set as
.clip(RoundedCornerShape(initialCornerRadius))
Initial Offset and initialSize
we can get by using .onGloballyPositioned
.
Combining everything, we get this:
var parentOffset by remember { mutableStateOf(Offset.Unspecified) } var mySize by remember { mutableStateOf(0) } Image( modifier = Modifier .aspectRatio(1f) .onGloballyPositioned { coordinates -> parentOffset = coordinates.positionInRoot() mySize = coordinates.size.width } .clip(RoundedCornerShape(initialCornerRadius)) .clickable { onClick(info, parentOffset, mySize) }, )
Now we need to find out the target parameters for the end position.
targetOffset
. To find the x offset of an element that will be centered, we need to subtract the width of the element from the screen width and divide by two. We set the vertical offset ourselves:
targetOffset = Offset( x = (maxContentWidth - sharedElementTargetSize.toPx(LocalDensity.current)) / 2f, y = 50.dp.toPx(LocalDensity.current).toFloat() ),
2. targetSize
we set ourselves as well.
3. targetCornerRadius
we set as half of the shared element size. And since our element is round: targetCornerRadius = sharedElementTargetSize / 2
The basic idea is that at the moment of transition, in the new function we place the shared element in the state it was in in the previous composable function, and then animate it to the state it should be in.
We can use LaunchedEffect
to start the progress animation.
val offsetProgress = remember { Animatable(if (isForward) 0f else 1f) } LaunchedEffect(key1 = isForward) { launch { offsetProgress.animateTo( targetValue = if (isForward) 1f else 0f, animationSpec = tween(ANIM_DURATION), ) onTransitionFinished() } }
Since we need to animate several things, we can animate only offsetProgress (the animation we showed above), and use linear interpolation to show changes to other parameters:
val cornersSize = lerp( params.initialCornerRadius, params.targetCornerRadius, offsetProgress.value, ) val currentOffset = lerp( initialOffset, targetOffset, offsetProgress.value ) val cornersSize = lerp( params.initialCornerRadius, params.targetCornerRadius, offsetProgress.value, )
Job Offers
This is how you can use sharedElement
:
@Composable fun SharedElementContainer( modifier: Modifier = Modifier, coverOffset: Offset, coverSize: Dp, coverCornersRadius: Dp, sharedElement: @Composable BoxScope.() -> Unit, ) { //... Box( modifier = modifier .padding(top = coverOffset.y.toDp(density)) .offset { IntOffset(x = coverOffset.x.toInt(), y = 0) } .size(coverSize) .clip(RoundedCornerShape(coverCornersRadius)), content = sharedElement, ) //... }
And this is all you need to animate the shared element transition! Let’s now look at the drag gesture transition.
Drag gesture transition
In our design we have a draggable button, which can be used to go to the next screen either by tapping or by swiping to the right.
For tapping, we use:
.clickable { onClick() }
For drag gestures we can use:
.pointerInput(Unit) { detectHorizontalDragGestures( onDragStart = {onDragStart()}, onDragEnd = onDragFinished, ){ change, dragAmount -> onOffsetChange(dragAmount) } }
onDragStart
is used to change screen state, to start drawing the new screen that appears.onDragEnd
is used to see which way the animation will end up. If the user completed the gesture and lifted their finger to the middle of the screen, the screen would return to its previous position. When the user’s gesture ends after the middle of the screen, we finish the animation and move to a new screen.
We use this method to determine which action to perform when the gesture is over.
fun completeAnimation(currentDragOffset: Float) { val shouldExpand = currentDragOffset > screenState.maxContentWidth / 4f if (shouldExpand) { expand() } else { collapse() } }
onOffsetChange
method is used to set the new offset and calculate the transition progress for animations.
fun calculateNewOffset(dragAmount: Float) { val newOffset = minOf( screenState.currentDragOffset + dragAmount, (screenState.maxContentWidth - draggableButtonSize.width.toPx(density)).toFloat() ) if (newOffset >= 0) { screenState.currentDragOffset = newOffset } }
Progress for the other animations we calculate from 0 to 1.
val fromPlayerControlsToAlbumsListProgress by derivedStateOf { currentDragOffset / maxContentWidth }
Based on this progress, we animate the necessary elements. A song information panel that moves up, a comment panel that moves out to the left, an album image that zooms in, an album list that appears.
For example, we calculate playerControlOffset
using CubicBezierEasing
for a nonlinear offset change:
val playerControlOffset by derivedStateOf { val cubicBezierEasing = CubicBezierEasing( a = 0.25f, b = -songInfoContainerHeight.toFloat() / 5, c = 0.5f, d = -10.dp.toPxf(density) ) cubicBezierEasing.transform(fromPlayerControlsToAlbumsListProgress) + songInfoOffset }
This concludes the transitions between app views. The 5th and last installment will cover finding and fixing performance issues. Release coming soon!
This article was previously published on proandroiddev.com