This project demonstrates how to build a smooth, gesture-driven card flip animation using Jetpack Compose.
- Determining the correct midpoint of the rotation. If the card does not reach this point, it returns to its original position; otherwise, it completes a 180° flip.
- Handling gesture direction correctly. Dragging from right to left flips the card in that direction, and vice versa.
- Keeping the animation smooth by controlling card-face visibility with
alphainstead of reinitializing the card background.
private const val CARD_FLIP_END_ROTATION = 180f
private const val CARD_FLIP_MIDPOINT_ROTATION = 60f
private const val CARD_FLIP_DURATION = 450
@Composable
private fun FlipCardManagementContent(
isCardEnabled: Boolean,
isCardBackVisible: Boolean,
onCardClick: () -> Unit,
onCardDragFlip: (Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
FlipCard(
isCardEnabled = isCardEnabled,
isCardBackVisible = isCardBackVisible,
onCardClick = onCardClick,
onCardDragFlip = onCardDragFlip
)
}
}
@Composable
private fun FlipCard(
isCardEnabled: Boolean,
isCardBackVisible: Boolean,
onCardClick: () -> Unit,
onCardDragFlip: (Boolean) -> Unit
) {
val rotationY = remember {
Animatable(if (isCardBackVisible) CARD_FLIP_END_ROTATION else 0f)
}
val targetRotationY = remember {
mutableFloatStateOf(if (isCardBackVisible) CARD_FLIP_END_ROTATION else 0f)
}
val dragStartRotationY = remember { mutableFloatStateOf(rotationY.value) }
val isCardBackVisibleAtDragStart = remember { mutableStateOf(isCardBackVisible) }
val isDragging = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val displayedCardBackVisible by remember(isCardBackVisible) {
derivedStateOf {
if (!isDragging.value) {
isCardBackVisible
} else {
val dragProgress = abs(rotationY.value - dragStartRotationY.floatValue)
if (isCardBackVisibleAtDragStart.value) {
dragProgress < CARD_FLIP_MIDPOINT_ROTATION
} else {
dragProgress >= CARD_FLIP_MIDPOINT_ROTATION
}
}
}
}
LaunchedEffect(isCardBackVisible) {
Log.i("TAG", "BankimaCard: target rotation y : ${targetRotationY.floatValue}")
rotationY.animateTo(
targetValue = targetRotationY.floatValue,
animationSpec = tween(durationMillis = CARD_FLIP_DURATION, easing = FastOutSlowInEasing)
)
}
val settleCardAfterDrag: (Float, Float) -> Unit = { dragRotationChange, initialRotationY ->
val shouldFlip = abs(dragRotationChange) >= CARD_FLIP_MIDPOINT_ROTATION
val rotationDirection = if (isCardBackVisible) -1f else 1f
Log.i("TAG", "BankimaCard: settle Y : value : ${rotationY.value}")
Log.i("TAG", "BankimaCard: settle Y : target value : ${rotationY.targetValue}")
if (shouldFlip) {
targetRotationY.floatValue = if (dragRotationChange >= 0f) {
initialRotationY + rotationDirection * CARD_FLIP_END_ROTATION
} else {
initialRotationY - rotationDirection * CARD_FLIP_END_ROTATION
}
onCardDragFlip(!isCardBackVisible)
} else {
coroutineScope.launch {
rotationY.animateTo(
targetValue = initialRotationY,
animationSpec = tween(
durationMillis = CARD_FLIP_DURATION,
easing = FastOutSlowInEasing
)
)
}
}
Unit
}
val shapeColor = if (!isCardEnabled) colorResource(R.color.ewano_card_management_not_enable) else colorResource(R.color.ewano_card_management_is_enable)
Card(
modifier = Modifier
.fillMaxWidth()
.height(234.dp).padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp)
.graphicsLayer {
this.rotationY = rotationY.value
cameraDistance = 12f * density
}
.pointerInput(isCardEnabled, isCardBackVisible) {
if (isCardEnabled) {
var initialRotationY = 0f
var totalDragAmount = 0f
var rotationChange = 0f
detectHorizontalDragGestures(
onDragStart = {
initialRotationY = rotationY.value
totalDragAmount = 0f
rotationChange = 0f
dragStartRotationY.floatValue = initialRotationY
isCardBackVisibleAtDragStart.value = isCardBackVisible
isDragging.value = true
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
totalDragAmount += dragAmount
rotationChange =
totalDragAmount * CARD_FLIP_END_ROTATION / size.width.toFloat()
val rotationDirection = if (isCardBackVisible) -1f else 1f
val draggedRotationY =
initialRotationY + rotationChange * rotationDirection
coroutineScope.launch {
// Log.i("TAG", "BankimaCard: dragged rotation y ")
Log.i("TAG", "BankimaCard: dragged rotation y : $draggedRotationY")
rotationY.snapTo(draggedRotationY)
}
},
onDragEnd = {
settleCardAfterDrag(rotationChange, initialRotationY)
isDragging.value = false
},
onDragCancel = {
settleCardAfterDrag(rotationChange, initialRotationY)
isDragging.value = false
}
)
}
}
.clickable(enabled = isCardEnabled) {
targetRotationY.floatValue = rotationY.value + CARD_FLIP_END_ROTATION
onCardClick()
},
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = shapeColor
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
if (!isCardEnabled) {
CardBack()
} else {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = if (displayedCardBackVisible) 0f else 1f
}
) {
CardFront()
}
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = if (displayedCardBackVisible) 1f else 0f
this.rotationY = CARD_FLIP_END_ROTATION
}
) {
CardBack()
}
}
}
}
}
@Composable
private fun CardFront() {
Column(modifier = Modifier.fillMaxSize().padding(start = 32.dp, end = 32.dp)) {
Row(modifier = Modifier.padding(top = 8.dp)) {
Image(
modifier = Modifier.size(24.dp).align(Alignment.CenterVertically).rotate(180f),
painter = painterResource(R.drawable.ic_dots_three_vertical_bold_),
contentDescription = "Logo",
)
Spacer(modifier = Modifier.weight(1f))
Image(
modifier = Modifier.size(54.dp).rotate(180f),
painter = painterResource(R.drawable.shetap_logo),
contentDescription = "Logo",
)
}
Row(modifier = Modifier.padding(12.dp)) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = "6037-9918-1234-5678",
color = colorResource(R.color.backgroundColor),
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.iran_yekan_xfa_numb_demi_bold)),
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
text = "سید فرزاد آقائی زاده",
color = colorResource(R.color.backgroundColor),
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.iran_yekan_xfa_num_regular)),
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "07/11",
color = Color.White,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.iran_yekan_xfa_num_regular)),
letterSpacing = 2.sp
)
}
Row(modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)) {
Image(
modifier = Modifier.size(36.dp).alpha(0.6f).graphicsLayer {
scaleX = -1f
},
painter = painterResource(R.drawable.ewano_logo_grey),
contentDescription = "Logo",
)
Spacer(modifier = Modifier.weight(1f))
Image(
modifier = Modifier.size(38.dp).graphicsLayer {
},
painter = painterResource(R.drawable.ic_powered_by_vee),
contentDescription = "Logo",
)
}
}
}
@Composable
private fun CardBack() {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Image(
modifier = Modifier.size(64.dp).align(Alignment.End).rotate(180f),
painter = painterResource(R.drawable.shetap_logo),
contentDescription = "Logo",
)
Image(
modifier = Modifier.size(82.dp).padding(bottom = 24.dp).align(Alignment.CenterHorizontally).graphicsLayer {
scaleX = -1f
},
painter = painterResource(R.drawable.ewano_logo_grey),
contentDescription = "Logo",
)
Spacer(modifier = Modifier.size(16.dp))
}
}