Click, Long Press, Drag, Swipe, and Gesture Detection in Compose

Quick answer: Prefer a Material component or high-level modifier first:
Buttonfor actions,clickablefor a custom tap target,combinedClickablefor tap plus long press,draggablefor continuous one-axis movement, andanchoredDraggablefor a control that settles between known states. UsepointerInputonly when no existing modifier describes the gesture—and add the semantics and keyboard alternative yourself.
Compose turns raw pointer events into useful interactions, but the lowest-level API is not automatically the best one. A custom detector may recognize a gesture, yet omit ripple feedback, focus, keyboard activation, and accessibility semantics that a standard component already provides.
Pick the highest-level API that fits
| Need | Prefer | Why |
|---|---|---|
| Invoke a primary action | Material Button | Includes button semantics, focus, and interaction feedback |
| Make a custom surface tappable | Modifier.clickable | Adds click semantics, ripple, hover, focus, and keyboard support |
| Tap, long press, or double tap | Modifier.combinedClickable | Supplies those interactions with accessibility labels |
| Move continuously on one axis | Modifier.draggable | Reports drag deltas for a horizontal or vertical control |
| Settle at known positions | Modifier.anchoredDraggable | Models anchors and settling behavior |
| Recognize a truly custom gesture | Modifier.pointerInput | Gives access to gesture detectors/raw events, with more responsibility |
Android’s gesture overview makes the same recommendation: favor component and modifier support before custom pointer handling because higher levels bring tested focus and accessibility behavior with them.
Use clickable for one custom action
For a card, row, or custom surface that performs one action, use clickable and give the interaction a precise label. Modifier order matters: padding placed after clickable becomes part of the tap target.
@Composable
fun ArticleRow(
article: ArticleUi,
onOpen: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClickLabel = stringResource(R.string.open_article),
role = Role.Button,
onClick = { onOpen(article.id) },
)
.padding(16.dp),
) {
ArticleSummary(article = article)
}
}The Compose modifier guide explains this order: padding after clickable enlarges the clickable area; padding before it does not. The accessibility defaults guide also notes that clickable merges descendant semantics, so a card with a title and summary is announced as one logical target.
Use a real Button when the visual and semantic concept is a button. Role.Button is helpful for a custom surface, but it is not a reason to rebuild a standard button from scratch.
Add a long press with combinedClickable
combinedClickable is appropriate when a primary open action and a secondary long-press action both make sense—for example, opening a note on tap and showing its context menu on long press.
@Composable
fun NoteRow(
note: NoteUi,
onOpen: (String) -> Unit,
onShowActions: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClickLabel = stringResource(R.string.open_note),
onLongClickLabel = stringResource(R.string.show_note_actions),
onClick = { onOpen(note.id) },
onLongClick = { onShowActions(note.id) },
)
.padding(16.dp),
) {
NoteSummary(note = note)
}
}The combinedClickable reference supports click, long click, and double click, plus their accessibility labels. Do not hide an indispensable action behind a long press: it is a useful shortcut, not a discoverable replacement for a visible menu button.
Understand drag: detection does not move content
draggable is the high-level API for a one-axis drag. It reports a delta in pixels; it does not move the composable on its own. The state holder decides how that delta changes UI state, and the UI renders that state using layout or drawing.
@Composable
fun TimelineHandle(
offsetPx: Float,
onDragBy: (Float) -> Unit,
) {
val draggableState = rememberDraggableState { delta ->
onDragBy(delta)
}
Box(
modifier = Modifier
.offset { IntOffset(offsetPx.roundToInt(), 0) }
.size(32.dp)
.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
),
)
}This is an illustrative UI snippet: the screen state holder should clamp the offset to the timeline bounds and map it to a domain value. The drag, swipe, and fling guide confirms that draggable reports pixel distance and that the app must represent the movement itself.
For a visual-only motion value, read the offset in the offset { ... } lambda as shown. This defers the state read to layout rather than forcing a full recomposition for every drag frame. Keep conversion, snapping rules, and persistence outside the composable.
Use anchoredDraggable for discrete swipe states
When a surface must settle at named positions—closed/open, hidden/visible, or start/center/end—use Foundation’s anchoredDraggable instead of deprecated swipeable APIs. The state tracks anchors; you apply its offset to the content.
enum class DrawerValue { Closed, Open }
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EdgePanel(
state: AnchoredDraggableState<DrawerValue>,
) {
Box(
modifier = Modifier
.offset { IntOffset(state.requireOffset().roundToInt(), 0) }
.anchoredDraggable(
state = state,
orientation = Orientation.Horizontal,
),
) {
PanelContent()
}
}AnchoredDraggableState must be configured with the control’s anchors, usually after its size is known. The current API reference says that on drag end the offset animates to an anchor and the state value updates when that anchor is reached. The older swipeable APIs were replaced by anchoredDraggable; Android’s migration guide also marks the successor experimental, so check your Foundation version before adopting it.
For common patterns such as a dismissible card, drawer, or bottom sheet, first look for an existing Material or Foundation component. Building the gesture state from scratch is justified only when the interaction is genuinely custom.
Use pointerInput for a custom gesture only
pointerInput is for gestures that high-level modifiers do not express, such as drag-after-long-press or a custom multi-pointer interaction. Gesture detectors are often top-level suspend functions, so place one detector in each pointerInput block.
@Composable
fun ReorderHandle(
itemId: String,
onDragStart: (String) -> Unit,
onDragBy: (String, Offset) -> Unit,
onDragEnd: () -> Unit,
) {
Box(
modifier = Modifier.pointerInput(itemId) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart(itemId) },
onDrag = { change, dragAmount ->
change.consume()
onDragBy(itemId, dragAmount)
},
onDragEnd = onDragEnd,
onDragCancel = onDragEnd,
)
},
) {
ReorderIcon()
}
}The pointer-input documentation calls detectors such as detectTapGestures and detectDragGestures lower-level options that do not include the high-level extras. That means a custom reorder gesture needs a visible handle, state feedback, and an alternative way to reorder for keyboard and assistive-technology users.
Use a meaningful key for pointerInput. When that key changes, Compose restarts the pointer-input coroutine with the new callback/data context. Do not key it with unstable objects that change each recomposition unless restarting is intended.
Gesture conflicts and event consumption
Gesture recognizers share the same pointer stream. A vertical list wants drag events for scrolling; a row inside it might want horizontal swipe; a child might consume events after recognizing a custom gesture. Test the complete hierarchy, not just the isolated item.
Practical rules:
- Prefer
LazyColumnscrolling,Slider, buttons, and other built-in behavior instead of intercepting their pointer input. - Keep horizontal and vertical responsibilities distinct where possible.
- Consume events only after your custom interaction has truly claimed them; consuming too early can break parent scrolling or child clicks.
- Do not stack several gesture detectors in one
pointerInputblock and expect them all to run; use separate blocks or deliberately coordinate lower-level events.
The gesture overview details hit testing and pointer-event dispatch. Its core lesson is useful in practice: custom input participates in a hierarchy, not in isolation.
Accessibility and testing checklist
- Give every standard action a visible, keyboard-accessible path; long press and swipe are shortcuts.
- Use
onClickLabel,onLongClickLabel,role, and string resources for custom interactive surfaces. - Use
CustomAccessibilityActionwhen a complex gesture needs a screen-reader alternative. - Keep tap targets large enough and test with TalkBack, keyboard, D-pad, mouse, and touch.
- Test cancellation, drag outside bounds, nested scrolling, different screen densities, and state restoration.
Android’s accessible-composables guidance specifically recommends clickable/toggleable for built-in semantics and CustomAccessibilityAction for complex touchscreen gestures. Pair this with the focus-management guide when an interaction must work beyond touch.
Common mistakes
Replacing a button with raw pointer detection
detectTapGestures can detect a tap, but it does not make the element keyboard-focusable or automatically announce its role. Use Button or clickable unless custom pointer behavior is essential.
Expecting draggable to animate or reposition content
It reports deltas only. Render the offset from state and choose a settling policy outside the visual composable.
Using deprecated swipeable for new controls
New custom anchored controls should use anchoredDraggable where its experimental status and your Foundation version are acceptable. Prefer existing higher-level components whenever possible.
Giving a swipe-only delete action no alternative
Make deletion visible through a menu, button, or accessibility action as well. Swipe is fast for experienced users but not universal.
FAQ
When should I use detectTapGestures?
Only when clickable or combinedClickable cannot express the interaction, such as a position-sensitive press. Add semantics and non-touch alternatives yourself.
Is anchoredDraggable a drop-in replacement for swipeable?
No. It provides the anchor-based foundation, but state setup and APIs differ. Follow Android’s migration guide and verify the Foundation version in your project.
Can I use a long press for a required action?
No. Keep a visible, discoverable action too. Long press is best as a convenience shortcut or contextual action.
Summary
Use the most semantic Compose interaction available, keep gesture effects in screen state, and reserve pointerInput for genuine custom input. A gesture is successful only when it is discoverable, cancellable, accessible, and behaves correctly alongside scrolling and focus.