Swipe-to-Dismiss and Row Actions

AndroidCompose
Share on LinkedIn

Mail clients trained users to swipe left for archive and swipe right to pin. Compose Material3 finally ships first-class swipe primitives—SwipeToDismissBox for dismiss-or-reveal patterns and AnchoredDraggable underneath for custom anchors. The API changed from Material2's SwipeToDismiss, so most Stack Overflow answers are stale. Here is the current pattern.

SwipeToDismissBox basics

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DismissibleListItem(
    item: TodoItem,
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val state = rememberSwipeToDismissBoxState(
        confirmValueChange = { value ->
            if (value == SwipeToDismissBoxValue.EndToStart) {
                onDismiss()
                true
            } else false
        },
    )

    SwipeToDismissBox(
        state = state,
        enableDismissFromStartToEnd = false,
        backgroundContent = {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.errorContainer)
                    .padding(horizontal = 20.dp),
                contentAlignment = Alignment.CenterEnd,
            ) {
                Icon(Icons.Default.Delete, contentDescription = "Delete")
            }
        },
        modifier = modifier,
    ) {
        ListItem(
            headlineContent = { Text(item.title) },
            supportingContent = { Text(item.subtitle) },
        )
    }
}

Background draws under the foreground content; swipe translates foreground revealing background.

Reveal actions without auto-dismiss

For archive + delete buttons, use anchors instead of immediate dismiss:

enum class SwipeAnchor { Settled, Revealed, Dismissed }

@Composable
fun ActionRow(item: Item, onArchive: () -> Unit, onDelete: () -> Unit) {
    val density = LocalDensity.current
    val anchors = DraggableAnchors {
        SwipeAnchor.Settled at 0f
        SwipeAnchor.Revealed at -160.dp.toPx(density)
        SwipeAnchor.Dismissed at -400.dp.toPx(density)
    }

    val state = rememberAnchoredDraggableState(
        initialValue = SwipeAnchor.Settled,
        anchors = anchors,
    )

    Box(Modifier.anchoredDraggable(state, Orientation.Horizontal)) {
        Row(
            Modifier
                .align(Alignment.CenterEnd)
                .width(160.dp),
            horizontalArrangement = Arrangement.SpaceEvenly,
        ) {
            IconButton(onClick = onArchive) {
                Icon(Icons.Default.Archive, "Archive")
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Default.Delete, "Delete")
            }
        }
        ListItem(
            headlineContent = { Text(item.title) },
            modifier = Modifier
                .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
                .background(MaterialTheme.colorScheme.surface),
        )
    }
}

Snap to Revealed on partial swipe; Dismissed only past threshold with undo snackbar.

Undo pattern on dismiss

val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }

confirmValueChange = { value ->
    if (value == SwipeToDismissBoxValue.EndToStart) {
        scope.launch {
            val result = snackbarHostState.showSnackbar(
                message = "Item deleted",
                actionLabel = "Undo",
                duration = SnackbarDuration.Short,
            )
            if (result == SnackbarResult.ActionPerformed) {
                state.snapTo(SwipeToDismissBoxValue.Settled)
            } else {
                onDismissConfirmed()
            }
        }
        true
    } else false
}

Defer actual deletion until snackbar timeout—standard Material pattern.

LazyColumn integration

LazyColumn {
    items(items, key = { it.id }) { item ->
        DismissibleListItem(
            item = item,
            onDismiss = { viewModel.delete(item.id) },
            modifier = Modifier.animateItem(),
        )
    }
}

Stable keys prevent swipe state leaking between recycled items when list updates.

Accessibility

Swipe gestures are invisible to TalkBack-only users. Expose actions in overflow menu and set custom actions on semantics:

Modifier.semantics {
    customActions = listOf(
        CustomAccessibilityAction("Delete") {
            onDelete()
            true
        },
    )
}

Material2 migration note

Old API:

// Deprecated pattern
SwipeToDismiss(state, background, dismissContent)

Map to SwipeToDismissBox with explicit direction flags. DismissValue maps to SwipeToDismissBoxValue.

Gesture conflict resolution in lists

Swipe rows inside LazyColumn compete with vertical scroll for touch ownership. Compose resolves this with touch slop — the first gesture direction to exceed the slop threshold wins. Horizontal swipe on a row won't trigger until the user moves horizontally enough that vertical scroll is ruled out.

Practical implications:

val state = rememberSwipeToDismissBoxState(
    positionalThreshold = { totalDistance -> totalDistance * 0.5f },
    confirmValueChange = { /* ... */ },
)

50% threshold means the user must swipe halfway before dismiss confirms — standard for destructive actions.

Only one open row pattern

Mail clients close other revealed rows when a new row is swiped. Implement with shared state:

@Composable
fun SwipeableList(items: List<Item>, viewModel: ListViewModel) {
    val openItemId by viewModel.openSwipeItemId.collectAsStateWithLifecycle()

    LazyColumn {
        items(items, key = { it.id }) { item ->
            ActionRow(
                item = item,
                isOpen = openItemId == item.id,
                onOpen = { viewModel.setOpenSwipeItem(item.id) },
                onClose = { viewModel.setOpenSwipeItem(null) },
                onDelete = { viewModel.delete(item.id) },
            )
        }
    }
}

When a row opens, animate others closed. Without this, users reveal five rows and forget which actions belong where.

Haptic and visual feedback

Material3 swipe should provide feedback at threshold crossing:

val haptic = LocalHapticFeedback.current

LaunchedEffect(state.currentValue) {
    if (state.currentValue != SwipeToDismissBoxValue.Settled) {
        haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
    }
}

Background color should intensify as swipe progresses — use state.progress (if available) or calculate from offset to interpolate alpha between surface and error container colors.

Testing swipe interactions

@Test
fun swipeToDismiss_triggersDelete() {
    composeTestRule.setContent {
        DismissibleListItem(item = testItem, onDismiss = { deleted = true })
    }

    composeTestRule.onNodeWithText(testItem.title)
        .performTouchInput { swipeLeft(endX = -500f) }

    assertTrue(deleted)
}

Test undo flow separately — swipe, wait for snackbar, click Undo, verify item restored. Test accessibility custom actions for TalkBack users who can't swipe.

Common failure modes

Production checklist

SwipeToDismissBox state management

Manage dismiss state across recompositions:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DismissibleItem(
    item: Item,
    onDismiss: (Item) -> Unit,
) {
    var showUndo by remember { mutableStateOf(false) }
    val dismissState = rememberSwipeToDismissBoxState(
        confirmValueChange = { value ->
            if (value == SwipeToDismissBoxValue.EndToStart) {
                showUndo = true
                false  // don't dismiss yet — wait for undo window
            } else true
        }
    )

    SwipeToDismissBox(
        state = dismissState,
        backgroundContent = { DismissBackground(dismissState) },
        enableDismissFromStartToEnd = false,
    ) {
        ItemRow(item)
    }

    if (showUndo) {
        LaunchedEffect(item.id) {
            delay(5000)
            onDismiss(item)
            showUndo = false
        }
        Snackbar(action = { TextButton(onClick = { showUndo = false }) { Text("Undo") } }) {
            Text("Item deleted")
        }
    }
}

confirmValueChange returning false prevents immediate dismiss — gives undo window. Reset dismiss state after undo.

Testing swipe interactions

@Test
fun swipeToDismiss_triggersDeleteAction() {
    composeTestRule.setContent { DismissibleList(items = testItems, onDismiss = { deleted = it }) }
    composeTestRule.onNodeWithTag("item-${testItems[0].id}")
        .performTouchInput { swipeLeft(startX = right, endX = left) }
    composeTestRule.onNodeWithText("Item deleted").assertIsDisplayed()
}

Test swipe threshold, undo action, and background reveal at partial swipe. Use performTouchInput for precise swipe simulation.

Resources

Frequently asked questions

What replaced SwipeToDismiss in Material3 Compose?

Material3 provides SwipeToDismissBox with background and dismiss content slots, backed by AnchoredDraggableState. It supports start-to-end and end-to-start swipe directions with positional thresholds for dismiss vs snap-back. Material2 SwipeToDismiss is deprecated in favor of this API.

How do I show delete and archive actions on swipe?

Use end-to-start swipe revealing a Row of action buttons in the background slot—not full dismiss. Set confirmValueChange to snap to an 'revealed' anchor instead of DismissedValue when the user should tap an action. Full dismiss only when swipe passes dismiss threshold.

How do I prevent swipe conflicts with LazyColumn scroll?

SwipeToDismissBox consumes horizontal drag after vertical scroll dominance is determined. Use rememberSwipeToDismissBoxState with requireDismissedDirection matching your UX. For rows in LazyColumn, ensure item modifier order places swipe on the row root; avoid nested horizontal scrollables inside swipe rows.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →