Blog Infos
Author
Published
Topics
,
Published

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

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 of Interactions. It wraps a Flow<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 a MutableInteractionSource so that we gain access to the emit 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 PressInteractions after our down and up events, and sending them through the InteractionSource. 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 addingModifier.indication to our Modifier chain. You can see we’re passing in our interactionSource and an instance of an Indication. In this case, we’re using the framework-provided Indication factory rememberRipple()Modifier.indication will take care of observing the InteractionSource that we’re piping Interactions through, and drawing the ripple Indication 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 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!

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu