jetpack compose에서 Lazy component의 Item을 Swipe하여 삭제하는 기능을 구현해보았다.
이 포스팅에서는 2가지 방법을 소개하며 하나는 Material3에서 제공하는 SwipeToDismissBox Component를 사용하는 것이고, 다른 하나는 anchorDraggableState를 이용하여 커스텀으로 만든 것이다.
(사실 SwipeToDismissBox도 내부 코드를 보면 anchorDraggableState로 구현되어있다.)
위의 첫번째 gif는 swipeToDismissBox로 구현한 것이고 두번째 gif는 커스텀으로 구현한 것이다.
Item은 Card Component로 구현하였으며, Item의 데이터는 TMDB Api의 Now_playing에서 받아온 것이다.
데이터를 받아오는 부분이나 상태 변화 등등은 따로 언급하지 않겠다.
Compose에서 제공하는 SwipeToDismissBox는 다음과 같다.
@Composable
@ExperimentalMaterial3Api
fun SwipeToDismissBox(
state: SwipeToDismissBoxState,
backgroundContent: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
enableDismissFromStartToEnd: Boolean = true,
enableDismissFromEndToStart: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier
.anchoredDraggable(
state = state.anchoredDraggableState,
orientation = Orientation.Horizontal,
enabled = state.currentValue == SwipeToDismissBoxValue.Settled,
reverseDirection = isRtl,
),
propagateMinConstraints = true
) {
Row(
content = backgroundContent,
modifier = Modifier.matchParentSize()
)
Row(
content = content,
modifier = Modifier.swipeToDismissBoxAnchors(
state,
enableDismissFromStartToEnd,
enableDismissFromEndToStart
)
)
}
}
- state: 사용자가 스와이프를 할 때 Box의 상태를 나타낸다. 밑에서 자세히 다루도록 하겠다.
- backgroundContent: 위 gif에서 보라색 영역을 나타낸다. 아이템 밑에 깔려있는 content를 의미한다.
- enableDismissStartToEnd: start(왼쪽)에서 End(오른쪽)으로 스와이프를 가능하게 할 지 말지 정하는 변수이다.
- enableDismissEndToStart: End(오른쪽)에서 Start(왼쪽)으로 스와이프를 가능하게 할 지 말지 정하는 변수이다.
- content: 화면에 보여지는 Item content를 의미한다.
화면에 보일 Card의 Content와 Swipe를 했을 때 Card 뒤에 보이는 Content를 구성한다.
MovieCard(Card Content)
@Composable
fun MovieCard(
movie: Movie,
onMoveMovieDetail: (Int, String) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(vertical = 10.dp)
.clickable {
onMoveMovieDetail(movie.id, movie.title)
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
) {
Box(
modifier = Modifier
.height(200.dp)
.width(150.dp)
) {
CoilImage(
imageModel = {
BASE_IMAGE_URL + movie.posterPath
},
imageOptions = ImageOptions(
contentScale = ContentScale.Crop,
alignment = Alignment.Center
)
)
}
Column(
) {
Text(
modifier = Modifier
.weight(1f),
text = "제목: ${movie.title}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(1f),
text = "개봉일: ${movie.releaseDate}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(3f),
text = "요약: ${movie.overview}",
maxLines = 2
)
}
}
}
}
DismissBackgroundMovieItem(Background Content)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DismissBackgroundMovieItem(
dismissState: SwipeToDismissBoxState
) {
val color = when(dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> Color.Transparent
SwipeToDismissBoxValue.EndToStart -> Purple80
SwipeToDismissBoxValue.Settled -> Color.Transparent
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(vertical = 10.dp)
.background(color)
.padding(end = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
text = "삭제",
style = TextStyle(
fontSize = 30.sp,
fontWeight = FontWeight.Bold
),
color = Color.White
)
}
}
SwipeBox의 상태에 따라 배경색을 다르게 해주었다.
Compose에서 제공하는 SwipeToDismissState는 다음과 같다.
@Composable
@ExperimentalMaterial3Api
fun rememberSwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float =
SwipeToDismissBoxDefaults.positionalThreshold,
): SwipeToDismissBoxState {
val density = LocalDensity.current
return rememberSaveable(
saver = Saver(
confirmValueChange = confirmValueChange,
density = density,
positionalThreshold = positionalThreshold
)
) {
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold)
}
}
- initialValue: Box의 상태를 나타낸다. default는 Settled이며 사용자가 스와이프를 시작했다가 취소하거나 충분히 스와이프하지 않아 원래 위치로 돌아온 상태를 나타낸다.
- confirmValueChange: Box의 상태를 EndToStart를 했을 때 또는 StartToEnd를 했을 떄 콜백을 수행할 수 있는 변수이다.
- positionalThreshold: 사용자가 얼만큼 Box를 스와이프 했을 때 스와이프로 인식할 수 있을지 정하는 값이다. default는 56dp.toPx()이다.
dismissState
val currentItem by rememberUpdatedState(newValue = movie)
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
when(it) {
SwipeToDismissBoxValue.StartToEnd -> {
return@rememberSwipeToDismissBoxState false
}
SwipeToDismissBoxValue.EndToStart -> {
onRemove(currentItem)
}
SwipeToDismissBoxValue.Settled -> {
return@rememberSwipeToDismissBoxState false
}
}
return@rememberSwipeToDismissBoxState true
},
// 몇 퍼 이상 스와이프해야 스와이프로 간주할 것이냐
positionalThreshold = { it * 0.7f },
)
위 SwipeToDismissState를 설정한 것이다. 사용자가 EndToStart로 (오른쪽 -> 왼쪽) 스와이프를 하면 onRemove 함수를 호출하여 아이템을 삭제하도록 하였다.
positionalThreshold의 it은 totalDistance이다. 곱하기 0.7을 해줌으로써 70% 이상 스와이프를 했을 떄 동작하도록 하였다.
이제 위에서 만든 항목들을 SwipeToDismissBox에 넣어주면 된다.
SwipeToDismissBox
SwipeToDismissBox(
state = dismissState,
modifier = Modifier,
backgroundContent = {
DismissBackgroundMovieItem(dismissState = dismissState)
},
enableDismissFromStartToEnd = false
) {
MovieCard(
movie = movie,
onMoveMovieDetail = onMoveMovieDetail
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieItemComponent(
movie : Movie,
onMoveMovieDetail : (Int, String) -> Unit,
onRemove: (Movie) -> Unit
) {
val currentItem by rememberUpdatedState(newValue = movie)
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
when(it) {
SwipeToDismissBoxValue.StartToEnd -> {
return@rememberSwipeToDismissBoxState false
}
SwipeToDismissBoxValue.EndToStart -> {
onRemove(currentItem)
}
SwipeToDismissBoxValue.Settled -> {
return@rememberSwipeToDismissBoxState false
}
}
return@rememberSwipeToDismissBoxState true
},
// 몇 퍼 이상 스와이프해야 스와이프로 간주할 것이냐
positionalThreshold = { it * 0.7f },
)
SwipeToDismissBox(
state = dismissState,
modifier = Modifier,
backgroundContent = {
DismissBackgroundMovieItem(dismissState = dismissState)
},
enableDismissFromStartToEnd = false
) {
MovieCard(
movie = movie,
onMoveMovieDetail = onMoveMovieDetail
)
}
}
@Composable
fun MovieCard(
movie: Movie,
onMoveMovieDetail: (Int, String) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(vertical = 10.dp)
.clickable {
onMoveMovieDetail(movie.id, movie.title)
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
) {
Box(
modifier = Modifier
.height(200.dp)
.width(150.dp)
) {
CoilImage(
imageModel = {
BASE_IMAGE_URL + movie.posterPath
},
imageOptions = ImageOptions(
contentScale = ContentScale.Crop,
alignment = Alignment.Center
)
)
}
Column(
) {
Text(
modifier = Modifier
.weight(1f),
text = "제목: ${movie.title}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(1f),
text = "개봉일: ${movie.releaseDate}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(3f),
text = "요약: ${movie.overview}",
maxLines = 2
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DismissBackgroundMovieItem(
dismissState: SwipeToDismissBoxState
) {
val color = when(dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> Color.Transparent
SwipeToDismissBoxValue.EndToStart -> Purple80
SwipeToDismissBoxValue.Settled -> Color.Transparent
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(vertical = 10.dp)
.background(color)
.padding(end = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Text(
text = "삭제",
style = TextStyle(
fontSize = 30.sp,
fontWeight = FontWeight.Bold
),
color = Color.White
)
}
}
기존 SwipeToDismissBox도 잘 나와있지만 나는 스와이프 속도를 조절하고 싶었고 (기존 SwipeToDismissBox는 스와이프를 70%로 설정했지만 스와이프를 빠르게 휙 하면 70%를 넘지 않아도 스와이프로 인식한다) 삭제라는 문구를 스와이프 하면서 같이 움직이는 애니메이션을 보여주고 싶어서 따로 만들어보았다.
compose-foundation 1.6.0-alpha01 버전부터 swipeable이 지원 증단되었고 anchorDraggableState로 이전되었다.
안드로이드 공식문서 Swipeable에서 AnchoredDraggable로 이전
DragValue
enum class DragValue {
START, CENTER, END
}
DraggableAnchors
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthPx = configuration.screenWidthDp.dp.toPx(density)
val anchors = DraggableAnchors {
DragValue.START at 0f
DragValue.CENTER at screenWidthPx / 2f
DragValue.END at screenWidthPx
}
먼저 anchors라는 것을 정의해줘야 한다.
anchors는 Jetpack Compose에서 AnchoredDraggableState를 사용할 때, 드래그 가능한 요소가 특정 위치에 고정되도록 정의하는 포인트를 의미한다. screenWidthPx은 사용자의 휴대폰 width 길이를 px로 받아온 것이다. START는 0px (왼쪽), CENTER는 폰 길이의 절반(가운데), END는 폰 길이(오른쪽)로 지정하였다.
val anchorDraggableState = remember {
AnchoredDraggableState(
initialValue = DragValue.START,
anchors = anchors,
positionalThreshold = { distance : Float -> distance * 0.5f },
velocityThreshold = { 1000000f },
animationSpec = tween(),
confirmValueChange = {
when(it) {
DragValue.START -> false
DragValue.CENTER -> false
DragValue.END -> true
}
}
)
}
LaunchedEffect(anchorDraggableState.currentValue) {
if (anchorDraggableState.currentValue == DragValue.END) {
onRemove(movie)
}
}
Drag의 상태를 설정하는 부분이다. positionalThreshold는 위에서 했던 것과 같은 속성이고 velocityThreshold로 스와이프의 인식 속도를 조정할 수 있다.
in px per second 단위로 속도를 조절하는데 솔직히 잘 모르겠어서 스와이프 휙 했을 때 넘어가지 않도록 임의의 큰 수로 지정했다.
Drag의 현재상태가 End이면 아이템을 삭제하도록 구현하였다.
Box(
modifier = Modifier
.height(200.dp)
.padding(vertical = 10.dp)
){
DismissBackground(
offset = IntOffset(x = (-anchorDraggableState.requireOffset() + screenWidthPx).roundToInt(), y = 0),
rowSize = configuration.screenWidthDp.dp,
)
MovieCard(
movie = movie,
onMoveMovieDetail = onMoveMovieDetail,
anchorDraggableState = anchorDraggableState
)
}
MovieCard
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MovieCard(
movie : Movie,
onMoveMovieDetail: (Int, String) -> Unit,
anchorDraggableState: AnchoredDraggableState<DragValue>
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(vertical = 10.dp)
.clickable {
onMoveMovieDetail(movie.id, movie.title)
}
.offset {
IntOffset(
x = -anchorDraggableState
.requireOffset()
.roundToInt(), y = 0
)
}
.anchoredDraggable(
state = anchorDraggableState,
orientation = Orientation.Horizontal,
reverseDirection = true
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
) {
Box(
modifier = Modifier
.height(200.dp)
.width(150.dp)
) {
CoilImage(
imageModel = {
BASE_IMAGE_URL + movie.posterPath
},
imageOptions = ImageOptions(
contentScale = ContentScale.Crop,
alignment = Alignment.Center
)
)
}
Column(
modifier = Modifier
.padding(vertical = 5.dp)
) {
Text(
modifier = Modifier
.weight(1f),
text = "제목: ${movie.title}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(1f),
text = "개봉일: ${movie.releaseDate}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(3f),
text = "요약: ${movie.overview}",
maxLines = 2
)
}
}
}
}
DismissBackground
@Composable
fun DismissBackground(
offset: IntOffset,
rowSize : Dp
) {
Row(
modifier = Modifier
.width(rowSize)
.height(200.dp)
.padding(vertical = 10.dp)
.background(
color = Purple80,
shape = RoundedCornerShape(13.dp)
)
.padding(start = 20.dp)
.offset { offset },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = "삭제",
style = TextStyle(
fontSize = 30.sp,
fontWeight = FontWeight.Bold
),
color = Color.White
)
}
}
SwipeToDismissBox 구현과의 차이점은 modifier에 offset을 지정하여 컴포저블의 수평 위치를 설정한다는 것이다.
x값을 -anchorDraggableState.requireOffset() 설정하여 오른쪽에서 왼쪽으로 이동시키게 한다.
DismissBackground에는 requireOffset()에 screenWidth를 더해서 화면 오른쪽 끝에 배치시키도록 한다.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MovieItemComponent(
movie : Movie,
onMoveMovieDetail : (Int, String) -> Unit,
onRemove: (Movie) -> Unit
) {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthPx = configuration.screenWidthDp.dp.toPx(density)
val anchors = DraggableAnchors {
DragValue.START at 0f
DragValue.CENTER at 0f
DragValue.END at screenWidthPx
}
val anchorDraggableState = remember {
AnchoredDraggableState(
initialValue = DragValue.START,
anchors = anchors,
positionalThreshold = { distance : Float -> distance * 0.5f },
velocityThreshold = { 1000000f },
animationSpec = tween(),
confirmValueChange = {
when(it) {
DragValue.START -> false
DragValue.CENTER -> false
DragValue.END -> true
}
}
)
}
LaunchedEffect(anchorDraggableState.currentValue) {
if (anchorDraggableState.currentValue == DragValue.END) {
onRemove(movie)
}
}
Box(
modifier = Modifier
.height(200.dp)
.padding(vertical = 10.dp)
){
DismissBackground(
offset = IntOffset(x = (-anchorDraggableState.requireOffset() + screenWidthPx).roundToInt(), y = 0),
rowSize = configuration.screenWidthDp.dp,
)
MovieCard(
movie = movie,
onMoveMovieDetail = onMoveMovieDetail,
anchorDraggableState = anchorDraggableState
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MovieCard(
movie : Movie,
onMoveMovieDetail: (Int, String) -> Unit,
anchorDraggableState: AnchoredDraggableState<DragValue>
) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(vertical = 10.dp)
.clickable {
onMoveMovieDetail(movie.id, movie.title)
}
.offset {
IntOffset(
x = -anchorDraggableState
.requireOffset()
.roundToInt(), y = 0
)
}
.anchoredDraggable(
state = anchorDraggableState,
orientation = Orientation.Horizontal,
reverseDirection = true
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
) {
Box(
modifier = Modifier
.height(200.dp)
.width(150.dp)
) {
CoilImage(
imageModel = {
BASE_IMAGE_URL + movie.posterPath
},
imageOptions = ImageOptions(
contentScale = ContentScale.Crop,
alignment = Alignment.Center
)
)
}
Column(
modifier = Modifier
.padding(vertical = 5.dp)
) {
Text(
modifier = Modifier
.weight(1f),
text = "제목: ${movie.title}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(1f),
text = "개봉일: ${movie.releaseDate}",
style = TextStyle(
fontSize = 16.sp
)
)
Text(
modifier = Modifier
.weight(3f),
text = "요약: ${movie.overview}",
maxLines = 2
)
}
}
}
}
@Composable
fun DismissBackground(
offset: IntOffset,
rowSize : Dp
) {
Row(
modifier = Modifier
.width(rowSize)
.height(200.dp)
.padding(vertical = 10.dp)
.background(
color = Purple80,
shape = RoundedCornerShape(13.dp)
)
.padding(start = 20.dp)
.offset { offset },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = "삭제",
style = TextStyle(
fontSize = 30.sp,
fontWeight = FontWeight.Bold
),
color = Color.White
)
}
}
fun Dp.toPx(density: Density) : Float {
return with(density) { this@toPx.toPx() }
}
커스텀 swipe 삭제는 AnchoredDraggableStated와 offset에 대한 이해가 부족하여 설명이 부족합니다. 참고로만 활용해주시길 바랍니다.