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.

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 = viewModel.cards.collectAsState()
val revealedCardIds = viewModel.revealedCardIdsList.collectAsState()
Scaffold(backgroundColor = Color.White) {
LazyColumn {
itemsIndexed(cards.value) { _, card ->
Box(Modifier.fillMaxWidth()) {
ActionsRow(
actionIconSize = ACTION_ITEM_SIZE.dp,
onDelete = {},
onEdit = {},
onFavorite = {}
)
DraggableCard(
card = card,
isRevealed = revealedCardIds.value.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 DraggableCard(
card: CardModel,
isRevealed: Boolean,
cardOffset: Float,
onExpand: () -> Unit,
onCollapse: () -> Unit,
) {
val offsetX = 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.value else -offsetX.value },
)
Card(
modifier = Modifier
.offset { IntOffset((offsetX.value + 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
    }
}

Job Offers

Job Offers


    Senior Android Engineer

    Peloton Interactive
    New York
    • Full Time
    apply now

    Android Engineer

    American Express
    London | New York | Phoenix
    • Full Time
    apply now

    Android AOSP Platform Developer (m/w/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

, ,

What does Recomposition mean to your app?

You’ve heard a lot that Jetpack Compose is a declarative UI toolkit and it recomposes only the components that changed. But what does it exactly mean? How does it apply not only in the scale…
Watch Video

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engineer
Android

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engi ...
Android

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engineer
Android

Jobs

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

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
Yes! You heard it right. We’ll try to understand the complete OTP (one time…
READ MORE

Leave a Reply

Your email address will not be published.

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

Menu