Preamble
- I wanted a button that I could hold down to increase a value. I wanted it to start slow and ramp up speed as the hold persisted.
- If you’re here for the final solution, it can be found at the end.
- This was written assuming the reader has working knowledge of Jetpack Compose.
A Repeating Button
The first issue we run into is that the Jetpack Compose Button doesn’t have any built-in mechanism for triggering onClick events, while in a held state, as part of its API. Seems like we’ll need to build that mechanism ourselves. I’d like to do it by leveraging the Jetpack Compose Button that already exists. You know, reinventing the wheel and all that.
Now, the mechanism! We really don’t want to reimplement a ton of behavior, or mirror Compose’s various internal and private classes, to accomplish this. After spending some time reading docs, consulting StackOverflow, etc., it seemed like I had two options: Modifier.pointerInput and Modifier.pointerInteropFilter.
I first tried pointerInput, but had no luck getting it to work (more on that later). I gave pointerInteropFilter a try, and it was fairly simple to get it to work right off the bat. So, pointerInteropFilter it is… for now.
Notice the pressed variable is a remembered, mutable state, so it will persist across recompositions. Inside the lambda for the interop filter, we update the pressed state as true on ACTION_DOWN, and false otherwise. We could have explicitly specified ACTION_UP as the “unpressed” state, but I want it to cancel on drags, or any other pointer movement, as well.
You’ll also notice we have true as the last line of the block for an implicit return, which represents whether we’re consuming the event or not. The button’s onClick isn’t called until an ACTION_UP event occurs, so it doesn’t really work for our held-button behavior. (also more on this later)
The Repeating Behavior
So, now we can detect when our button is being held down. The next step will be to give it something to do while the button is held. We’ve passed in our own onClick parameter, and we’ve given the button itself an empty onClick lambda. For our repeating behavior, we have a few requirements:
Parameters:
- We should be able to specify the maximum / starting delay
- We should be able to specify the minimum delay
- We should be able to specify how the maximum decays to the minimum
Behavior:
- While it’s held, it should call the
onClickwe passed in. - We should be able to delay between
onClickinvocations. - We should be able to decrease the delay between
onClickcalls over time. - We should stop calling
onClickwhen the button is either disabled, or no longer pressed.
To that end, it sounds like want a LaunchedEffect with a while loop.
Conveniently, we can key the effect to become sensitive to our enabled and pressed parameters so that the effect is canceled and re-launched if either of those values changes.
Inside the LaunchedEffect, we can place a while loop that will call the onClick lambda, delay for a specified amount of time, then modify that delay for use in the next iteration. The end product might look something like this:
Notice our list of parameters now contains maxDelayMillis, minDelayMillis, and delayDecayFactor. You can see how we make use of those in our LaunchedEffect. What’s happening here is pretty simple. Our LaunchedEffect will be canceled and re-launched whenever pressed or enabled changes, and so the previously-running while loop will also be disposed of. A new LaunchedEffect will then start, but unless pressed and enabled are both true, the looping mechanism is skipped, and the LaunchedEffect has run its course.
Inside the while, we call the onClick that was passed in, delay for a set amount of time, and then update the delay value based on our delayDecayFactor, going no lower than our minDelayMillis.
This seems like it should work fine, but there’s a small problem. If you’ve implemented controls that use this repeating button with proper state-hoisting, each call to onClick seems to be using the same values each time. What gives?
When our LaunchedEffect is set in motion, it’s capturing the onClick and using it in each iteration. The onClick captured its own set of data and uses it each time. The problem seems to be that we need to use the updated onClick for every iteration of our while loop. How can we get this to happen? Due to recomposition, our onClick is being frequently re-created with new values, and it’s being fed into our RepeatingButton each time, but the LaunchedEffect is unaware.
You might think that we should add our onClick as a key for the LaunchedEffect, but not quite. If we do that, we’ll never be able to delay properly. As soon as the onClick is triggered, and values are updated, a new onClick will be born, canceling and re-launching our LaunchedEffect, and so the delay that happens after the onClick, along with its decay, will never happen. We’ll get our “held” behavior, but it will be as fast as the while can iterate and Compose can update:

Fast from start to finish
To get our delay-and-decay behavior to work, we can make use of rememberUpdatedState (took me a while to discover that one). Every time recomposition happens, we can take the new onClick and hand it to the compose framework. Our LaunchedEvent can then use the updated value in its while loop, effectively querying the compose framework for the value each time. Something like so:
Job Offers
Notice that in our LaunchedEffect, we’re calling currentClickListener, which is simply val currentClickListener by rememberUpdatedState(onClick). With this, we should be good to go, right?

Slow start, fast finish
What about the Ripple?
The only remaining issue is that we don’t get the ripple effect while using the interop filter. When we consume the event, the underlying Button implementation for applying the ripple effect is never triggered. What can we do about that? Well, remember how my initial approach to this whole undertaking was to use pointerInput instead of pointerInteropFilter, but I had no luck getting the logic to trigger? With pointerInput, I wouldn’t have to consume the upstream event to get the logic to work. Let’s invest the time to see if it’s viable.
After looking through some sparse Compose documentation, I ended up trying out their drag detector. It triggered with no issue, which seemed odd. I dug into its implementation, and it turns out, when you are waiting for a down event on a Compose Button, the events you’ll receive have already been marked as “consumed.” In order to still receive the events via awaitFirstDown() you need to pass false for its requireUnconsumed parameter. Oops. If we refactor things to use pointerInput, we can get everything we want with the following implementation:
Now we’ve got a ripple! This feels more correct to me, or at least less hacky.
You’ll notice we’ve moved the logic that was in our LaunchedEffect into a coroutine within the pointerInput. We need to wrap the implementation with forEachGesture or the logic will only be triggered once, on the first appropriate gesture. The coroutineScope is also necessary if we want to launch a coroutine. We’ve gotten rid of our remembered pressed variable in favor of using the pressed property of our down: PointerInputChange object.
This will work fine, but we could extract this to our own, custom Modifier if we wanted to. Not absolutely necessary, but here it is in case you were curious:
Final Thoughts
I learned quite a bit through this little journey, and I hope it was useful to you as well. So far, I’m enjoying Jetpack Compose quite a lot. In my quest to shove Redux patterns into everything, it’s been a great tool (hope to write more on that later). If this helped you out at all, then it was worth the time to write. 🙂



