Part 3, UI Widgets from scratch in Jetpack Compose.
Tutorial, Part 3 of 3 (Adding Animation to The Deck of Cards)
--
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:
- 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;
- 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;
- Each time a user swipes the card out from the screen, the deck should push and show the next card on the top;
- We should be able to swipe the top card out from the screen programmatically (for the Play All Cards mode);
- The top card should flip to the back and front when a user presses the Peep or Back button;
- 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.
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.
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.
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.
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!
UI Widgets from scratch in Jetpack Compose
Part 1 of 3 (an iOS styled Home Screen List)
Part 2 of 3 (the Deck of Cards)
Part 3 of 3 (adding animation to the Deck of Cards)
Demo repository:
https://github.com/sergenes/compose-demo
Thinking in Compose:
https://developer.android.com/jetpack/compose/mental-model
StudyCards app helps to memorize things (my version of the flashcards/memory cards):
Google Play(Beta, work in progress):
https://play.google.com/store/apps/details?id=com.nes.studycards
Appstore:
https://apps.apple.com/us/app/study-cards-help-to-memorize/id1436913731
Website: