Table of Contents
Learn how to implement different gestures in Jetpack Compose and provide your app an intuitive user experience.
Gestures are essential to a mobile app because they foster an intuitive user experience and create a cleaner interface. Get rid of clunky UI and use common gesture interaction to make your app more delightful. Small changes can make your app better.
Migrating from XML-based layouts to Compose has changed much of how we create gestures. In this tutorial, you’ll learn how to install the following gestures in the new Jetpack Compose paradigm:
- How to respond to single taps on buttons and other view types.
- Handling double taps on list items.
- Scrolling lists of uniform items and nonuniform items.
- Swiping to dismiss content in lists.
You’ll be working through a to-do list app to better understand common gestures and how to integrate them in Compose.
Use the Download Materials button at the top or bottom of this tutorial to download the project. Open the starter project in Android Studio. You’ll see the initial screen for the to-do list app:
If you start exploring the files, Inside
ui folder, you’ll see two main composables: TodoListComposable.kt and TodoEditorComposable.kt. Those are the two primary screens that provide a list of to-do items, and an editor to add items and modify previous ones.
Yet, you can’t interact with anything in the app at this point. You’ll update that with the power of gestures.
Introduction to Jetpack Compose Gestures
If you’ve been developing UI in XML-based layouts, you might wonder how to add listeners to Jetpack Compose components. For the most part, you don’t need to. Rather than adding listeners to inflated views, while working with Jetpack Compose, you can add gesture modifiers and gesture callbacks directly to the composables when you declare them.
Taps are the simplest and most important form of interaction in mobile apps. They signify a single finger press and release to indicate selection. In your app, they’re necessary to interact with all of the buttons and to-do list items.
First, open TodoListComposable.kt and replace the
TODO: Add click event comment inside the
onClick = navController.navigate(Destinations.EDITOR_ROUTE) ,
This will now navigate to the editor screen for a new to-do item creation.
Next, add this callback in TodoEditorComposable.kt to replace the
TODO: Add click event comment in the save
onClick = todo?.let // Update item if it already exists todoEditorViewModel.updateTodo(todo, title = title.value, content = content.value) ?: run // Add new item if one doesn't already exist todoEditorViewModel.saveTodo(title = title.value, content = content.value) // Navigate back to the to-do list screen after saving changes navController.popBackStack()
This action saves a new event — if the screen was navigated to without a to-do item but just updates the item if one was passed. It then returns to the to-do list by popping the back stack.
Now, add a
clickable modifier to the to-do list item in TodoListComposable.kt where it asks
TODO: Add clickable modifier.
.clickable navController.navigate( "$Destinations.EDITOR_ROUTE?$NavigationParameters.EDITOR_ITEM_KEY=$item.id" )
This uses Compose navigation to navigate to the editor screen and pass the to-do item ID as a navigation argument. Note that we added the
clickable modifier to the entire row. It will open the editor for the item on click.
Build and run the app. You should be able to interact with all of the buttons and the to-do list now.
You could add the
clickable modifier to an element within the row to make a certain section clickable. Only that element would trigger the action.
Now it’s time to learn the double tap!
Double Tapping to Star
The next feature you’ll work on is making to-do list elements “star-able” in order to draw attention to them. In the current app, a single click isn’t possible because it opens the editor. You can add an empty star button that the user could tap once to star the item, but that will begin to bloat the UI. Instead we can use another common gesture — double tapping.
Double taps are added within a slightly different modifier than the more generic button
onClick. Add the following modifier to the line in TodoListComposable.kt labeled
TODO: Add pointer input modifier.
.pointerInput(Unit) detectTapGestures( onDoubleTap = todoListViewModel.toggleStarred(item) )
detectTapGestures function allows more flexibility to detect tap inputs, which include:
onPress— the initial press down of a tap is first detected.
onDoubleTap— two taps in rapid succession.
onLongPress— a single press held down.
onTap— after a single press and release.
Using these additional gestures allows you to expand the range of interactions with less additional code.
detectTapGestures modifier can also accept single taps, you can get rid of the clickable modifier and add that action to the
detectTapGestures function, if you want to clean up the code a bit.
.pointerInput(Unit) detectTapGestures( onTap = navController.navigate("$Destinations.EDITOR_ROUTE?$NavigationParameters.EDITOR_ITEM_KEY=$item.id") , onDoubleTap = todoListViewModel.toggleStarred(item) )
Build and run the app. It should star and unstar a row on double tap.
Handling Scrolling Gestures
You can only display a few items at once, and then you have to scroll to show what is off-screen. Scrolling plays a role of an essential gesture here.
Default Scrolling Behavior
Making content scrollable happens in two primary ways: By putting it in a Column/Row or in a LazyColumn/LazyRow. A regular Column/Row isn’t scrollable by default, but we have a modifier for that!
LazyColumn/LazyRow are scrollable by default but typically are only used for homogenous lists of elements or long lists that couldn’t render all at once.
Currently, both the list screen and the editor screen are implemented with Columns, which doesn’t support scrolling. That can cause major dysfunctions with the app. You have a series of repeating elements on the list screen, which is a good spot for a LazyColumn.
In TodoListComposable.kt, find the
// TODO: Change to LazyColumn comment and replace the existing
Column implementation with the following
LazyColumn(modifier = Modifier.padding(16.dp), content = items(items) TodoListItem(it, todoListViewModel, navController) )
This code is almost identical to the previous code, except it uses LazyColumn instead of Column to take advantage of the automatic scrolling. It uses the built-in
items function to generate a list of homogenous elements from a list of data.
And just like that, the to-do list scrolls! You can test it by adding a bunch of new to-dos using the plus button on the list screen:
And once you have enough, you can drag the list up and down:
The editor screen doesn’t have repeating elements, but it will still be helpful to have it scrollable in case the input content ever spreads beyond the screen. You can add a regular scrollable modifier to the
Column containing editor inputs in order to allow scrolling off screen.
Open TodoEditorComposable.kt and replace the
// TODO: Add vertical scroll code with the following modifier.
This allows the Column to scroll when content goes off the screen and provides a state holder to store the scroll position and handle recomposition.
Build and run the app. Now you can write an entire manuscript in the to-do item and be able to see all of it.
Swipe to Dismiss
You still need a way to remove to-do items without adding additional buttons and keeping your UI tidy and beautiful!
A frequently used gesture for this use case is “swipe to dismiss.” It works by dragging an element either to the left or right and once the item passes a certain threshold, it slides off the screen and triggers an action.
This is such a common use that it’s now part of the
androidx.compose.material library as its own composable. The first step is to create a state holder within the list item’s composable. You can add the following code at the
TODO: Add swipe to dismiss state in TodoListComposable.kt.
val dismissState = rememberDismissState(confirmStateChange = if (it == DismissValue.DismissedToEnd) todoListViewModel.removeTodo(item) true )
This creates the action associated with the
SwipeToDismiss component. It will trigger when the element is swiped, calling the view model method to remove the row item.
Next, add the
SwipeToDismiss component. In TodoListComposable.kt, replace
TODO: Wrap with swipe to dismiss and the TodoListRowContent function call with:
SwipeToDismiss( state = dismissState, dismissThresholds = FractionalThreshold(0.5f) , directions = setOf(DismissDirection.StartToEnd), // TODO: Add top layer UI // TODO: Add bottom layer UI )
- The state argument passes the SwipeToDismiss state holder, which triggers state change actions.
- The threshold prevents triggering the state until the element has been dragged by a certain proportion of the screen. In this case, the row must be over 50% of the screen before it is dismissed.
- Finally, the directions tells the component to only allow drag from left to right. If the user tries to drag the other way, it will nudge in that direction before returning to its regular position. It is useful because you might want context-specific actions such as archiving if a user drags to the left and deleting if a user drags to the right. If you add additional directions here, you must also update the state holder to handle those state changes.
Now you can add the UI portion of the composable. Add the following snippet as an argument to
SwipeToDismiss where the
TODO: Add top layer UI is.
dismissContent = TodoListRowContent(item, todoListViewModel, navController) ,
The UI for SwipeToDismiss is composed of two layers: the top layer row content and the background content that is exposed when the top layer is swiped away. The
dismissContent is the top level content while the
background is the layer below it, which is visible on swipe.
In this case, you can add a trash icon for the background to indicate that the dismiss action will remove the element from the list. Add the following beneath the
background = Icon( painterResource(id = R.drawable.ic_baseline_delete_outline_24), modifier = Modifier .size(30.dp) .align(Alignment.CenterVertically), contentDescription = null, tint = Color.Red )
This adds a trash icon behind the original row content so when the user swipes the row, the intent of the action will be clear.
You can run the app now and see your new swipe-to-dismiss gesture. However, you might notice one final gotcha.
When you swipe to delete an item, it doesn’t swipe off screen completely. That’s because the composable items are being recycled in the LazyColumn, but the underlying data set changes aren’t able to convey the recomposition. To tell the LazyColumn the underlying data should recompose the element, update the LazyColumn item creation with:
items(items, key = it.id ) ...
The key associated with data ID tells the LazyColumn that each data element should correspond to its own composable and should refresh the composable when the data changes. Build and run the app. You should see the swipe-to-dismiss working like a charm!
Where to Go From Here?
You can download the final project by using the Download Materials button at the top or bottom of this tutorial.
The gestures covered in this tutorial should get you through most scenarios, but if you need to implement others, check out the Official Documentation.
You also can continue learning about Jetpack Compose from the Jetpack Compose by Tutorials book.
Continue your Jetpack Compose journey. Much remains to explore. :]
If you have any questions or comments, please join the forum discussion below!