- 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
private classes, to accomplish this. After spending some time reading docs, consulting StackOverflow, etc., it seemed like I had two options:
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.
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
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:
- 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
- While it’s held, it should call the
onClickwe passed in.
- We should be able to delay between
- 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
Conveniently, we can key the effect to become sensitive to our
pressed parameters so that the effect is canceled and re-launched if either of those values changes.
LaunchedEffect, we can place a
while loop that will call the
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
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
enabled changes, and so the previously-running
while loop will also be disposed of. A new
LaunchedEffect will then start, but unless
enabled are both true, the looping mechanism is skipped, and the
LaunchedEffect has run its course.
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
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?
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:
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:
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. 🙂