Swipe Button

A swipe-to-confirm button for Jetpack Compose.

Swipe Button preview
SwipeButton.kt

import androidx.compose.runtime.Composable
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.panwar2001.ideapro.core.icons.arrow_forward_ios
import kotlin.math.roundToInt
@Composable
fun SwipeButton(
    text: String,
    onSwipeComplete: () -> Unit,
    modifier: Modifier = Modifier
) {
    val thumbSize = 56.dp
    val trackHeight = 58.dp
    val cornerRadius = trackHeight / 2

    val trackColor = Color(0xFFF1F3F2)
    val progressColor = Color(0xFF22C55E)
    val thumbColor = Color(0xFF2563EB)

    val density = LocalDensity.current
    BoxWithConstraints(
        modifier = modifier
            .fillMaxWidth()
            .height(trackHeight)
            .clip(CircleShape)
            .background(trackColor)
            .border(
                width = 1.dp,
                color = Color(0xFFE2E8E5),
                shape = CircleShape
            ),
        contentAlignment = Alignment.CenterStart
    ) {

        val trackWidthPx = with(density) { maxWidth.toPx() }
        val thumbSizePx = with(density) { thumbSize.toPx() }

        val maxOffset = (trackWidthPx - thumbSizePx)
            .coerceAtLeast(0f)

        var swipeOffset by remember { mutableFloatStateOf(0f) }

        val progress = remember(swipeOffset, maxOffset) {
            if (maxOffset > 0f) {
                (swipeOffset / maxOffset).coerceIn(0f, 1f)
            } else {
                0f
            }
        }

        val isReadyToComplete = progress >= 0.8f

        /*
         * Smooth movement when:
         * - user releases early
         * - swipe completes
         */
        val animatedOffset by animateFloatAsState(
            targetValue = swipeOffset,
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioMediumBouncy,
                stiffness = Spring.StiffnessMediumLow
            ),
            label = "SwipeOffset"
        )

        /*
         * Smooth progress animation.
         */
        val animatedProgress by animateFloatAsState(
            targetValue = progress,
            animationSpec = tween(
                durationMillis = 100,
                easing = LinearOutSlowInEasing
            ),
            label = "SwipeProgress"
        )

        /*
         * Text fades away as the thumb moves.
         */
        val textAlpha by animateFloatAsState(
            targetValue = when {
                isReadyToComplete -> 1f
                else -> 1f - (progress * 1.25f)
            }.coerceIn(0f, 1f),
            animationSpec = tween(120),
            label = "TextAlpha"
        )

        /*
         * Arrow grows slightly near completion.
         */
        val arrowScale by animateFloatAsState(
            targetValue = if (isReadyToComplete) 1.15f else 1f,
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioMediumBouncy
            ),
            label = "ArrowScale"
        )

        /*
         * Green progress layer.
         *
         * It extends underneath the thumb so the
         * thumb feels like it is pushing the progress.
         */
        Box(
            modifier = Modifier
                .fillMaxHeight()
                .width(
                    with(density) {
                        (thumbSizePx / 1.8f + swipeOffset).toDp()
                    }
                )
                .clip(
                    RoundedCornerShape(cornerRadius)
                )
                .background(progressColor)
        )

        /*
         * Center CTA text.
         */
        Text(
            text = if (isReadyToComplete) {
                "Release to continue"
            } else {
                text
            },
            modifier = Modifier
                .align(Alignment.Center)
                .graphicsLayer {
                    translationX = progress * 18f
                    scaleX = 1f - (progress * 0.04f)
                    scaleY = 1f - (progress * 0.04f)
                }
                .alpha(textAlpha),
            color = if(isReadyToComplete) Color.White else Color(0xFF475569) ,
            fontSize = 16.sp,
            fontWeight = FontWeight.SemiBold
        )

        /*
         * Draggable thumb.
         */
        Box(
            modifier = Modifier
                .offset {
                    IntOffset(
                        animatedOffset.roundToInt(),
                        0
                    )
                }
                .size(thumbSize)
                .shadow(
                    elevation = if (isReadyToComplete) {
                        8.dp
                    } else {
                        4.dp
                    },
                    shape = CircleShape,
                    clip = false
                )
                .clip(CircleShape)
                .background(
                    if (isReadyToComplete) {
                        Color.White
                    } else {
                        thumbColor
                    }
                )
                .draggable(
                    orientation = Orientation.Horizontal,
                    state = rememberDraggableState { delta ->
                        swipeOffset = (
                                swipeOffset + delta
                                ).coerceIn(
                                0f,
                                maxOffset
                            )
                    },
                    onDragStopped = {

                        if (progress >= 0.8f) {

                            swipeOffset = maxOffset

                            onSwipeComplete()

                        } else {

                            swipeOffset = 0f
                        }
                    }
                ),
            contentAlignment = Alignment.Center
        ) {

            Icon(
                imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
                contentDescription = null,
                tint = if (isReadyToComplete) {
                    progressColor
                } else {
                    Color.White
                },
                modifier = Modifier
                    .size(19.dp)
                    .graphicsLayer {
                        scaleX = arrowScale
                        scaleY = arrowScale

                        /*
                         * Arrow becomes slightly more
                         * dynamic as the user swipes.
                         */
                        rotationZ = progress * 8f
                    }
            )
        }
    }
}

}