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.

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.