필자가 업무를 진행하다 iOS에서는 익숙하지만 Android에서는 그렇게 익숙하지 않은 제스처 이벤트인 Swipe 하여 Item의 데이터를 변경하는 방법에 대하여 찾아보게 되었다.
빠르게 확인하고 적용해본 결과 서비스에 해당 기능은 추가하지 않았지만 작성해 두면 생각보다 사용할만한 곳들이 많이 생길 것 같아서 예제를 만들어보기로 했다.
이번 예제는 Compose에서 제공해주는 SwipeToDismiss Component를 사용하여 해당 이벤트를 구현하였으며, 실무에서 사용할 때 사용할법한 이벤트를 추가하여 구현하였다.
우선, 위에서 언급한대로 SwipeToDismiss Component부터 확인해 보자.
fun SwipeToDismiss(
state: DismissState,
modifier: Modifier = Modifier,
directions: Set<DismissDirection> = setOf(EndToStart, StartToEnd),
dismissThresholds: (DismissDirection) -> ThresholdConfig = {
FixedThreshold(DISMISS_THRESHOLD)
},
background: @Composable RowScope.() -> Unit,
dismissContent: @Composable RowScope.() -> Unit
)
dismiss를 수행할 State를 가지고 있는 DismissState,
swipe 이벤트가 들어가는 케이스를 저장하는 directions,
swipe 이벤트가 들어가는 수치를 저장하는 dismissThresholds,
그리고 사라질 아이템인 dismissContent와 그 background에 깔릴 view.
modifier를 제외하고 총 5가지의 파라미터를 가지고 있는 Component이다.
여기서 사용되는 파라미터들을 하나씩 확인해 보도록 하자.
class DismissState(
initialValue: DismissValue,
confirmStateChange: (DismissValue) -> Boolean = { true }
) : SwipeableState<DismissValue>(initialValue, confirmStateChange = confirmStateChange)
default로 사용될 데이터인 initialvalue와 dismissState가 변경될 때마다 호출되는 이벤트인 confirmStatechange가 있다.
여기서 중요한 것은 confirm 값이다.
default로 true로 되어있는 것은 Dismissvalue에 대한 모든 값을 true로 설정한다는 것인데, 여기서 false로 지정하게 되면 해당 이벤트는 사용하지 않는다는 뜻이 된다.
이것이 무슨 말인가? 싶을 수 있는데 다음 사용 코드를 보면 이해가 될 것이다.
val dismissState = rememberDismissState(
confirmStateChange = { dismissValue ->
when (dismissValue) {
DismissValue.DismissedToEnd -> {
// left to right event
false
}
DismissValue.DismissedToStart -> {
// right to left event
true
}
else -> {
false
}
}
}
)
dismissValue에서 확인 가능한 데이터는 총 3가지로 다음과 같다.
Default는 아무런 이벤트가 발생하기 전의 상태를 말하는 것이고, DismissedToEnd는 -> 방향으로 스와이프 하는 케이스, DismissedToStart는 <- 방향으로 스와이프 하는 케이스에서 확인할 수 있는 값이다.
즉, 위의 코드에서 확인할 수 있는 것은 <- 방향으로 스와이프 하는 경우에만 true로 반환하기 때문에 정상적으로 dismiss 이벤트가 수행되고, 그 외의 케이스에서는 false로 반환하기 대문에 dismiss 이벤트가 수행되지 않는다.
여기서 중요한 점은, dismiss 이벤트가 수행되지 않을 뿐이지 false로 반환하기 전까지의 코드는 항상 수행되기 때문에 swipe를 했을 때 별도의 처리가 필요한 경우 해당 block에 수행할 함수를 작성해 주면 된다.
다음으로 directions는 별도로 설정은 하지 않아도 되지만, 특정 이벤트를 발생조차 시키지 않고 싶은 경우 커스텀을 하면 된다.
directions: Set<DismissDirection> = setOf(EndToStart, StartToEnd),
default로 설정되어 있는 값처럼,
directions = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd)
커스텀 또한 이렇게 수행해 주면 된다.
여기서 DismissDirection에 대한 값은
이것이 전부이기 때문에 별도로 설정하지 않아도 괜찮다는 것이다.
여기서 startToEnd를 제거하면 왼쪽에서 오른쪽으로 swipe 하는 이벤트를 사용하지 않는다는 것이고,
endToStart를 제거하면 오른쪽에서 왼쪽으로 swipe하는 이벤트를 사용하지 않는다는 것이다.
그럼 여기서, dismissState에서 false로 설정하는 것과 directions에서 설정하지 않는 것과의 차이점이 무엇인가? 같은 것이 아닌가?라고 의문이 생길 수 있는데, 실제로 제스처 이벤트가 수행되는가 안되는가 여부에 차이가 발생한다.
directions에서 설정을 제거하게 되면 해당 방향으로의 swipe 이벤트가 발생하지 않게 되고,
dismissState에서 false로 설정하게 되면, swipe 이벤트는 발생하지만 dismiss 이벤트가 발생하지 않고 다시 item이 원래 자리로 이동하게 된다.
이것에 따른 결과는 다음 영상에서 명확하게 확인할 수 있다.
왼쪽에서 오른쪽으로 swipe 하는 경우 다시 원래대로 돌아가며, 오른쪽에서 왼쪽으로 swipe 하는 이벤트는 발생하지 않는 것을 확인할 수 있다.
다음으로는 dismissThresholds이다.
dismiss 이벤트가 발생하는 수치를 설정하는 것인데, 이것도 영상을 보면 한 번에 이해가 가능할 것이다.
좌우 스와이프 시 이벤트 발생 시점이 다른 것을 확인할 수 있다.
이처럼 커스텀하기 위해서는 다음과 같이 설정하면 된다.
dismissThresholds = { direction ->
FractionalThreshold( // Deprecated
if (direction == DismissDirection.StartToEnd) {
0.2f
} else {
0.6f
}
)
}
오른쪽으로 스와이프 하는 경우를 0.2, 왼쪽으로 스와이프 하는 경우를 0.6으로 하면, 해당 지점까지 스와이프가 발생했을 때 dismiss 이벤트를 수행한다고 생각하면 된다.
여기서 중요한 부분은, FractionalThreshold가 deprecated 됐다는 점이다.
따라서 해당 부분을 AnchoredDraggable으로 변경하라고 메시지를 확인할 수 있는데, 이것에 대해서는 나중에 처리하고 글을 작성하도록 하겠다.
마지막으로 background와 dismissContent는 각각 dismissToSwipe가 동작하는 아이템의 background와 Content를 설정해 주는 부분이다.
위의 샘플로 보자면, CardView로 이루어져 있는 Text가 들어간 부분이 dismissContent로 들어가고, 이벤트 시 색상이 변경되는 부분이 Background로 들어가므로 각각 해당 UI를 그려주면 된다.
그렇다면 실제로 구현하려면 어떻게 해야 할지 위의 영상에 나온 UI를 구현해 보도록 하자.
실제로 구현하는 것 자체는 위의 설명만 사용해서 구현이 가능하지만, 여기서 중요한 부분은 백그라운드 애니메이션 부분이라고 생각하므로 이것들을 제외하곤 간단하게 설명하고 넘어가도록 하겠다.
우선, 각각의 아이템들은 별도의 state를 가지고 있기 때문에 반복문 안에 해당 state를 선언해서 별개로 상태 값을 가질 수 있도록 해야 한다.
itemsIndexed(items) { index, data ->
val dismissState = rememberDismissState(
confirmStateChange = { dismissValue ->
when (dismissValue) {
...
}
}
)
...
}
다음으로는 SwipeToDismiss를 구현해서 기본 토대를 만들어준다.
SwipeToDismiss(
state = dismissState,
dismissThresholds = { direction ->
FractionalThreshold( // Deprecated
if (direction == DismissDirection.StartToEnd) {
startDismissEventRatio
} else {
endDismissEventRatio
}
)
},
directions = setOf(
DismissDirection.StartToEnd,
DismissDirection.EndToStart
), // dismiss Animation이 가능한 부분 설정.
background = {
...
},
dismissContent = {
...
}
)
dismissContent에서는 즐겨찾기 이벤트를 주면 별 아이콘을 넣어주고, 삭제 이벤트를 주면 해당 아이템을 안 보이게 감춰야 한다.
...
dismissContent = {
// isDismissItem 만 사용하면 천천히 스크롤했을 때 이슈 발생.
// recomposable이 발생할 때, false로 설정 후 true로 변경되면서 아래로 아이템 크기만큼 스크롤.
val cardModifier = if (data.isDismiss || isDismissItem) {
Modifier
.fillMaxWidth()
.height(0.dp)
} else {
Modifier
.fillMaxWidth()
.padding(10.dp)
}
Card(
modifier = cardModifier
.onGloballyPositioned { coordinates ->
// LazyColumn의 실제 너비를 측정하여 변수에 저장
cardItemWidth = coordinates.size.width.toFloat()
},
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.padding(end = 12.dp),
text = "${data.item}[${data.key}]",
)
if (isFavoriteItem) {
Image(
modifier = Modifier.size(16.dp),
painter = rememberVectorPainter(image = Icons.Default.Star),
colorFilter = ColorFilter.tint(Color.Black),
contentDescription = "favorite icon"
)
}
}
}
}
}
data class SwipeItem(
val item: String,
var key: Int,
var isFavorite: Boolean = false,
var isDismiss: Boolean = false,
)
이벤트 부분은 뒤에 서술하겠지만, 이벤트 발생 시 데이터를 변경해서 ui를 변경해 주도록 한다.
실무에서 사용할 때 삭제 이벤트가 발생하면 실제로 데이터를 제거해 주면 되겠지만, 이번 예제에서는 고정된 데이터를 가지고 만들어보는 예제이므로 height를 0으로 만들어서 안 보이도록 처리하도록 하였다.
여기에서 oGloballPositioed를 사용한 이유는, 백그라운드 부분에서 애니메이션을 적용하는 데 사용할 데이터로 아이템의 width를 구하기 위해서이다. 해당 값을 사용하지 않는다면 신경 쓰지 않아도 된다.
가장 중요한 background 부분을 확인하기 앞서, dismissState부터 보고 넘어가도록 하자.
val dismissState = rememberDismissState(
confirmStateChange = { dismissValue ->
when (dismissValue) {
DismissValue.DismissedToEnd -> {
data.isFavorite = data.isFavorite.not()
isFavoriteItem = data.isFavorite
false // Start to End Event는 무시한다.
}
DismissValue.DismissedToStart -> {
coroutineScope.launch {
// delay를 주지 않으면, Animation 도중에 view가 사라져서 어색함.
delay(500L)
val deleteItem = items.indexOfFirst {
it.key == data.key
}
if (deleteItem != -1) {
data.isDismiss = true
isDismissItem = true
}
}
true
}
else -> {
false
}
}
}
)
좌에서 우로 스와이프 했을 때는 즐겨찾기 이벤트를 주었으므로 favorite 관련 설정을 변경해 주고,
우에서 좌로 스와이프 했을 때는 삭제 이벤트를 주었으므로, dismiss 관련 설정을 변경해 주었다.
여기서 0.5초의 딜레이를 준 이유는, 삭제 시 백그라운드가 변경되는 이벤트가 보이는데 이가 완전히 끝나기 전 height를 0으로 만들어 아이템을 감추다 보니 애니메이션의 동작이 어색하게 느껴져서 추가한 부분이다.
애니메이션이 끝나는데 0.5초가 걸리지 않기 때문에 이를 기다린 후 제거 이벤트가 발생하게 된다.
또한, DismissToEnd의 return 값이 false인 이유는, 즐겨찾기를 했을 때는 해당 아이템이 없어지는 게 아니라 그대로 남아있으면서 보이는 UI가 변경되어야 하므로, 없어지는 이벤트를 적용하지 않기 위해 false로 설정해 준다.
그럼 background를 확인하자.
background = {
AnimatedContent(
targetState = Pair(
dismissState.dismissDirection, // dismissDirection
enableDismissDirection != null // enableDismissDirection
),
transitionSpec = {
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
}, label = "좌우 아이템 Animation"
) { (dismissDirection, enableDismissDirection) ->
val backgroundSize =
remember { Animatable(if (enableDismissDirection) 0f else 1f) }
val iconSize =
remember { Animatable(if (enableDismissDirection) 0.8f else 1f) }
LaunchedEffect(key1 = Unit, block = {
if (enableDismissDirection) {
backgroundSize.snapTo(0f) // 크기를 0으로 설정한다. 이 때, 애니메이션은 다 취소된다.
launch {
backgroundSize.animateTo(1f, animationSpec = tween(400))
} // 크기를 100%로 설정한다. 애니메이션을 추가한다.
iconSize.snapTo(0.8f) // 아이콘 사이즈를 80%로 설정 한다. 이 때, 애니메이션은 다 취소된다.
iconSize.animateTo(
1.25f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) // 크게 보이게 하고,
iconSize.animateTo(1f) // 원래 크기로 돌아온다.
}
})
Box(
modifier = Modifier
.fillMaxSize()
.clip(
// 크기에 따라서 Circle Animation을 설정한다.
// LaunchedEffect 에서 0, 1을 변경한다.
CircleAnimationPath(
backgroundSize.value,
dismissDirection == DismissDirection.StartToEnd
)
)
.background(
// 백그라운드 색상을 아이콘 색상으로 반전시킨다.
color = when (dismissDirection) {
DismissDirection.StartToEnd -> if (enableDismissDirection) {
Color(0xFFFFFF00)
} else {
Color.Black
}
DismissDirection.EndToStart -> if (enableDismissDirection) {
Color(0xFF8B0000)
} else {
Color.Black
}
else -> Color.Transparent
},
)
) {
Box(
modifier = Modifier
.align(
// 드래깅 방향에 따라서 아이콘을 보여준다.
when (dismissDirection) {
DismissDirection.StartToEnd -> Alignment.CenterStart
else -> Alignment.CenterEnd
}
)
.fillMaxHeight()
.padding(horizontal = 20.dp)
.scale(iconSize.value),
contentAlignment = Alignment.Center
) {
// 아이콘 색상을 백그라운드 색상으로 반전시킨다.
when (dismissDirection) {
DismissDirection.StartToEnd -> {
Image(
painter = rememberVectorPainter(image = Icons.Default.Star),
colorFilter = ColorFilter.tint(
if (enableDismissDirection) {
Color.Black
} else {
Color(0xFFFFFF00)
}
),
contentDescription = "favorite icon"
)
}
DismissDirection.EndToStart -> {
Image(
painter = rememberVectorPainter(image = Icons.Default.Delete),
colorFilter = ColorFilter.tint(
if (enableDismissDirection) {
Color.Black
} else {
Color(0xFF8B0000)
}
),
contentDescription = "dismiss icon"
)
}
else -> {}
}
}
}
}
}
설명이 필요한 부분에 주석을 추가했기 때문에, github에서 전체 코드를 보더라도 쉽게 이해가 가능할 것이다.
그럼 이해하기 쉽게 순차적으로 코드를 살펴보자.
AnimatedContent(
targetState = Pair(
dismissState.dismissDirection, // dismissDirection
enableDismissDirection != null // enableDismissDirection
),
transitionSpec = {
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
}, label = "좌우 아이템 Animation"
)
Background에서 애니메이션을 주기 위해 AnimatedContent라는 Component를 사용하였다. 해당 Component는 targetState가 변경될 때 content에 애니메이션을 적용할 수 있는 컨테이너이다.
즉, 위의 코드로 보았을 때 dismissState 이벤트가 발생하고, enableDismissDirection 값이 null이 아닐 때 동작하게 된다.
여기서 enableDismissDirection 값은 뒤에 어떤 역할을 하는지 작성할 것이므로 우선 무시하고, dismissState 이벤트가 발생하면 동작한다고 생각하면 된다.
val backgroundSize =
remember { Animatable(if (enableDismissDirection) 0f else 1f) }
val iconSize =
remember { Animatable(if (enableDismissDirection) 0.8f else 1f) }
LaunchedEffect(key1 = Unit, block = {
if (enableDismissDirection) {
backgroundSize.snapTo(0f) // 크기를 0으로 설정한다. 이 때, 애니메이션은 다 취소된다.
launch {
backgroundSize.animateTo(1f, animationSpec = tween(400))
} // 크기를 100%로 설정한다. 애니메이션을 추가한다.
iconSize.snapTo(0.8f) // 아이콘 사이즈를 80%로 설정 한다. 이 때, 애니메이션은 다 취소된다.
iconSize.animateTo(
1.25f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) // 크게 보이게 하고,
iconSize.animateTo(1f) // 원래 크기로 돌아온다.
}
})
enableDismissDirection 값이 true이면 background와 icon에 대한 애니메이션 이벤트를 넣어준다.
background의 크기를 0f로 설정. 즉, 안 보이게 한 후에 1f까지 늘리는데 0.4초의 시간 동안 애니메이션을 주면서 늘리도록 한다.
또한, icon의 크기를 0.8f로 설정. 즉, 기존 크기의 80%로 줄였다가 125%로 키웠다가 다시 100%로 설정하는 동작을 수행한다. Damping Effect를 주어 해당 이벤트가 발생했을 때 큰 아이콘이 흔들림으로써 조금 더 직관적으로 해당 이벤트가 발생했음을 보여줄 수 있다.
이렇게 말이다.
이쯤 되면 enableDismissDirection 값이 무엇이길래 이렇게 애니메이션에 사용되는가 싶을 것이므로 설명하도록 하겠다.
var enableDismissDirection: DismissDirection? by remember {
mutableStateOf(null)
}
LaunchedEffect(key1 = Unit, block = {
...
snapshotFlow { dismissState.offset.value }
.collect {
enableDismissDirection = when {
it > cardItemWidth * startDismissEventRatio -> DismissDirection.StartToEnd
it < -cardItemWidth * endDismissEventRatio -> DismissDirection.EndToStart
else -> null
}
}
})
SwipeToDimiss Component와 같은 layer에 선언되어 있는 부분이다.
enableDismissDirection는 DismissDirection를 직접 선언한 변수로 Swipe이벤트를 수행할 때 직접 커스텀한 타이밍에 해당 이벤트를 발생시키기 위해서 별도로 선언하여 사용하는 부분이다.
dismissState를 observe 하여 해당 값이 변경될 때 enableDismissDirection 값을 같이 변경시켜 준다.
이때 데이터를 그대로 사용하는 것이 아닌 dismissContent의 width 값과 직접 지정한 비율에 따라서 다르게 적용시켜 주었다.
즉, 위의 영상에서 확인할 수 있듯이 스와이프 할 때 바로 이벤트가 수행되는 것이 아닌 특정한 구간까지 swipe 해야 이벤트가 발생할 수 있는 것이 여기서 이렇게 설정을 해두었기 때문이다.
cardItemWidth는 dismissContent에서 onGloballyPositioned를 통해 가져온 데이터인데, 필자와 같이 특정 구간에 이벤트를 발생시킨다.라는 조건이 없어도 된다면 무시해도 된다.
아무튼, 특정 값을 만족할 때 비로소 swipe 이벤트가 수행된 것으로 직접 설정을 해주기 때문에, dismissState와 더불어 enableDismissDirection 값을 사용하여 애니메이션에 관련된 처리를 해준 것이다.
Box(
modifier = Modifier
.fillMaxSize()
.clip(
// 크기에 따라서 Circle Animation을 설정한다.
// LaunchedEffect 에서 0, 1을 변경한다.
CircleAnimationPath(
backgroundSize.value,
dismissDirection == DismissDirection.StartToEnd
)
)
.background(
// 백그라운드 색상을 아이콘 색상으로 반전시킨다.
color = when (dismissDirection) {
DismissDirection.StartToEnd -> if (enableDismissDirection) {
Color(0xFFFFFF00)
} else {
Color.Black
}
DismissDirection.EndToStart -> if (enableDismissDirection) {
Color(0xFF8B0000)
} else {
Color.Black
}
else -> Color.Transparent
},
)
) { // Icon Content }
background에 깔아줄 UI 부분인데, Box의 background 부분은 dismissState에 따라서 색상을 변경시켜 주었다.
기존엔 검은색으로 깔아 두고, 이벤트가 발생하면 노란색과 붉은색으로 변경시켜주었는데, 0f로 설정하고 1f로 0.4초 동안 변경하는 애니메이션이 있으므로 0.4초에 걸쳐서 background의 색상이 변경되게 될 것이다.
그리고 동작하는 애니메이션은 CircleAnimationPath라는 함수를 사용하여 적용시켜 주었는데, 해당 부분은 직접 만드는 게 어려워서 Google에서 검색해서 찾아내어 사용하였다.
마지막으로 Icon Content 영역에는 dismissState에 따라서 보여줄 Icon을 설정해주기만 하면 되는 부분이므로 해당 부분을 확인하고 싶으면 github에서 확인하길 바란다.
이것으로 swipeToDismiss에 대해 알아보고, 간단하게 애니메이션을 적용한 예제를 만들어 보았다.
해당 기능을 구현하고 로그를 찍어보니,
애니메이션이 동작함에 있어서 recompose가 상당히 많이 발생하는 것으로 보였다.
실무에서 적용하게 된다면 해당 기능이 들어가 있는 부분의 Component를 디테일하게 잘 나누지 않으면 불필요하게 다시 그려지면서 퍼포먼스가 많이 떨어질 수도 있다고 생각이 들었다.
하지만 적재적소에 적용한다면 좋은 사용자 경험을 줄 수 있는 Component라고 생각이 들었고,
해당 기능을 검토할 때 조금만 더 시간이 있었더라면 보다 사용자 경험이 좋은 UX를 만드는데 도움이 되었을 것 같은데 실제로 적용해보지 못해서 아쉽다고 생각했던 기능이다.
이번에 직접 예제를 구현해 보면서 상당히 재미있는 기능이라고 생각을 했고, 애니메이션을 조금 더 커스텀할 수 있으면 개성 있는 서비스를 만드는데 도움이 될 것 같다고 생각이 들었다.
해당 게시글에 사용한 예제는 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
'Android > Jetpack Compose' 카테고리의 다른 글
[Compose] Compose환경 WebView에서 JavascriptInterface를 사용할 때 주의할 점 (0) | 2024.05.26 |
---|---|
[Compose] Side Effect 관련 API 재 정리 (0) | 2024.05.06 |
[Compose] 다양한 방법으로 Drawer를 구현해보자 - Scaffold, ModalDrawer (0) | 2024.04.16 |
[Compose] 다양한 방법으로 BottomSheet를 구현해보자. - BottomSheetScaffold, ModalBottomSheet, CustomBottomSheet (4) | 2024.03.26 |
[Compose] LazyList에서 스크롤을 커스텀하기 위해 FlingBehavior를 사용해보자. (0) | 2024.03.03 |