Gestures play a pivotal role in user interaction, offering a means to navigate, select, and manipulate elements within an app.
From simple taps and swipes to complex pinch-to-zoom and multi-touch gestures, Jetpack Compose provides a robust framework to seamlessly integrate these interactions into your application’s UI.
In this comprehensive guide, we embark on a deep dive into Jetpack Compose’s gesture-handling capabilities. We’ll explore everything you need to know, from the basics of detecting taps to advanced techniques like handling fling effects and tracking interactions manually.
We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!
In compose, any kind of user input that interacts with the screen is called a Pointer. From tapping on the screen to moving the finger and releasing the tap by figure up is the gesture. Jetpack Compose provides a wide API range to handle gestures. Let’s divide it into modifiers and Detectors.
Modifier.pointerInput()
is for processing the raw pointer inputs or we can say events.
Compose provides inbuilt recognizers to detect specific gestures in the pointerInput
modifier. These detectors detect the specific movement in PointerInputScope
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = {}, onDoubleTap = {},
onLongPress = {}, onPress = {})
detectDragGestures(onDrag = { change, dragAmount -> },
onDragStart = {}, onDragEnd = {},
onDragCancel = {})
detectHorizontalDragGestures(onHorizontalDrag = {change, dragAmount -> },
onDragStart = {}, onDragEnd = {}, onDragCancel = {})
detectVerticalDragGestures(onVerticalDrag = {change, dragAmount -> },
onDragStart = {},onDragEnd = {}, onDragCancel = {})
detectDragGesturesAfterLongPress(onDrag = { change, dragAmount -> },
onDragStart = {}, onDragEnd = {},onDragCancel = {})
detectTransformGestures(panZoomLock = false,
onGesture = {
centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
})
}
detectTapGestures(...)
: detect different touch gestures like taps, double-taps, and long presses.
Keep in mind:
detectDragGestures(...)
: detect dragging gestures, like when a user swipes or drags something on the screen.
onDragStart
function is called. It provides the initial touch position.onDrag
function is called repeatedly. It provides information about how much the user has moved their finger (the dragAmount
) and where their finger currently is (the change object).onDragEnd
function is called.onDragCancel
function is called, indicating that the drag gesture was cancelled.detectHorizontalDragGestures(...)
: it same detectDragGestures
but ensures that the drag is only detected if the user moves their finger horizontally beyond a certain threshold (touch slop).
detectVerticalDragGestures(...)
: ensures that the drag is only detected if the user moves their finger vertically.
detectDragGesturesAfterLongPress(...)
: This function ensures that the drag is detected only after a long press gesture. It consumes all position changes after the long press, meaning it tracks the finger’s movement until the user releases their touch.
detectTransformGestures(...)
: detect multi-touch gestures like rotation, panning (moving), and zooming (scaling) on an element.
Compose has some Modifier that is built on top of the low-level pointer input modifier with some extra functionalities. Here’s the list of all available Modifiers that we can directly apply to the composable without PointerInput
Modifier.clickable(onClick: () -> Unit)
Modifier.combinedClickable(
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
)
Modifier.draggable(state: DraggableState, orientation: Orientation, ...)
Modifier.anchoredDraggable(state: AnchoredDraggableState<T>,
orientation: Orientation, ...
)
Modifier.scrollable( state: ScrollableState, orientation: Orientation, ...)
Modifier.horizontalScroll(state: ScrollState, ...)
Modifier.verticalScroll( state: ScrollState, ...)
Modifier.transformable(state: TransformableState, ...)
You might have questioned why would we use gesture detectors or gesture Modifiers.
When we’re using detectors we’re purely detecting the events, whereas the modifiers, along with handling the events, contain more information than a raw pointer input implementation.
We should choose between them based on the complexity and specificity of touch interactions.
Now let’s see the use cases of these gesture detectors and modifiers.
Many Jetpack Compose elements, such as Button
, IconButton
, come with built-in support for click-or-tap interactions. These elements are designed to be interactive by default, so you don't need to explicitly add a clickable
modifier or gesture detection code. You can simply use these composable elements and provide the action to be executed when they are clicked or tapped.
Button(onClick = { /* Handle click action here*/ }) { Text("Click!") }
The Modifier.clickable {}
modifier is a simple and convenient way to handle tap gestures in Jetpack Compose. You apply it to a composable element like Box
, and the lambda inside clickable
is executed when the element is tapped.
With clickable
Modifier, we can also add additional features such as interaction source, visual indication, focus, hovering etc. Let’s see an example where we change the corner radius based on the interaction source.
val interactionSource = remember { MutableInteractionSource() }
val isPressed = interactionSource.collectIsPressedAsState()
val cornerRadius by animateDpAsState(targetValue = if (isPressed.value) 10.dp else 50.dp)
Box(
modifier = Modifier
.background(color = pink, RoundedCornerShape(cornerRadius))
.size(100.dp)
.clip(RoundedCornerShape(cornerRadius))
.clickable(
interactionSource = interactionSource,
indication = rememberRipple()
) {
//Clicked
}
.padding(horizontal = 20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Click!",
color = Color.White
)
}
It’s used to handle double click or long click alongside the single click. Same as clickable
Modifier, we can provide interaction source, an indication to combinedClickable
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
.combinedClickable(
onClick = { /* Handle click action here */},
onDoubleClick = { /* Handle double click here */ },
onLongClick = { /* Handle long click here */ },
)
)
Detect the raw input events.
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
.pointerInput(Unit) {
detectTapGestures(
onTap = { /* Handle tap here */ },
onDoubleTap = {/* Handle double tap here */ },
onLongPress = { /* Handle long press here */ },
onPress = { /* Handle press here */ }
)
}
)
Now you might have a question, what if we use all the above together?
If we apply all to the same composable, then the first modifier in the chain will be replaced by a later one. If we specify clickable
first and then combinedClickable
we’ll get a tap event in combinedClickable
instead of clickable
Modifier.
For more details refer to this official documentation.
To detect drag we can use either Modifier.draggable
, or detectDragGesture
also, experimental Modifier.anchoredDraggable
API with anchored states like swipe-to-dismiss. Let’s see them one by one.
Create a UI element that can be dragged in one direction (like left and right or up and down) and measure how far it’s dragged. Common for sliders or draggable components.
@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.padding(top = 20.dp)
.graphicsLayer {
this.translationX = offsetX
}
.draggable(
state = rememberDraggableState {delta ->
offsetX += delta
}, orientation = Orientation.Horizontal
)
.size(50.dp)
.background(Color.Blue)
)
Text(text = "Offset $offsetX")
}
}
anchoredDraggable
allows to drag content in one direction horizontally or vertically. This experimental API was recently introduced in 1.6.0-alpha01
it’s a replacement of Modifier.swipeable()
It has two important parts, AnchoredDraggableState
and Modifier.anchoredDraggable
. AnchoredDraggableState
hold the state of dragging. TheanchoredDraggable
modifier is built on top of Modifier.draggable()
.
When a drag is detected, it updates the offset of the AnchoredDraggableState
with the drag's delta (change). This offset can be used to move the UI content accordingly. When the drag ends, the offset is smoothly animated to one of the predefined anchors, and the associated value is updated to match the new anchor.
Here’s a simple example,
@Composable
fun AnchoredDraggableDemo() {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Start,
anchors = DraggableAnchors {
DragAnchors.Start at 0f
DragAnchors.End at 1000f
},
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset {
IntOffset(
x = state
.requireOffset()
.roundToInt(), y = 0
)
}
.anchoredDraggable(state = state, orientation = Orientation.Horizontal)
.size(50.dp)
.background(Color.Red)
)
}
}
For whole control over dragging gestures, use the drag gesture detector with the pointer input modifier.
@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.graphicsLayer {
this.translationX = offsetX
this.translationY = offsetY
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
.size(50.dp)
.background(Color.Red)
)
Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
}
}
Similar to the above gesture detector, to detect drag in a specific direction, we have these two gestures.
@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset {
IntOffset(x = offsetX.roundToInt(), y = offsetY.roundToInt())
}
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount
}
/*
detectVerticalDragGestures { change, dragAmount ->
change.consume()
offsetY += dragAmount
}
*/
}
.size(50.dp)
.background(Color.Red)
)
Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
}
}
So what if we specify both in the same Pointer input scope?
Simply, the first gesture detector in the chain will invoke, and later one ignored. If you want to use both gestures for composable, chain two Modifier.pointerInput
.
This gesture detection allows us to have fine control over drag, it invokes the onDrag
call back only after a long press. This can be helpful in various scenarios where you want to provide users with a way to rearrange or interact with items in a list, reorder elements in a grid, or perform actions that require confirmation or initiation through a long press before allowing dragging.
@Preview
@Composable
fun DraggableContent() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset {
IntOffset(x = offsetX.roundToInt(), y = offsetY.roundToInt())
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress { change, dragAmount ->
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
.size(50.dp)
.background(Color.Green)
)
Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
}
}
There are few modifiers that make composable scrollable or allow us to detect the scroll. Also, some composables like LazyColumn
or LazyRow
comes with inbuilt scrolling, you can use the state
property of the LazyListState
to detect scroll events.
Modifier.scrollable
detects scroll gestures but doesn’t automatically move the content. Modifier.scrollable
listens to scroll gestures and relies on the provided ScrollableState
to control the scrolling behaviour of its content.
You should use Modifier.scrollable
over Modifier.verticalScroll()
or Modifier.horizontalScroll()
when you need more fine-grained control over the scrolling behaviour and when you want to handle custom scrolling logic, such as nested scrolling or complex interactions.
@Composable
fun ScrollableDemo() {
val state = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.scrollable(state, orientation = Orientation.Horizontal)
) {
Box(
modifier = Modifier
.padding(top = 40.dp)
.offset { IntOffset(x = state.value, y = 0) }
.size(50.dp)
.background(Color.Red)
)
Text(text = "Offset X ${state.value} ")
}
}
In contrast to Modifier.scrollable()
, Modifier.verticalScroll()
and Modifier.horizontalScroll()
are simpler and more straightforward options for basic scrolling needs.
They are well-suited for cases where you want to make a single Composable vertically or horizontally scrollable without the need for custom scroll handling or nested scrolling scenarios.
These modifiers provide a convenient way to enable standard scrolling behaviour with minimal configuration.
@Composable
fun HorizontalScrollDemo() {
val state = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(text = "Offset X ${state.value} ")
Row(
modifier = Modifier
.fillMaxSize()
.horizontalScroll(state) // or use .verticalScroll(...) for vertical scrolling
) {
repeat(20) {
Box(
modifier = Modifier
.padding(8.dp)
.size(50.dp)
.background(Color.Red)
)
}
}
}
}
Nested scrolling enables coordinated scrolling interactions between multiple UI elements, ensuring that scroll actions propagate correctly through a hierarchy of scrollable and non-scrollable components.
The in-built scrolling component like LazyList supports nested scrolling but for non-scrollable components, we need to enable it manually with NestedScrollConnection.
Let’s have a quick look at it.
There are two ways an element can participate in nested scrolling:
NestedScrollDispatcher
to the nested scroll chain.NestedScrollConnection
, which is called when another nested scrolling child below dispatches scrolling events.You may choose to use one or both methods based on your specific use case.
Four Main Phases in Nested Scrolling:
We’ll not deep dive into NestedScroll in this blog post, you can refer to the official documentation for more detail.
Let’s see a simple example, where we have nested horizontal scrolling with an anchored draggable component.
@Composable
fun NestedScrollExample() {
val density = LocalDensity.current
val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Center,
anchors = DraggableAnchors {
DragAnchors.Start at -200f
DragAnchors.Center at 0f
DragAnchors.End at 200f
},
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}
Box(
modifier = Modifier.fillMaxSize().padding(20.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.anchoredDraggable(state, Orientation.Horizontal)
.offset {
IntOffset(state.requireOffset().roundToInt(), 0)
}
) {
Box(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.height(60.dp)
.background(Color.Black, RoundedCornerShape(10.dp))
.padding(horizontal = 10.dp)
.horizontalScroll(rememberScrollState()),
contentAlignment = Alignment.Center
) {
Text(
text = "Nested scroll modifier demo. Hello, Jetpack compose",
color = Color.White,
fontSize = 24.sp,
maxLines = 1,
textAlign = TextAlign.Center
)
}
}
}
}
You can see here, that vertical scrolling and dragging are not working together. Now let’s add nested scrolling and dispatch the offset to make composable draggable and scrollable.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if ((available.x > 0f && state.offset < 0f) || (available.x < 0f && state.offset > 0f)) {
return Offset(state.dispatchRawDelta(available.x), 0f)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return Offset(state.dispatchRawDelta(available.x), 0f)
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.anchoredDraggable(state, Orientation.Horizontal)
.offset {
IntOffset(state.requireOffset().roundToInt(), 0)
}
.nestedScroll(nestedScrollConnection)
) {
// .... content
}
In this code:
NestedScrollConnection
to manage scroll events for a draggable component.onPreScroll
function handles scroll events before they're consumed and adjusts the state of the anchored draggable component based on the scroll direction.onPostScroll
function handles scroll events after they've been consumed and further adjusts the component's state.And that’s it for the part 1.
In this first part of our exploration into gestures in Jetpack Compose, we’ve delved into some fundamental aspects of gesture handling that will set the stage for more advanced techniques and functionalities in Part Two.
In Part 1, we’ve covered the essentials of gestures in Jetpack Compose. We explored Gesture Modifiers, Tap Detection, Movements like Drag and Swipe, and Scroll Gestures.
In Part 2 of our series, we’ll explore advanced topics, including handling multiple gestures simultaneously, creating fling effects, manual interaction tracking, non-consuming observation, interaction disabling, and waiting for specific events.
Stay tuned for Part 2.