Blog Infos
Author
Published
Topics
Author
Published

 

 

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.

 

 

  1. 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu