Recently, I wrote an article on how I implemented a repeating button using Jetpack Compose. After reading the article, a coworker posed the question: “What if I use that modifier on something besides a Button?” Let’s find out.
Let’s start with the custom repeatingClickable from the last article:
| fun Modifier.repeatingClickable( | |
| interactionSource: InteractionSource, | |
| enabled: Boolean, | |
| maxDelayMillis: Long = 1000, | |
| minDelayMillis: Long = 5, | |
| delayDecayFactor: Float = .20f, | |
| onClick: () -> Unit | |
| ): Modifier = composed { | |
| val currentClickListener by rememberUpdatedState(onClick) | |
| pointerInput(interactionSource, enabled) { | |
| forEachGesture { | |
| coroutineScope { | |
| awaitPointerEventScope { | |
| val down = awaitFirstDown(requireUnconsumed = false) | |
| val heldButtonJob = launch { | |
| var currentDelayMillis = maxDelayMillis | |
| while (enabled && down.pressed) { | |
| currentClickListener() | |
| delay(currentDelayMillis) | |
| val nextMillis = currentDelayMillis - (currentDelayMillis * delayDecayFactor) | |
| currentDelayMillis = nextMillis.toLong().coerceAtLeast(minDelayMillis) | |
| } | |
| } | |
| waitForUpOrCancellation() | |
| heldButtonJob.cancel() | |
| } | |
| } | |
| } | |
| } | |
| } |
As an exercise, let’s try to make our own version of theRepeatingButton from the previous article, but this time without using Button under the hood. A Compose Button‘s content parameter is a RowScope extension function, and it feeds that to a Surface under the hood, so let’s just skip the middle-man and make a Surface with a Row that contains some Text. We’ll toss repeatingClickable into the Surface‘s modifier chain and see what happens. We’ll have it update a repeatCount as it’s held, and display that as the button’s content:
| @Preview | |
| @Composable | |
| fun CustomRepeatingButton() { | |
| Box(modifier = Modifier.fillMaxSize()) { | |
| var repeatCount by remember { mutableStateOf(0) } | |
| val interactionSource = remember { MutableInteractionSource() } | |
| Surface( | |
| modifier = Modifier | |
| .repeatingClickable( | |
| interactionSource = interactionSource, | |
| enabled = true, | |
| onClick = { | |
| repeatCount++ | |
| } | |
| ) | |
| .align(Alignment.Center) | |
| ) { | |
| Row { | |
| Text( | |
| modifier = Modifier.padding(6.dp), | |
| text = "Repeat Count: $repeatCount" | |
| ) | |
| } | |
| } | |
| } | |
| } |
Ignore the Box; it’s just there for positioning. The important thing here is the repeatingClickable and the fact that it’s not on a Button. When we hold the not-a-button down, we can see that it’s working exactly as we’d hoped:

Job Offers
Wait a sec… What the hell happened to the ripple effect? We worked so hard to get it back in the previous article, and now we’ve been robbed!
Resurrecting the Ripple
Turns out, we were relying on Button to call Modifier.clickable to get the ripple effect for free. Is there a way we can get it back?
The Easy Way
Well, the fast way would be to just toss an empty .clickable into the Modifier chain and call it a day.
I mean, it’ll work. 🤷♂
The Right Way™
Disclaimer: I don’t know that this is the right way, but it’s decently similar to how a Compose Button implements things, and I learned some things along the way, so I want to share it with you.
There are a couple key players we need to know about before we proceed:
Mutable/InteractionSourcerepresents an observable pipeline ofInteractions. It wraps aFlow<Interaction>.Indicationrepresents the visual effect that indicates an interaction is taking place.
Honestly, not too bad in terms of complexity. All we’re going to do here is determine which Interaction to create when pointer events happen, and shove it through the pipeline. To do that, we’ll need to update our repeatingClickable implementation. Check it out:
| fun Modifier.repeatingClickable( | |
| interactionSource: MutableInteractionSource, | |
| enabled: Boolean, | |
| maxDelayMillis: Long = 1000, | |
| minDelayMillis: Long = 5, | |
| delayDecayFactor: Float = .20f, | |
| onClick: () -> Unit | |
| ): Modifier = composed { | |
| val currentClickListener by rememberUpdatedState(onClick) | |
| pointerInput(interactionSource, enabled) { | |
| forEachGesture { | |
| coroutineScope { | |
| awaitPointerEventScope { | |
| val down = awaitFirstDown(requireUnconsumed = false) | |
| // Create a down press interaction | |
| val downPress = PressInteraction.Press(down.position) | |
| val heldButtonJob = launch { | |
| // Send the press through the interaction source | |
| interactionSource.emit(downPress) | |
| var currentDelayMillis = maxDelayMillis | |
| while (enabled && down.pressed) { | |
| currentClickListener() | |
| delay(currentDelayMillis) | |
| val nextMillis = currentDelayMillis - (currentDelayMillis * delayDecayFactor) | |
| currentDelayMillis = nextMillis.toLong().coerceAtLeast(minDelayMillis) | |
| } | |
| } | |
| val up = waitForUpOrCancellation() | |
| heldButtonJob.cancel() | |
| // Determine whether a cancel or release occurred, and create the interaction | |
| val releaseOrCancel = when (up) { | |
| null -> PressInteraction.Cancel(downPress) | |
| else -> PressInteraction.Release(downPress) | |
| } | |
| launch { | |
| // Send the result through the interaction source | |
| interactionSource.emit(releaseOrCancel) | |
| } | |
| } | |
| } | |
| } | |
| }.indication(interactionSource, rememberRipple()) | |
| } |
There are a few things to notice here:
- We’ve changed our
InteractionSourceparameter to aMutableInteractionSourceso that we gain access to theemitmethod. We were always passing in a mutable instance, but polymorphism let us reference it previously as an immutable instance. - You can see that we’re creating
PressInteractions after our down and up events, and sending them through theInteractionSource. For correctness, we’re determining whether our up event is a release or cancel, but for the ripple effect, I don’t think it really matters. - When we’re creating the up interactions, we need to give it the down interaction it’s paired with (
downPress). - The final ingredient is adding
Modifier.indicationto ourModifierchain. You can see we’re passing in ourinteractionSourceand an instance of anIndication. In this case, we’re using the framework-providedIndicationfactoryrememberRipple().Modifier.indicationwill take care of observing theInteractionSourcethat we’re pipingInteractionsthrough, and drawing the rippleIndicationfor us.

That’s the stuff
Final Thoughts
There you have it. We learned that we can stick our repeatingClickable Modifier onto any Composable to gain the held, repeating functionality. We also learned how to utilize InteractionSources and Indications in order to gain a ripple effect without relying on the underlying implementation of Button or Modifier.clickable. I’d call that a success!



