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
onClick
we passed in. - We should be able to delay between
onClick
invocations. - We should be able to decrease the delay between
onClick
calls over time. - We should stop calling
onClick
when 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. 🙂