Blog Infos
Author
Published
Topics
,
Published

Swipe to dismiss is really easy to implement in compose, including item removal animation by using a combination of SwipeToDismiss & AnimatedVisibility composables.

SwipeToDismiss doesn’t allow us to stop the dragging motion midway though, so let’s take a look how we can achieve the following by detecting horizontal drag gestures on any composable.

Swipe to dismiss is really easy to implement in compose, including item removal animation by using a combination of SwipeToDismiss & AnimatedVisibility composables.
Step 1: Creating a data source

Those who’ve read expandable lists in jetpack compose can skip this step, since data source is almost the same.

First we define our model to hold the card related info:

@Immutable
data class CardModel(val id: Int, val title: String)

You can find what Immutable annotation means here.

We’ll be using AAC viewModel example here, but feel free to use any “controller” abstraction you prefer.

 

class CardsScreenViewModel : ViewModel() {
private val _cards = MutableStateFlow(listOf<CardModel>())
val cards: StateFlow<List<CardModel>> get() = _cards
private val _revealedCardIdsList = MutableStateFlow(listOf<Int>())
val revealedCardIdsList: StateFlow<List<Int>> get() = _revealedCardIdsList
init {
getFakeData()
}
private fun getFakeData() {
viewModelScope.launch {
withContext(Dispatchers.Default) {
val testList = arrayListOf<CardModel>()
repeat(20) { testList += CardModel(id = it, title = "Card $it") }
_cards.emit(testList)
}
}
}
fun onItemExpanded(cardId: Int) {
if (_revealedCardIdsList.value.contains(cardId)) return
_revealedCardIdsList.value = _revealedCardIdsList.value.toMutableList().also { list ->
list.add(cardId)
}
}
fun onItemCollapsed(cardId: Int) {
if (!_revealedCardIdsList.value.contains(cardId)) return
_revealedCardIdsList.value = _revealedCardIdsList.value.toMutableList().also { list ->
list.remove(cardId)
}
}
}

This class serves 5 purposes:

  1. Holds list of cards using MutableStateFlow in _cards field, and exposes a StateFlow to observers via cards field.
  2. Holds list of “revealed” card ids in _revealedCardIdsList, and exposes them to observers via revealedCardIdsList field.
  3. Provides list of cards using getFakeData() function. We need a coroutine here, to emit the testList into _cards.
  4. Has onItemExpanded() to mark cards as revealed, by adding tapped card id to _revealedCardIdsList, and notify observers about this change by mutating the state of _revealedCardIdsList.
  5. Has onItemCollapsed() to remove revealed card id from _revealedCardIdsList, and notify observers about this change by mutating the state of _revealedCardIdsList.
Step 2: CardsScreen composable
@ExperimentalCoroutinesApi
@Composable
fun CardsScreen(viewModel: CardsScreenViewModel) {
val cards by viewModel.cards.collectAsStateWithLifecycle()
val revealedCardIds by viewModel.revealedCardIdsList.collectAsStateWithLifecycle()
Scaffold {
LazyColumn {
items(cards.value, CardModel::id) { card ->
Box(Modifier.fillMaxWidth()) {
ActionsRow(
actionIconSize = ACTION_ITEM_SIZE.dp,
onDelete = {},
onEdit = {},
onFavorite = {}
)
DraggableCard(
card = card,
isRevealed = revealedCardIds.contains(card.id),
cardHeight = CARD_HEIGHT.dp,
cardOffset = CARD_OFFSET.dp(),
onExpand = { viewModel.onItemExpanded(card.id) },
onCollapse = { viewModel.onItemCollapsed(card.id) },
)
}
}
}
}
}
view raw CardsScreen.kt hosted with ❤ by GitHub

We are observing the viewModel.cards containing our list of cards, and viewModel.expandedCardIds with the help of .collectAsState(). UI is quite simple here, each list item is represented with a Box that contains our hidden action icons & the draggable card.

ActionsRow — row with 3 icons, exposes callbacks from each icon tap: onDeleteonEditonFavorite.

DraggableCard — our custom card composable that exposes onExpand & onCollapse callbacks.

  • we use isRevealed field to apply initial state of the card.
  • cardOffset is the amount of horizontal offset of the card when it’s in a “revealed” state. This number should be equal to the width of content under the card. In our case it’s width of 3 icons.
Step 3: DraggableCard composable (short version)
@Composable
fun DraggableCardComplex(
card: CardModel,
isRevealed: Boolean,
cardOffset: Float,
onExpand: () -> Unit,
onCollapse: () -> Unit,
) {
val offsetX by remember { mutableStateOf(0f) }
val transitionState = remember {
MutableTransitionState(isRevealed).apply {
targetState = !isRevealed
}
}
val transition = updateTransition(transitionState)
val offsetTransition by transition.animateFloat(
label = "cardOffsetTransition",
transitionSpec = { tween(durationMillis = ANIMATION_DURATION) },
targetValueByState = { if (isRevealed) cardOffset - offsetX else -offsetX },
)
Card(
modifier = Modifier
.offset { IntOffset((offsetX + offsetTransition).roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
..
}
},
content = { CardTitle(cardTitle = card.title) }
)
}
val offsetX = remember { mutableStateOf(0f) }

offsetX — will hold our real time provided horizontal offset value.

val transitionState = remember {
    MutableTransitionState(isRevealed).apply {
        targetState = !isRevealed
    }
}

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

We declare MutableTransitionState, and put it into our transition composable. Our initialState will depend on whether we have this card id in our revealedCardIds, and targetState will be a reversed initialState, since we only have 2 states.

val offsetTransition by transition.animateFloat(
    label = "cardOffsetTransition",
    transitionSpec = { tween(durationMillis = ANIMATION_DURATION) },
    targetValueByState = { 
if (isRevealed) cardOffset - offsetX.value else -offsetX.value },)

The offsetTransition helps us adjust the placement of the card, providing us with the “snap” effect.

.offset { IntOffset((offsetX.value + offsetTransition).roundToInt(), 0) }

We are combining real time updates from offsetX provided by the detectHorizontalDragGesturesand updates caused by our offsetTransition and passing them to to the offset modifier.

.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
val original = Offset(offsetX.value, 0f)
val summed = original + Offset(x = dragAmount, y = 0f)
val newValue = Offset(x = summed.x.coerceIn(0f, cardOffset), y = 0f)
if (newValue.x >= 10) {
onExpand()
return@detectHorizontalDragGestures
} else if (newValue.x <= 0) {
onCollapse()
return@detectHorizontalDragGestures
}
change.consumePositionChange()
offsetX.value = newValue.x
}
}
view raw pointerInput.kt hosted with ❤ by GitHub
  • newValue.x is calculated from the drag amount caused by user.
  • newValue.x ≥ 10 allows us to snap the card to revealed state, without forcing user to drag it further. This value can be whatever you find pleasing, eg: the middle of the hidden content. Any drag amount from the revealed state will trigger the snap to hidden state.

This example uses compose version 1.0.3 and will be periodically updated, to reflect the latest compose version. Feel free to contact me if you know how this approach might be simplified or enhanced.

Full example can be found here:

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu