Part 3, UI Widgets from scratch in Jetpack Compose.

Tutorial, Part 3 of 3 (Adding Animation to The Deck of Cards)

Sergey Nes
Geek Culture
Published in
10 min readJul 7, 2021

--

StudyCards app — remember answer to any question

In this trilogy of tutorials, I will show how I build Compound UI Widgets for my pet project (Study Cards app — my version of the flash/mem cards).

In Part One of this tutorial, I showed how to build an iOS-styled ListView widget with the rows (list items) that have a different shape, depending on the number of rows and the row location and that look different in edit or delete mode.

In Part Two, we were building the Deck of Cards widget.

In Third Part - we will add the state-driven swipe and flip animation and the drag gesture recognizer. Also, we will apply the separation of concerns principles to this widget. To keep the code of the widget small and to have separated classes responsible for the animation and user actions, and other events.

First, let’s define what our widget supposed to do and how it should behave:

  1. A user should be able to drag the card from the top of the deck, and the card should go above all UI elements on the screen;
  2. If a user drops the card, it should be swiped out from the screen or returned to the initial position, depending on the place on the screen where the user drops it;
  3. Each time a user swipes the card out from the screen, the deck should push and show the next card on the top;
  4. We should be able to swipe the top card out from the screen programmatically (for the Play All Cards mode);
  5. The top card should flip to the back and front when a user presses the Peep or Back button;
  6. When a user drags the top card, we should push the rest of the cards to the top, one position up.

Of Course, we shall use animation for all those transitions as much as possible! Animations are essential in a modern mobile app in order to implement a smooth and understandable user experience.

I found a helpful chart in Jetpack Compose documentation that helps decide what kind of animation is the best use in your case.
For the Flip animation, I choose the animate*AsAState because I’ll be animating multiple values simultaneously, my animation is not infinite, and it’s state-based and animates content change. For the swipe animation, we will use Animatable. Animatable is a value holder that can animate the value as it is changed via animateTo() method.

via:https://developer.android.com/jetpack/compose/animation

Flipping card animation (animateFloatState, animationSpec, tween)

Let’s begin with the flipping card animation. One way to create this effect is to decrease the scaleY (height) of the card (the Composable Surface a root container of the card) from 100% to 10%, then replace the content and increase the scaleY back to 100%, see the Figures 2 and 3.
We know that in order to to make the animation play, in Jetpack Compose, we must force it to do recomposition for each animation frame. To achieve that, we need to declare two state variables for X and Y scale values modified by composable animationFloatState() function (lines 12 and 20). Also, we need the third state variable, the flipState, to trigger the animation every time we change its state. Initially, the flipState is set to FRONT_SIDE, and this is the idle state. Updating flipState with the FLIP_BACK value will trigger the animation. When the animation ends, the flipState will transition to another idle state is BACK_FRONT. To flip the card to the front, we need to update the flipState with the FLIP_FRONT value.

In the code below, you can see that I initialize those lateinit variables in the Composable Init() function. And, because we remember the flipState, the Jetpack Compose will recompose the UI when we call flipToBack() or flipToFront() functions. As I said earlier, those functions update the flipState with the FLIP_BACK or FLIP_FRONT values. Also, you can see that the X scale goes from 100% to 80%. When the scale Y animation reaches the first target point — 10%, it triggers the second part of the animation by setting the flipState value to the BACK_SIDE or the FRONT_SIDE for the flipping to the front.

Fugures 2,3,4

It is not perfect — we can improve this animation by adding a fade effect when replacing the sides. Also, to make it more realistic, we could add a perspective and keep the closer edge of the card longer than the farther edge. However, I didn’t find a way to achieve that with the current Compose API. Nevertheless, with normal speed, even this simple animation looks pretty good!

Swiping card animation (Animatable, animateTo, snapTo, finiteAnimationSpec)

For the swipe effect, we will use the offset animation. We can experiment with the different animation specs, but for now, let’s use the same tween spec as we used for the Flip effect. We remember the x and y offset of the card in the container and will make Jetpack Compose recompose the widget and translate the card every time we modify the value of the cardDragOffset. However, we will not change offset directly. We remember the offset as an Animatable state and will use animateTo() and snapTo() to translate the view. Note that the animateTo() and snapTo() are suspended functions and must be called from the coroutine context only.

The targetValueByState() helps determine a current target position where the animation should translate the card. The target is always to the East, out from the screen, for the SWIPED state (for the play all cards mode). The swipeDirection() function determines the direction of the swipe, for the DRAGGING state, depending on the last offset value (a place where the user drops the card). If the current offset farther from the center of the screen than the threshold, the card should translate to the North, East, South, or West. Otherwise, the card should return to the initial position.

Figure 5

Cards Position Manager

And the last animation helps manage all cards positions in the deck. It remembers the initial scale and offset of each card, can update their positions, and can calculate and return to the initial values.

The Deck ViewModel

Now, let’s separate some of the deck’s properties from the deck by creating the view Model data class, and let’s group all deck-related events into the Events data class.
In the data model, we will keep:
- an index of the current card in the data source;
- the data source itself;
- the number of visible cards;
- the screen dimensions;
- a few helping functions determine the color for the visible cards;
- the number of visible cards for each state.

The Decks Events Model

All Events, including the animations and the action handlers are grouped in this class. Also, I decided to keep there the custom modifier that responsible for creating the modifier object for each card and for connecting the animations with this modifier object. The most important part here is in (idx == topCardIndex) branch. In other words, this is the modifier for the top card.
The scale of this card will change when flipCard.scaleX or flipCard.scaleY change.
The offset or position of the card depends on the value in cardSwipe object.
In the pointerInput lambda, we implement detectDragGesture that handles two events onDrag and onDragEnd.

OnDrag event occurs a number of times while a user drags the card. It calls the cardSwipe.draggingCard and passes the new offset value as a parameter. Inside this function, we use snapTo() to immediately update the cardDragOffset Animatable value with the new value calculated from the OnDrag event. This forces the compose framework to run recomposition for this particular UI element and move it to the new position.

When onDragEnd occurs we trigger cardSwipe.animateToTarget() with DRAGGING option as the value of a second parameter. As you remember the cardDragOffset is an internal state variable of the cardSwipe object, so we always know the last card position, therefore we can calculate in which part of the screen a user drops the card, and according to that we can decide where the cards should go, to leave the screen in the west, east, south or the north direction, or it should return to the initial state, if the user didn’t drag the card too far away from the center of the screen. See the line 37, swipeDirection() function in CardSwipeAnimation.

Now that we have all our elements defined, we need to slightly modify the DeckOfCards widget from the second part of this tutorial. We extend the number of states in the CardFlipState enum, add enum to control the swipe animation, define the animation time for all animations as 350 milliseconds.

First, we need to initialize all animations objects. Remember, the init() methods are Composable. They all define or remember states, so when states are changed by animation, the recomposition for this widget will run from line 19.

The composable StudyCardDeck almost didn’t change. The same Box as a root container repeated block for each visible card. But, now, all values we get from the model and events objects. Also, we dispatch all events to the handlers and initialize the UI by calling the backToInitialState() method from events.cardsSwipeAnimation object.

And, finally, in the preview, we remember the state of the topCardIndex and the coroutineScope to call suspended functions. Then, we define two data model objects that describe the behavior of the DeckOfCards and link the view with the animations and gesture handlers. Also, to test the external event, let’s add a button on the top of the screen, which will trigger the cardSwipe animation to swipe the top card out from the screen and update the topCardIndex to recompose the deck the same way as nextHandler lambda does for manual swipe.

Look at the Figure 6 to see the live preview running.

figure 6

What have we learned?

Jetpack Compose provides powerful and extensible APIs that make it easy to implement various animations. Most of the Animations API are available as composable functions. Also, compose provides various APIs to help you detect gestures generated from user interactions. The APIs cover a wide range of use cases. Some of them are high-level and simple, designed to cover the most commonly used gestures, for example, the clickable modifier to detect clicks. Others are lower-level APIs designed to detect taps, drag, scroll, swipe, and multi-touch gestures more complex but offer more flexibility. We also learned that we could use @Composable annotation for UI emitting functions and utility functions.

If we want to make a library that will keep all reusable widgets, we can declare the interfaces’ contract methods as Composable.

Epilogue

The declarative approach and Jetpack Compose are not the ultimate remedy for the bugs. You can easily make a loop of recursively changing states, or on the opposite, make a state change in the wrong thread/coroutine scope, so even state is changed, it will not trigger the recomposition. However, when you really change the way you were thinking:

“Program your goal depending on states instead of programming the steps to achieve the goal!”

You will find that Jetpack compose can speed up the development process, you will find that you write way less code, less boilerplate, and as a result, you will create fewer bugs.

I’m feeling optimistic about the UI development for Android, and I’m looking forward to Jetpack Compose version 1. Also, there are few exciting projects using Jetpack Compose for non-UI purposes, checkout Mosaic and Compose for Web projects.

I wonder how Compose will evolve in the next few years? Will Google decouple Compose from the Jetpack and the AndroidX monorepo?

PS: Maybe I will recreate the DeckOfCard widget for my iOS version of the StudyCards app using SwiftUI — it should be nice to put the Compose/Kotlin and SwiftUI/Swift code side by side and compare them!

--

--

Sergey Nes
Geek Culture

Android and iOS Expert, LLM Applications Enthusiast. Follow me on LinkedIn for my thoughts and insights: https://www.linkedin.com/in/sergey-neskoromny-86662a10