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/InteractionSource
represents an observable pipeline ofInteraction
s. It wraps aFlow<Interaction>
.Indication
represents 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
InteractionSource
parameter to aMutableInteractionSource
so that we gain access to theemit
method. 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
PressInteraction
s 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.indication
to ourModifier
chain. You can see we’re passing in ourinteractionSource
and an instance of anIndication
. In this case, we’re using the framework-providedIndication
factoryrememberRipple()
.Modifier.indication
will take care of observing theInteractionSource
that we’re pipingInteractions
through, and drawing the rippleIndication
for 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 InteractionSource
s and Indication
s in order to gain a ripple effect without relying on the underlying implementation of Button
or Modifier.clickable
. I’d call that a success!