Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 

Repository files navigation

Card Flip Animation with Jetpack Compose

This project demonstrates how to build a smooth, gesture-driven card flip animation using Jetpack Compose.

Main challenges

  • 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 alpha instead 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))


    }


}

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors