Card Animations in Jetpack Compose

 



I've created a small demo program to show how to use animations in your Android app. This app deals cards from a deck when clicked and returns them to the deck when it is clicked a second time.

CardComponents.kt


BaseCard

@Composable
fun BaseCard(
modifier: Modifier = Modifier,
elevation: Dp = 0.dp,
content: @Composable () -> Unit
) {
val shape = RoundedCornerShape(10.dp)

Card(
modifier = modifier
.size(width = CARD_WIDTH.dp, height = CARD_HEIGHT.dp)
.clip(shape)
.border(2.dp, Color.Black, shape),
elevation = elevation,
content = content
)
}


Composable function which is used as a common base for both the card deck and animated cards. Parameters include an optional modifier extension, elevation which will be set so the cards can overlap, and the content lambda.


Deck

@Composable
fun Deck(modifier: Modifier = Modifier, onClick: () -> Unit) {
BaseCard(
modifier = modifier.clickable { onClick() }
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.linearGradient(
0.0f to Color.Green,
0.75f to Color.Blue
)
)
)
}
}


Composable function which represents the card deck where the animated cards are dealt. Interacting with it will deal a hand of cards and tapping it again will return the cards to the deck. Takes an optional modifier and an onClick lambda which will start the card animations.


AnimatedCard

@Composable
fun AnimatedCard(
isVisible: Boolean,
elevation: Dp = 0.dp,
rotation: Float = 0f,
initialX: Int = 0,
initialY: Int = 0,
duration: Int = 0,
imageRes: Int = 0
) {
// Slide right and up to the card's resting position on enter
// Slight left and down back to the deck's position on exit
AnimatedVisibility(
visible = isVisible,
enter = slideIn(
initialOffset = { IntOffset(initialX, initialY) },

animationSpec = tween(duration)
),
exit = slideOut(
targetOffset = { IntOffset(initialX, initialY) },
animationSpec = tween((duration * 1.3f).roundToInt()),
)
) {
// the card
BaseCard(
modifier = Modifier
.background(Color.Transparent)
.rotate(rotation),
elevation = elevation
) {
// PNG image of the card
Image(
modifier = Modifier.fillMaxSize(),
painter = painterResource(id = imageRes),
contentDescription = "",
contentScale = ContentScale.FillBounds
)
}
}
}


Composable function which represents an individual card with a defined enter/exit transition animation. Parameters are:


isVisible: Boolean flag to tell if the cards are shown
elevation: the specific elevation on the card in relation to the others. Used so the cards can visibly overlap.
rotation: add a slight rotation offset to all cards except for the middle one
initialX: x location of the start of the animation
initialY: y location of the start of the animation
duration: the duration of the animation from when the card leaves the deck to when it comes to rest.

imageRes: the image resources (.png) from the drawables folder for the individual cards


AnimatedVisibility has the following parameters:


visible: are the cards shown?
enter: defines a slideIn animation with an initial offset (where the animation starts) and an animationSpec, where the duration of the animation is defined.
exit: defines a slideOut animation, which is just the above animation in reverse. The targetOffset parameter defines the end location of the animation, and an animationSpec where the duration is staggered slightly so the last card is returned to the deck first and it looks slightly different than just a reverse of the enter transition.
content: the content lambda block contains the visible UI components of the card. Here a BaseCard is modified and a Text composable is added to its content to display the card’s value.


Table

const val CARD_WIDTH = 50
const val CARD_HEIGHT = 80
const val HAND_SIZE = 5
@Composable
fun Table() {
var isVisible by remember { mutableStateOf(false) } // toggle enter/exit card animations
var isClickable by remember { mutableStateOf(true) } // is deck clickable right now?
val coroutineScope = rememberCoroutineScope()
val onClick: () -> Unit = { // deal some cards on click
if (isClickable) {
isVisible = !isVisible
isClickable = false

coroutineScope.launch {
delay(2000L)
isClickable = true
}
}
}

Row(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient( // gray/white background
0.1f to Color.DarkGray,
0.35f to Color.White,
0.65f to Color.White,
0.9f to Color.DarkGray
)
),
verticalAlignment = Alignment.CenterVertically
) {
// Graphical representation of the card deck
// also where the played card animations start
Deck(
modifier = Modifier
.padding(top = 128.dp, start = 8.dp, end = CARD_WIDTH.dp),
onClick = onClick
)

// Add cards to the table
repeat(HAND_SIZE) { cardIndex ->
// Start card animation at (initialX, initialY) at deck's location
// These values are based on dp then converted to pixels
// due to varying screen sizes on different devices
val initialX = with (LocalDensity.current) {
val individualCardOffsetX = (CARD_WIDTH * 0.66f)
.roundToInt() * (HAND_SIZE - 1)

-(2 * CARD_WIDTH).dp.toPx().roundToInt() - individualCardOffsetX
}
val initialY = with (LocalDensity.current) {
(CARD_HEIGHT - 16).dp.toPx().roundToInt()
}
val offset = with (LocalDensity.current) {
(CARD_WIDTH / 2).dp.toPx().roundToInt() * cardIndex
}

AnimatedCard(
isVisible = isVisible, // toggle whether the cards are in a dealt state or not
elevation = cardIndex.dp, // overlap each card in the hand dealt
rotation = -20f + cardIndex * 10, // tilt hand -20 to 20 degrees
initialX = initialX - offset, // offset cards so their bounds overlap during animation
initialY = initialY,
duration = 1500 - 300 * cardIndex, // cards on the right move faster
imageRes = when (cardIndex) { // give a different graphic for each card dealt
0 -> R.drawable.jack
1 -> R.drawable.seven
2 -> R.drawable.four
3 -> R.drawable.queen
4 -> R.drawable.ace

else -> throw IndexOutOfBoundsException()
}
)
}
}
}

Composable function which represents the surface the cards and deck are set (i.e. the rest of the UI’s screen). This composable ties the rest of the UI elements together.


Variables:
isVisible: controls the visibility of the animated cards (a change in visibility triggers an enter or exit animation).
isClickable: toggles if the deck can currently be clicked to trigger the card animation.
onClick: lambda passed to the deck to control both card visibility and the deck’s clickable. state. After a click, the deck should not be able to be clicked again until the card animations are finished. Here, we set a two second delay before clicks are allowed again.


The Row contains all the elements of the table and defines where the deck sits and the final positions of the animated cards.


The repeat block is where the animation information is set for each of the five cards being dealt.


The card animation’s initial x, y, and offset values ensure that each card’s animation starts where the card deck is set. Note that all animations are initialized in density-independent pixels (dp) and are then rounded into pixel values. This is necessary since different phones have different screen sizes! LocalDensity provides the screen density information to do this conversion. We finally pass this information into each AnimatedCard.


MainActivity.kt

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CardsTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Table()
}
}
}
}
}


Finally, we just call our Table inside of our MainActivity class.

Github link: https://github.com/PopularPenguin/Cards

Comments

Popular posts from this blog

Permissions in Jetpack Compose

Jetpack Compose Login UI