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:
- Holds list of cards using MutableStateFlow in _cards field, and exposes a StateFlow to observers via cards field.
- Holds list of “revealed” card ids in _revealedCardIdsList, and exposes them to observers via revealedCardIdsList field.
- Provides list of cards using getFakeData() function. We need a coroutine here, to emit the testList into _cards.
- 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.
- 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) }, | |
) | |
} | |
} | |
} | |
} | |
} |
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: onDelete, onEdit, onFavorite.
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 } }
Job Offers
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 detectHorizontalDragGestures, and 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 | |
} | |
} |
- 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: