How to apply a ripple to an rectangle drawn in Jetpack compose's canvas?

548 Views Asked by At

I have a sample composable function that's contains a rectangle drawn in a Canvas composable. The detectTapGestures within the pointerInput Modifier is used to detect whenever any point in the Canvas is touched, specifically within the rectangle coordinates.

@Composable
fun Sample() {
    var rectangleCoordinates by remember{ mutableStateOf(Rect.Zero) }
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTapGestures {
                    if (rectangleCoordinates.contains(it)) {
                        Log.d(topBarTag, "rectangle touched")
                    }
                }
            }

    ){
        val rectSize = Size(40.dp.toPx(), 40.dp.toPx())
        rectangleCoordinates = Rect(center, rectSize)
        drawRect(
            topLeft = center,
            size = rectSize,
            color = Color.Blue
        )
    }
}

Is there a way to show a ripple on the rectangle when it's touched?

1

There are 1 best solutions below

0
On

First, i changed from tap to awaitFirstDown to detect initial contact and to waitForUpOrCancellation to detect when user lifts last pointer or moves out of Canvas coordinates. You can change waitForUpOrCancellation with custom behavior. I explain in this answer how to create your own onTouchDown implentation with Jetpack Compose. You can break loop with contains check if you wish to

Second, we need to animate radius and color of circle from touch position.

val animatableAlpha = remember { Animatable(0f) }
val animatableRadius = remember { Animatable(0f) }

var touchPosition by remember { mutableStateOf(Offset.Unspecified) }

var isTouched by remember { mutableStateOf(false) }

val coroutineScope = rememberCoroutineScope()

isTouched to detect whether we touched rect so we can play a reverse animation for canceling ripple.

clipRect is for bounding ripple to Canvas or your rectangle coordinates. You can customize color, alpha limit and more importantly keyFrames to have better ripple version if you wish to

@Composable
private fun RippleOnCanvasSample() {

    var rectangleCoordinates by remember { mutableStateOf(Rect.Zero) }
    val animatableAlpha = remember { Animatable(0f) }
    val animatableRadius = remember { Animatable(0f) }

    var touchPosition by remember { mutableStateOf(Offset.Unspecified) }

    var isTouched by remember { mutableStateOf(false) }

    val coroutineScope = rememberCoroutineScope()

    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {

                val size = this.size
                val radius = size.width.coerceAtLeast(size.height) / 2

                forEachGesture {
                    awaitPointerEventScope {

                        val down: PointerInputChange = awaitFirstDown(requireUnconsumed = true)

                        val position = down.position

                        if (rectangleCoordinates.contains(position)) {

                            touchPosition = position

                            coroutineScope.launch {
                                animatableAlpha.animateTo(
                                    targetValue = .3f,
                                    animationSpec = keyframes {
                                        durationMillis = 150
                                        0.0f at 0 with LinearOutSlowInEasing
                                        0.2f at 75 with FastOutLinearInEasing
                                        0.25f at 100
                                        0.3f at 150
                                    }
                                )
                            }

                            coroutineScope.launch {
                                animatableRadius.animateTo(
                                    targetValue = radius.toFloat(),
                                    animationSpec = keyframes {
                                        durationMillis = 150
                                        0.0f at 0 with LinearOutSlowInEasing
                                        radius * 0.4f at 30 with FastOutLinearInEasing
                                        radius * 0.5f at 75 with FastOutLinearInEasing
                                        radius * 0.7f at 100
                                        radius * 1f at 150
                                    }
                                )
                            }

                            isTouched = true
                        }

                        waitForUpOrCancellation()

                        if (isTouched && touchPosition.isSpecified && touchPosition.isFinite) {
                            coroutineScope.launch {
                                animatableAlpha.animateTo(
                                    targetValue = 0f,
                                    animationSpec = tween(150)
                                )
                                animatableRadius.snapTo(0f)
                            }
                        }

                        isTouched = false
                    }
                }
            }

    ) {
        val rectSize = Size(150.dp.toPx(), 150.dp.toPx())

        rectangleCoordinates = Rect(center, rectSize)

        drawRect(
            topLeft = center,
            size = rectSize,
            color = Color.Cyan
        )

        if (touchPosition.isSpecified && touchPosition.isFinite) {
//            clipRect(
//                left = rectangleCoordinates.left,
//                top = rectangleCoordinates.top,
//                right = rectangleCoordinates.right,
//                bottom = rectangleCoordinates.bottom
//            ) {
            drawCircle(
                center = touchPosition,
                color = Color.Gray.copy(alpha = animatableAlpha.value),
                radius = animatableRadius.value
            )
        }
//        }
    }
}

Result

The rectangle on top is actual ripple with Modifier.clickable

Box(modifier = Modifier
    .size(150.dp)
    .background(Color.Cyan)
    .clickable(
        interactionSource = MutableInteractionSource(),
        indication = rememberRipple(
            bounded = false,
            radius = 300.dp
        ),
        onClick = {

        }
    )
)

Second one is the one from Canvas.

enter image description here

To complete circle ripple animations you can turn each into a class add to a class when pressed and run animations from a list and remove each one when animation is over or pointer is up to full length ripples unlike in Canvas that stops propagating because animatable.animateTo cancels previous one.