필자가 회사에서 업무를 진행하다가, 메일 앱처럼 리스트 아이템을 옆으로 스와이프해서 삭제·즐겨찾기 같은 동작을 트리거하는 UI가 필요한 일이 생겼다. 예전에 Material 2의 SwipeToDismiss를 써본 적이 있어서 그대로 가져다 쓰려 했는데, 막상 코드를 작성해 보니 FractionalThreshold가 Deprecated 되어 있었고 Material 3에는 새로운 API가 따로 존재하고 있었다.
최근 안드로이드 관련 기술 블로그를 확인하다가, Material 3의 SwipeToDismissBox 가이드 글을 보게 되어 이번 기회에 한 번 정리해보고자 한다.
이번 글에서는 Material 3의 SwipeToDismissBox를 사용해 단방향·양방향·조건부 스와이프와 M2 스타일의 복잡한 배경 애니메이션을 M3 API로 구현하는 방법을 알아볼 예정이다.
Material 2 vs Material 3 비교
먼저 두 버전의 API를 짧게 비교해보자. 익숙한 M2 코드를 사용하다가 M3로 넘어가려는 사람에게는 이 부분이 가장 헷갈리는 지점인 것 같다.
Material 2 (androidx.compose.material)
SwipeToDismiss컴포저블DismissState/rememberDismissState()DismissValue: Default, DismissedToEnd, DismissedToStartFractionalThreshold(Deprecated)
Material 3 (androidx.compose.material3)
SwipeToDismissBox컴포저블SwipeToDismissBoxState/rememberSwipeToDismissBoxState()SwipeToDismissBoxValue: Settled, StartToEnd, EndToStartpositionalThreshold(dp 단위)enableDismissFromStartToEnd/enableDismissFromEndToStart로 방향 제어
이름이 SwipeToDismissBox로 명시적으로 바뀌었고, 스와이프 방향을 boolean 플래그로 명확히 제어할 수 있도록 개선되어 한층 더 직관적인 형태가 된 것 같다.
1. Basic Swipe to Dismiss
가장 단순한 형태부터 시작해보자. 메일 아이템을 왼쪽으로 스와이프하면 삭제되는 형태이다.
val items = remember {
mutableStateListOf(
EmailItem(1, "Alice", "Meeting tomorrow", "Let's discuss the project..."),
EmailItem(2, "Bob", "Lunch plans", "Are you free for lunch?"),
// ...
)
}
items.forEach { item ->
var show by remember { mutableStateOf(true) }
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
when (dismissValue) {
SwipeToDismissBoxValue.EndToStart -> {
show = false
true // 스와이프 승인
}
else -> false
}
}
)
AnimatedVisibility(
visible = show,
exit = fadeOut() + slideOutVertically()
) {
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = true,
backgroundContent = {
DismissBackground(
dismissState.currentValue,
dismissState.targetValue,
icon = Icons.Default.Delete,
color = Color(0xFFD32F2F),
alignment = Alignment.CenterEnd
)
},
content = { EmailItemCard(item) }
)
}
}
위 코드의 핵심은 confirmValueChange 콜백이다. 이 함수에서 true를 반환해야 비로소 스와이프가 "승인"되고, false를 반환하면 아이템이 원래 자리로 돌아간다. 즉, 단순히 스와이프 제스처를 인식하는 게 아니라 "이 동작을 수행할 것인가"를 개발자가 직접 결정할 수 있다는 의미이다.
그리고 backgroundContent는 스와이프할 때 아이템 뒤에 노출되는 배경(삭제 아이콘 등)이고, content는 실제로 손가락을 따라 움직이는 아이템 자체이다. 이 두 레이어를 분리해서 받기 때문에 구조가 매우 깔끔해졌다는 점이 만족스러웠다.
2. Two-Way Swipe (양방향 스와이프)
좌우 방향마다 다른 동작을 수행하고 싶을 때가 있다. 예를 들어 Gmail처럼 오른쪽으로는 즐겨찾기, 왼쪽으로는 삭제(또는 보관) 같은 식이다.
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
when (dismissValue) {
SwipeToDismissBoxValue.StartToEnd -> {
item.isFavorite = !item.isFavorite
false // 즐겨찾기는 토글만 하고 아이템은 사라지지 않음
}
SwipeToDismissBoxValue.EndToStart -> {
show = false
true // 삭제는 실제로 dismiss
}
else -> false
}
}
)
// StartToEnd 동작 후에는 자동으로 원위치로 복귀
LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue == SwipeToDismissBoxValue.StartToEnd) {
delay(500)
dismissState.reset()
}
}
여기서 주목할 점은 "동작은 수행하되 아이템은 유지" 하고 싶을 때의 처리이다. 즐겨찾기는 토글이지 삭제가 아니기 때문에 confirmValueChange에서 false를 반환해 dismiss를 거부하고, 동시에 item.isFavorite만 토글했다.
다만 이렇게만 두면 아이템이 스와이프된 위치에 그대로 멈춰 있으므로, LaunchedEffect로 currentValue를 관찰하다가 500ms 뒤에 dismissState.reset()을 호출해서 원위치로 돌려보내야 자연스러운 UX가 만들어진다.
3. Conditional Swipe (조건부 스와이프)
특정 아이템은 절대 삭제되지 않도록 잠가둬야 하는 경우도 있다. 예를 들어 "중요" 표시가 된 메일이나 시스템 메시지 같은 것이다.
이 경우는 confirmValueChange에서 조건 검사를 한 번 거치면 된다.
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (item.isLocked) {
false // 잠긴 아이템은 항상 거부
} else {
when (dismissValue) {
SwipeToDismissBoxValue.EndToStart -> {
show = false
true
}
else -> false
}
}
}
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = !item.isLocked, // 잠겼으면 제스처 자체를 비활성화
backgroundContent = { /* ... */ },
content = { EmailItemCard(item) }
)
여기서 enableDismissFromEndToStart까지 !item.isLocked로 묶어둔 이유는, confirmValueChange에서만 막아도 사용자가 스와이프 시 일단 손가락을 따라 움직이긴 하기 때문이다. 제스처 자체를 비활성화해야 "아예 움직이지 않는다"는 시각적 피드백을 줄 수 있다.
4. M2 스타일의 복잡한 애니메이션을 M3로 재현하기
예전 M2 시절에는 임계값을 넘는 순간 배경색이 반전되고, 아이콘이 튀어오르듯 바운스되는 식의 화려한 배경 애니메이션을 자주 봤다. M3에서는 보통 animateFloatAsState 정도로 간단히 처리하는 것이 권장되지만, 그런 화려한 효과가 필요한 경우 Animatable과 snapshotFlow를 조합해서 직접 만들 수 있다.
var thresholdReached by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { dismissState.currentValue }
.collect { value ->
thresholdReached = value != SwipeToDismissBoxValue.Settled
}
}
snapshotFlow로 currentValue를 Flow처럼 관찰하다가, Settled 상태가 아니게 되는 순간을 임계값 도달 시점으로 간주하는 패턴이다. 이 boolean을 가지고 배경 애니메이션의 트리거를 잡는다.
val backgroundScale = remember { Animatable(0f) }
val iconScale = remember { Animatable(0.8f) }
LaunchedEffect(thresholdReached) {
if (thresholdReached) {
backgroundScale.snapTo(0f)
launch {
backgroundScale.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 400)
)
}
// 아이콘은 약간 튀어오르듯 바운스
iconScale.snapTo(0.8f)
iconScale.animateTo(
targetValue = 1.25f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)
iconScale.animateTo(targetValue = 1f)
} else {
backgroundScale.snapTo(0f)
iconScale.snapTo(0.8f)
}
}
임계값을 넘은 시점에 배경은 원형으로 확장되고, 아이콘은 spring 애니메이션으로 잠깐 커졌다가 원래 크기로 돌아온다. 색상도 임계값 전/후로 반전시켜주면 "지금 손을 떼면 동작이 실행된다"는 신호를 시각적으로 강하게 전달할 수 있다.
val backgroundColor = when (currentValue) {
SwipeToDismissBoxValue.StartToEnd -> if (thresholdReached) Color(0xFFFFFF00) else Color.Black
SwipeToDismissBoxValue.EndToStart -> if (thresholdReached) Color(0xFF8B0000) else Color.Black
else -> Color.Transparent
}
M3가 권장하는 방식보다는 다소 복잡하지만, M2 시절 디자인 가이드를 그대로 유지해야 하는 경우에는 이런 식으로 충분히 재현 가능하다는 것을 확인할 수 있었다.
구현하면서 신경 써야 했던 부분
1. dismiss 후 AnimatedVisibility로 한 번 더 감싸기
처음에는 confirmValueChange에서 true만 반환하면 아이템이 알아서 사라질 줄 알았는데, 실제로는 SwipeToDismissBox가 끝까지 스와이프된 상태로 멈춰 있을 뿐이다. 결국 별도의 show 상태와 AnimatedVisibility로 감싸서 fade-out·slide-out 애니메이션을 직접 제어해야 자연스러운 삭제 효과가 나온다.
2. 양방향 스와이프 후 reset 타이밍
StartToEnd 스와이프 후에는 반드시 delay를 한 번 주고 reset()을 호출해야 한다. 너무 빨리 reset 하면 사용자가 동작이 수행됐다는 시각적 피드백을 받기 전에 else -> Color.Transparent SwipeToDismissBoxValue.EndToStart -> if (thresholdReached) Color(0xFF8B0000) else Color.Black else -> Color.Transparent }
M3가 권장하는 방식보다는 다소 복잡하지만, M2 시절 디자인 가이드를 그대로 유지해야 하는 경우에는 이런 식으로 충분히 재현 가능하다는 것을 확인할 수 있었다.
구현하면서 신경 써야 했던 부분
1. dismiss 후 AnimatedVisibility로 한 번 더 감싸기
처음에는 confirmValueChange에서 true만 반환하면 아이템이 알아서 사라질 줄 알았는데, 실제로는 SwipeToDismissBox가 끝까지 스와이프된 상태로 멈춰 있을 뿐이다. 결국 별도의 show 상태와 AnimatedVisibility로 감싸서 fade-out·slide-out 애니메이션을 직접 제어해야 자연스러운 삭제 효과가 나온다.
2. 양방향 스와이프 후 reset 타이밍
StartToEnd 스와이프 후에는 반드시 delay를 한 번 주고 reset()을 호출해야 한다. 너무 빨리 reset 하면 사용자가 동작이 수행됐다는 시각적 피드백을 받기 전에 원위치로 돌아가 버려서, "내가 스와이프한 게 맞나?" 싶은 어색한 UX가 되어버린다. 500ms 정도가 적당한 것 같다.
3. confirmValueChange는 동기 콜백
confirmValueChange는 동기 함수이기 때문에 suspend 호출을 직접 할 수 없다. delete API 호출 같은 비동기 작업이 필요하면 rememberCoroutineScope로 별도 launch 해야 한다.
마무리
이번 글에서는 Material 3의 SwipeToDismissBox를 사용해 단방향·양방향·조건부 스와이프와 M2 스타일의 복잡한 배경 애니메이션을 재현하는 방법에 대하여 작성해 보았다.
M2 시절에 비해 API가 훨씬 명확해진 점이 가장 만족스러운 부분이었던 것 같다. enableDismissFromStartToEnd 같은 boolean 플래그로 방향을 제어할 수 있다는 점이 특히 편리하다고 느꼈다. 다만 dismiss 이후 아이템을 실제로 사라지게 하는 부분은 여전히 개발자가 직접 처리해야 해서, 이 부분이 헷갈리지 않도록 패턴화해서 사용하는 것이 좋겠다는 생각이 든다.
다음에는 SwipeToDismissBox를 LazyColumn에 적용했을 때 발생할 수 있는 recomposition 이슈와 key 사용 패턴에 대해서도 한번 정리해볼 예정이다.
필자도 아직 Material 3에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Compose] TextMeasurer와 Canvas로 커스텀 텍스트 효과를 만들어보자 - Faded, Warped, Typewriter (0) | 2026.05.02 |
|---|---|
| [Compose] TabRow와 ScrollableTabRow 사이에서 고민될 때, SubcomposeLayout으로 반응형 탭을 만들어보자 (0) | 2026.05.01 |
| [Compose] Canvas로 도형 그리기와 애니메이션 기초를 알아보자 (0) | 2026.04.26 |
| [Compose] TopAppBarScrollBehavior로 Fancy TopAppBar를 구현해보자 (0) | 2026.04.23 |
| [Compose] Quick Settings Tile로 마이크로 인터랙션 패턴을 구현해보자 (0) | 2026.04.19 |