Single Gesture Interaction between Multiple Composables
Jul 1, 2024 · 8 minute read · Commentscode
composekotlin
Introduction
Recently, I spent some time trying to replicate a gesture I liked on iOS1. I had a simple button where, if you long press it, a menu shows up from which an item can be selected. On the iOS app, you could select an item from this menu in a single gesture. By long pressing the button and not letting go, you could drag your finger to the item and select a menu item in one gesture.
In this video, you can see this behavior in action:
- second 1 - normal press on the button does an action
- seconds 2-7 - long press on the button shows the menu. clicking an item selects it.
- seconds 8-13 - use the gesture to simplify the second portion, by long pressing and selecting in a single gesture.
Implementation
Base Implementation
Let’s start with a simple implementation of the button and the menu.
@Composable
fun DropDownExample(modifier: Modifier) {
val showMenu = remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Box {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
tint = Color.DarkGray,
modifier = Modifier.combinedClickable(
onLongClick = { showMenu.value = true },
) { println("onClick") }
)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
) {
(1..3).forEach { i ->
DropdownMenuItem(
text = { Text("Item $i") },
onClick = {
println("item $i clicked")
showMenu.value = false
}
)
}
}
}
}
}
Nothing particularly noteworthy here, and with just a few lines of code, we have the first half of the video mostly implemented.
Adding the Gesture
Let’s start by adding a Modifier.pointerInput
to the Column
wrapping the button and its menu:
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> println("onDragStart: $offset") },
onDrag = { change, offset -> println("onDrag: $change, $offset") },
onDragCancel = { println("onDragCancel") },
onDragEnd = { println("onDragEnd") }
)
}
) {
// ...
}
If we run this, we’ll see an onDragStart
, followed by a onDragCancel
. This is because we have a long press on the Icon
itself that conflicts with this drag gesture. Since this gesture detects long press for us, we’ll move the long press handling here to avoid the conflict2.
We’ll switch Modifier.combinedClickable
with just Modifier.clickable
, and move the showMenu.value = true
into onDragStart
. If we do this, we’ll see what the drag events we expect to see after seeing the onDragStart
, and we’ll see onDragEnd
once the drag is complete.
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> showMenu.value = true },
onDrag = { change, offset -> println("onDrag: $change, $offset") },
onDragCancel = { println("onDragCancel") },
onDragEnd = { println("onDragEnd") }
)
}
) {
Box {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
tint = Color.DarkGray,
modifier = Modifier.clickable { /* some action on click */ }
)
DropdownMenu(/* ... */)
}
}
Handling the Gesture
Ideally, we’d love to have the touch handling “transfer” to the DropdownMenu
once we show it. Unfortunately, I couldn’t find a way to make this work, most likely due to the fact that the DropdownMenu
is a Popup
under the hood3. Consequently, we’ll have to handle the drag gesture calculations ourselves.
To do this, what we need is to know where the gesture is released, along with knowing the bounds of the DropdownMenuItem
s. With the combination of these, we can determine which item, if any, was selected, and perform the action.
We can add an onGloballyPositioned
modifier to each item and to the box surrounding the menu itself. If we do this initially and just print the values, we’ll notice that the values we get for the position of the items are relative to the DropdownMenu
itself, irrespective of whether we use boundsInRoot()
, or boundsInWindow()
. This is because the DropdownMenu
is a Popup
. In other words, the DropdownMenu
’s origin is at (0, 0)
.
We know, however, that the DropdownMenu
takes the position of the anchor it’s attached to. We can use this information to calculate the actual position on the screen to detect whether or not there is a hit. We do this by recording the position of the Box
wrapping the anchor and the DropdownMenu
.
val menuAnchorBounds = remember { mutableStateOf(Rect.Zero) }
val items = remember { mutableStateOf(arrayOf(Rect.Zero, Rect.Zero, Rect.Zero)) }
// <snip>
Box(
modifier = Modifier.onGloballyPositioned {
menuAnchorBounds.value = it.boundsInParent()
}
) {
Icon(/* ... */)
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false }
) {
(1..3).forEach { i ->
DropdownMenuItem(
text = { Text("Item $i") },
onClick = { println("item $i clicked"); showMenu.value = false },
modifier = Modifier.onGloballyPositioned {
items.value = items.value.apply { set(i - 1, it.boundsInParent()) }
}
)
}
}
}
With this, the remaining piece is to figure out, in onDragEnd
, which item the drag ended at (if any). Since onDragEnd
does not have any parameters about the ending position, we’ll have to store the position as it updates in onDrag
. Afterwards, we can use this information in onDragEnd
to check for a hit, in combination with the bounds of the items and the menu stored above.
@Composable
fun DropdownExample(modifier: Modifier) {
// <snip>
val lastPosition = remember { mutableStateOf(Offset.Unspecified) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> showMenu.value = true },
onDrag = { change, offset -> lastPosition.value = change.position },
onDragCancel = { println("onDragCancel") },
onDragEnd = {
val upPosition = lastPosition.value
val adjustedTargetLocation = upPosition.minus(menuAnchorBounds.value.bottomLeft)
val match = items.value.indexOfFirst { adjustedTargetLocation in it }
if (match != -1) {
println("Item ${match + 1} clicked")
showMenu.value = false
} else {
println("No button clicked")
}
}
)
}
) {
// <snip>
}
}
We take the last position of the drag, and adjust it by the position of the menu’s anchor, in order to get positions relative to the DropdownMenu
. In this case, because the menu opens anchoring to the bottom left of the anchor, we use that as the offset on the last drag position. We can then check for a match amongst our items. This works! 🎉
Improvements: Adding a Hover Effect
This works, but we no longer have any hover effect on each item when we drag over them. The DropdownMenuItem
composable accepts an interactionSource
, based on which it renders the hover effect. We can emit and memoize a PressInteraction.Press(pressPosition)
when an item is hit, and emit a PressInteraction.Release(pressInteraction)
when the item is no longer hovered over. We can do this within onDrag
itself, though we need to explicitly map the position of the drag to the corresponding item, in a similar way to how we detected the item in onDragEnd
.
// we need an interaction source per drop down menu item, since if we share them,
// we'd get all items selecting and unselecting together, irrespective of the position.
val interactionSources =
remember { arrayOf(MutableInteractionSource(), MutableInteractionSource(), MutableInteractionSource()) }
val lastInteractionItem = remember { mutableIntStateOf(-1) }
val lastInteractionPress = remember { mutableStateOf(Offset.Unspecified) }
// snip
onDrag = { change, dragAmount ->
lastPosition.value = change.position
val adjustedTargetLocation = change.position.minus(menuAnchorBounds.value.bottomLeft)
val currentItem = items.value.indexOfFirst { adjustedTargetLocation in it }
// if the item we are hovering over has changed...
if (currentItem != lastInteractionItem.intValue) {
// if it wasn't -1, we need to "release" the press on the old one
if (lastInteractionItem.intValue != -1) {
val interactionSource = interactionSources[lastInteractionItem.intValue]
interactionSource.tryEmit(
PressInteraction.Release(PressInteraction.Press(lastInteractionPress.value))
)
}
// if we have a chosen item, we need to "press" on the new one
if (currentItem != -1) {
val interactionSource = interactionSources[currentItem]
interactionSource.tryEmit(PressInteraction.Press(adjustedTargetLocation))
lastInteractionPress.value = adjustedTargetLocation
}
lastInteractionItem.intValue = currentItem
}
}
We’d then update our DropdownMenuItem
s to use the corresponding interactionSources[index]
.
Improvements: Fixing the unwanted Click bug
If we try this out, we notice everything works great, with one exception: if we long press the anchor and don’t drag at all, the menu shows up, but the “click” handle is also fired on the button, which isn’t what we want. If we drag, however, everything is fine. How do we fix this?
As the documentation tells us, there are three PointerEventPass
es. We are interested in the PointerEventPass.Initial
pass, which, as the documentation says:
In the Initial pass, the event flows from the top of the UI tree to the bottom. This flow allows a parent to intercept an event before the child can consume it.
This seems to be what we want. Moreover, The code also links an example of this in the implementation of the Tooltip
in Material 3. Notice how, in lines 228 to 229 of the implementation, PointerEventPass.Initial
pointer events are consumed, preventing them from being actioned by the children. We can see something similar to this in the detectDragGesturesAfterLongPress code as well (though without the pass specification, and only after a drag, which is why this issue only appears if we don’t drag).
The easiest way to fix this is to copy the detectDragGesturesAfterLongPress
code, and consume the PointerEventPass.Initial
events after calling the onDragStart
lambda by adding these two lines after the onDragStart.invoke(drag.position)
call:
// consume the children's click handling
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
event.changes.forEach { if (it.changedToUp()) it.consume() }
Conclusion
Early on, I came across this artice about multiselection within a photo grid, which pointed me in the right direction after getting stuck with trying to find a way to “hand off” events to the menu. This was a fun experiment for something that, admittedly, is not likely to be discovered or used much in my app. The journey did help me increase my understanding of how touch is handled in Compose.
Special thanks to Efeturi for reviewing this post.
-
Efeturi tells me this is a pattern that was there on Android in the native view days as well. ↩︎
-
The click will also conflict, but we’ll resolve that later by consuming those gestures in
PointerEventPass.Initial
so that the child does not receive nor act on them. ↩︎ -
A combination of
PointerInputModifierNode
combined withsharePointerInputWithSiblings
seemed promising, but actualDropdownMenuItem
s don’t get pointer events, likely due to being aPopup
. ↩︎