App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - item 추가ㆍ제거 애니메이션

interfacer_han 2024. 12. 15. 14:04

#1 애니메이션

LazyColumn에 아이템이 추가ㆍ제거될 때의 애니메이션 효과를 추가하려고 한다. 이 때, 그 구현 방식은 (당장 내가 보기에) 아래와 같이 2개로 나눌 수 있다. 2개의 공식 문서 링크를 달겠다. 각각은 서로 다른 구현 방식을 알려주고 있다.

 

#1-1 'Composable'의 애니메이션 처리를 위한 가이드

 

애니메이션 수정자 및 컴포저블  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 애니메이션 수정자 및 컴포저블 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose에는 일반적인

developer.android.com

가이드에 기술된 코드의 구조는 아래와 같다.

 

var visible by remember {
    mutableStateOf(true)
}
// Animated visibility will eventually remove the item from the composition once the animation has finished.
AnimatedVisibility(visible) {
    // your composable here
    // ...
}

주석 "your composable here" 부분에 컴포저블 함수를 넣으면, 해당 컴포저블 함수가 화면에 보여질 때(= visible 프로퍼티의 값이true로 변경될 때) 애니메이션 효과가 첨가된다. AnimatedVisibility()와 비슷한 동작 방식을 지닌 AnimatedContent()Crossfade()도 있다.

 

#1-2 'LazyColumn 내 item'의 애니메이션 처리를 위한 가이드

 

목록 및 그리드  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 목록 및 그리드 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 많은 앱에서 항목의 컬렉션을 표시해

developer.android.com

가이드에 기술된 코드의 구조는 아래와 같다.

 

LazyColumn {
    // It is important to provide a key to each item to ensure animateItem() works as expected.
    items(books, key = { it.id }) {
        Row(Modifier.animateItem()) {
            // ...
        }
    }
}

ModifieranimateItem()을 메소드 체이닝한다. #1-1#1-2나 Jetpack Compose답게 간편해서 좋다.

 

#1-3 처리 방식 결정

#1-1의 코드를 #1-2의 경우에도 적용할 수 있겠지만, #1-2에서 별도의 방식을 기술했다는 점에서 어떤 이유가 있을 것으로 추측한다. 단지 그 이유만으로 #1-2를 선택할 이유는 충분하긴 하다. 공식이 시키는 대로 하는 게 웬만하면 옳다. 물론 그 선택의 이유를 아무거나 하나 곱씹어볼 수도 있겠다. 눈에 보이는 이유도 있고. 그건 바로 후자의 가이드는 item 목록에 일괄적인 애니메이션 처리를 할 수 있다는 것이다. 또 메소드 체이닝 방식이라는 점에서, 재사용하거나 유연하게 적용하기 편할 것이다.

 

#2 코드 - items()로 전환

#2-1 key

바로 animateItem()을 사용하면 좋겠지만, 문서에 "You should also provide a key via LazyListScope.item/LazyListScope.items for this modifier to enable animations. (애니메이션을 활성화하려면, 이 Modifier에게 LazyListScope.item/LazyListScope.items을 통해 key를 제공해야 합니다.)"라는 문구가 보인다. 여기서 말하는 key는, 구체적으로 item() 또는 items()의 key 프로퍼티를 의미한다. item() 또는 items()에 임의의 key를 할당해놓으면 나머지는 암시적으로 수행된다.

 

실제로, key값을 제공하지않은 상태로 animateItem()도 사용해봤는데 역시 애니메이션이 적용되지 않았다. 애니메이션을 어떤 아이템에 적용해주어야 하는가?를 프로그래머가 Compose Runtime에게 알려줘야하는데, 그러질 못했으니.

 

아래 코드는 key의 데이터형을 볼 수 있는 LazyListScope.items() 함수의 모양이다.

open fun items(
    count: Int,
    key: ((index: Int) -> Any)? = null,
    contentType: (index: Int) -> Any? = { null },
    itemContent: @Composable LazyItemScope.(index: Int) -> Unit
): Unit

 

#2-2 무엇을 key로 써야 하는가?

내 프로젝트에서 구현할 애니메이션은, 낱개의 아이템에 각각 독립적으로 적용되어야 한다. 따라서 key는 고유해야 한다. 또, 아이템은 데이터베이스의 레코드 하나에 1:1 대응되므로 레코드의 id를 그대로 key로 사용하면 될 것이다.

 

#2-3 key 프로퍼티 부여

// in NutrientScreen.kt

...

@Composable
fun NutrientScreen(
    ...
) {
    ...

    LazyColumn(
        ...
    ) {
        item {
            ...
        }

        ...
        itemsIndexed(dayMeals, key = { _, dayMeal -> dayMeal.mealId }) { index, dayMeal ->
            Card(
                ...
            ) {
                ...
            }
        }
    }
}

기존에 사용하면 LazyListScope.itemsIndexed()는 이제 필요없다. 레코드를 식별할 수 있는 key값이 생겼으니 역할이 중복되는 것이다. itemIndexed()를 items()로 바꾼다.

 

#2-4 items()로 전환

// in NutrientScreen.kt

...

@Composable
fun NutrientScreen(
   ...
) {
    ...

    LazyColumn(
        ...
    ) {
        item {
            ...
        }

        ...
        items(dayMeals, key = { it.mealId }) { dayMeal ->
            val key = remember { dayMeal.mealId }

            Card(
                ...
            ) {
                ...
            }
        }
    }
}

items()로 전환한 코드다. key = ... 부분의 람다 표현식 문법이 간소화됐다. itemsIndexed()의 key 프로퍼티 형식은 ((index: Int, item) -> Any)?이고, items()는 ((index: Int) -> Any)?다. 매개변수가 2개에서 1개로 줄었으므로, it 키워드를 사용했으며 그래서 시각적으로 가벼워졌다.

 

또, (내 프로젝트의 경우) items() content 부분 로직 처리를 위해 key를 명시적으로 사용할 필요가 있기에 별도의 로컬 프로퍼티도 선언해주었다.

 

#3 코드 - 애니메이션 적용

#3-1 animateItem()

// in NutrientScreen.kt

@Composable
fun NutrientScreen(
   ...
) {
    ...

    LazyColumn(
        ...
    ) {
        item {
            ...
        }

        ...
        items(dayMeals, key = { it.mealId }) { dayMeal ->
            val key = remember { dayMeal.mealId }

            Card(
                modifier = Modifier
                    ...
                    .padding(
                        ...
                        top = if (key == dayMeals.last().mealId) 8.dp else 0.dp,
                        ...
                    )
                    .animateItem(),
                ...
            ) {
                Box(
                    ...
                ) {
                    Column(
                        ...
                    ) {
                        Text(
                            ...
                        )

                        Text(
                            text = "mealId: ${dayMeal.mealId}",
                            ...
                        )
                    }

                    IconButton(
                        ...
                    ) {
                        ...
                    }
                }
            }
        }
    }
}

animateItem()을 Card의 Modifier에 메소드 체이닝한다. 또, index가 사라졌으므로 index에 의존하던 다른 코드들도 적절하게 바꿔준다.

 

#3-2 animateItem() 커스텀

open fun Modifier.animateItem(
    fadeInSpec: FiniteAnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
    placementSpec: FiniteAnimationSpec<IntOffset>? = spring(
                stiffness = Spring.StiffnessMediumLow,
                visibilityThreshold = IntOffset.VisibilityThreshold
            ),
    fadeOutSpec: FiniteAnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow)
): Modifier

위 코드가 있는 공식 문서의 링크. #3-1에서는 animateItem() 3가지 프로퍼티엔 기본값인 spring(stiffness = Spring.StiffnessMediumLow), spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold), spring(stiffness = Spring.StiffnessMediumLow)이 각각 적용되었다.

 

세 프로퍼티 전부 FiniteAnimationSpec 데이터형인데, 여기에 할당할만한 클래스는 SpringSpec와 TweenSpec이 있다. SpringSpec에서 Spring은 우리가 아는 그 스프링(용수철)을 의미한다. 용수철이 튕기는 정도값(dampingRatio)과 용수철의 강도(stiffness)로 애니메이션을 조절한다. TweenSpec은 좀 SpringSpec에 비해 좀 더 세세하다(명령적이다). TweenSpec은 시간에 기반한다. 애니메이션이 완료되기까지의 시간(durationMillis), 애니메이션 시작 전 대기 시간(delayMillis), 애니메이션의 스타일(속도 곡선) (easing)로 애니메이션을 조절한다.

 

현재는 우선 기본값을 쓰고 넘어가지만, 나중에라도 애니메이션을 앱의 컨셉에 맞게 수정할 일이 필요할 것 같다. 그래서 이렇게 간단히 애니메이션의 커스텀에 대해 기록해둔다. 

 

#4 완성된 앱

#4-1 작동 영상

 

#4-2 이 게시글 시점의 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com

 

#4-3 본 프로젝트의 가장 최신 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com