Single Gesture Interaction between Multiple Composables

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:

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 DropdownMenuItems. 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 DropdownMenuItems 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 PointerEventPasses. 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.


  1. Efeturi tells me this is a pattern that was there on Android in the native view days as well. ↩︎

  2. 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. ↩︎

  3. A combination of PointerInputModifierNode combined with sharePointerInputWithSiblings seemed promising, but actual DropdownMenuItems don’t get pointer events, likely due to being a Popup↩︎

comments powered by Disqus