Glow Swipe to Dismiss

Glow Swipe to Dismiss

Subscribe for Live Previews, in your browser

$3 / month

Code

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Archive
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Egg
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import theme.Blue500
import theme.Blue950
import theme.Red600
import theme.Transparent
import theme.Zinc100
import theme.Zinc200
import theme.Zinc400
import theme.Zinc50
import theme.Zinc500
import theme.Zinc600
import theme.Zinc900

@Composable
fun GlowSwipeToDismiss() {
    val state = rememberSwipeToDismissBoxState()
    val scope = rememberCoroutineScope()
    val willTrigger by remember {
        derivedStateOf {
            state.targetValue != SwipeToDismissBoxValue.Settled
        }
    }

    val haptic = LocalHapticFeedback.current
    LaunchedEffect(willTrigger) {
        haptic.performHapticFeedback(
            if (willTrigger) {
                HapticFeedbackType.LongPress
            } else {
                HapticFeedbackType.SegmentTick
            }
        )
    }

    var dismissed by remember { mutableStateOf(false) }

    SwipeToDismissBox(
        state = state,
        modifier = Modifier.width(300.dp),
        backgroundContent = {
            val color = when (state.dismissDirection) {
                SwipeToDismissBoxValue.StartToEnd -> Blue500
                SwipeToDismissBoxValue.EndToStart -> Red600
                SwipeToDismissBoxValue.Settled -> Transparent
            }
            Box(
                Modifier
                    .fillMaxSize()
                    .border(
                        width = 1.dp,
                        shape = RoundedCornerShape(24.dp),
                        color = color,
                    )
                    .dropShadow(
                        shape = RoundedCornerShape(24.dp)
                    ) {
                        this.color = color
                        radius = 40f
                        alpha = if (willTrigger) .2f else 0f
                    }
                    .background(
                        color = Blue500.copy(
                            alpha = if (willTrigger) .1f else .06f
                        ),
                        shape = RoundedCornerShape(24.dp)
                    )
                    .innerShadow(
                        shape = RoundedCornerShape(24.dp)
                    ) {
                        this.color = color
                        radius = 40f
                        alpha = if (willTrigger) 1f else .2f
                    }
            ) {
                val iconScale by animateFloatAsState(
                    targetValue = if (willTrigger) 1f else .8f,
                    animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
                )
                val slide by animateDpAsState(
                    targetValue = if (!willTrigger)
                        when (state.dismissDirection) {
                            SwipeToDismissBoxValue.StartToEnd -> -10.dp
                            SwipeToDismissBoxValue.EndToStart -> 10.dp
                            SwipeToDismissBoxValue.Settled -> 0.dp
                        }
                    else 0.dp,
                    animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
                )
                Icon(
                    imageVector = when (state.dismissDirection) {
                        SwipeToDismissBoxValue.StartToEnd -> Icons.Rounded.Archive
                        SwipeToDismissBoxValue.EndToStart -> Icons.Rounded.Delete
                        SwipeToDismissBoxValue.Settled -> Icons.Rounded.Egg
                    },
                    contentDescription = null,
                    modifier = Modifier
                        .graphicsLayer {
                            scaleX = iconScale
                            scaleY = iconScale
                            translationX = slide.toPx()
                        }
                        .align(
                            when (state.dismissDirection) {
                                SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
                                SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd
                                SwipeToDismissBoxValue.Settled -> Alignment.Center
                            }
                        )
                        .aspectRatio(1f)
                        .fillMaxHeight()
                        .padding(28.dp),
                    tint = Zinc50,
                )
            }
        },
        onDismiss = {
            scope.launch {
                dismissed = true
                delay(2000)
                dismissed = false
                state.reset()
            }
        },
        content = {
            val alpha by animateFloatAsState(
                targetValue = if (dismissed) 0f else 1f,
                animationSpec = spring(stiffness = Spring.StiffnessMedium)
            )
            val scale by animateFloatAsState(
                targetValue = if (willTrigger) .95f else 1f
            )
            AnimatedCustomClickArea(
                Modifier.alpha(alpha = alpha)
                    .graphicsLayer {
                        this.alpha = alpha
                        this.scaleX = scale
                        this.scaleY = scale
                    }
            )
        }
    )
}


@Composable
private fun AnimatedCustomClickArea(modifier: Modifier = Modifier) {
    var selected by remember { mutableStateOf(false) }
    val interaction = remember { MutableInteractionSource() }
    val isHovered by interaction.collectIsHoveredAsState()
    val isPressed by interaction.collectIsPressedAsState()

    val selectedAnimation by animateFloatAsState(
        targetValue = if (selected) 1f else 0f,
        animationSpec = spring(stiffness = Spring.StiffnessLow)
    )
    val focusAnimation by animateFloatAsState(
        targetValue = when {
            isPressed -> 1f
            isHovered -> .5f
            else -> 0f
        }
    )
    Row(
        modifier = modifier
            .hoverable(interaction)
            .clickable(
                interactionSource = interaction,
                indication = null
            ) {
                selected = !selected
            }
            .fillMaxWidth()
            .background(
                color = Zinc900,
                shape = RoundedCornerShape(24.dp)
            )
            .background(
                color = Blue950.copy(alpha = lerp(0f, .4f, selectedAnimation)),
                shape = RoundedCornerShape(24.dp)
            )
            .innerShadow(
                shape = RoundedCornerShape(24.dp)
            ) {
                color = Blue500
                radius = lerp(0f, 40f, focusAnimation)
                alpha = lerp(0f, 1f, focusAnimation)
            }
            .border(
                width = 1.dp,
                brush = Brush.verticalGradient(
                    colors = listOf(
                        Zinc100.copy(alpha = .3f),
                        Zinc100.copy(alpha = .1f),
                    )
                ),
                shape = RoundedCornerShape(24.dp)
            )
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(
            modifier = Modifier
                .size(56.dp)
                .background(
                    color = if (selected) Blue950 else Zinc600,
                    shape = RoundedCornerShape(8.dp)
                )
                .clip(RoundedCornerShape(8.dp))
                .clickable {
                    selected = !selected
                },
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.Center,
        ) {
            AnimatedVisibility(
                visible = selected,
                enter = scaleIn(
                    initialScale = .1f,
                    animationSpec = spring(
                        dampingRatio = Spring.DampingRatioMediumBouncy,
                        stiffness = Spring.StiffnessLow,
                    )
                ) + fadeIn(),
                exit = scaleOut(targetScale = .1f) + fadeOut(),
            ) {
                Icon(
                    imageVector = Icons.Rounded.CheckCircle,
                    contentDescription = null,
                    tint = Blue500,
                    modifier = Modifier
                        .rotate(lerp(30f, 0f, selectedAnimation))
                        .align(Alignment.CenterVertically)
                )
            }
        }
        Spacer(Modifier.width(10.dp))
        Column(
            modifier = Modifier.weight(1f)
        ) {
            Text(
                text = "New UI Recipe!",
                color = Zinc200
            )
            Spacer(Modifier.height(4.dp))
            Text(
                text = "Swipe to archive",
                color = Zinc400
            )
        }
        Spacer(Modifier.width(8.dp))
        Text(
            text = "16:00",
            color = Zinc500
        )
    }
}
Mastodon