본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] LazyColumn, LazyRow 의 항목 삭제가 정상적으로 이루어지지 않는 이슈

Unsplash, Naveen Kumar.

개발 도중, 제목과 같은 이슈가 발생했습니다. 아래는 이해를 돕기 위한 영상입니다.

 

 

야키토리, 휘낭시에, 스시, 생고기 네 장의 사진을 불러왔고, 그 중 두 개의 사진을 삭제합니다.

LazyList 의 itemsIndexed() 메서드를 사용했고, 리스트에서의 삭제를 위해 index 를 인자로 넘깁니다.

첫번째로는 스시를 삭제하고, 두번째로는 휘낭시에를 삭제합니다. 

그렇다면, 남아있어야 할 사진은 야키토리생고기이지만, 실제로 삭제 후 화면에 남은 사진은 야키토리휘낭시에 입니다. 즉, 의도와 다르게 엉뚱한 이미지가 삭제되는 것(처럼 보이는 상황)입니다.

 

위와 같은 상황을 겪고 계신다면, 당연히 디버깅을 진행하셨으리라 생각합니다. 디버깅 해보신 분들은 아시겠지만, 리스트에서는 정상적으로 의도한 사진이 삭제되고 있습니다. 그렇다면 우리는 자연스럽게 Compose 를 의심할 수 있겠지요.

 


해결 방법

어떤 방식으로 해결하여야 할까요?

이는 LazyDsl 내의 items() 메서드에 붙은 주석으로 알 수 있습니다. 다음 텍스트는 items() 의 파라미터인 key 에 대한 주석입니다.

 

key - a factory of stable and unique keys representing the item. Using the same key for multiple items in the list is not allowed. Type of the key should be saveable via Bundle on Android. If null is passed the position in the list will represent the key. When you specify the key the scroll position will be maintained based on the key, which means if you add/remove items before the current visible item the item with the given key will be kept as the first visible one.

 

항목을 나타내는 안정적이고 고유한 키의 팩토리라고 하는데요. null 이 전달되면 목록 내 아이템의 position 이 key 가 됩니다. key 를 지정해주면 전달된 items 의 index 에 맞게 LazyList 가 구성됨을 알려주고 있습니다.

 

이해가 잘 가지 않는다면, 메서드의 코드를 참고해보면 될 것입니다.

 

inline fun <T> LazyListScope.itemsIndexed(
    items: List<T>,
    noinline key: ((index: Int, item: T) -> Any)? = null,
    crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null },
    crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
) = items(
    count = items.size,
    key = if (key != null) { index: Int -> key(index, items[index]) } else null,
    contentType = { index -> contentType(index, items[index]) }
) {
    itemContent(it, items[it])
}

 

key 가 null 이 아닌 경우, LazyList 에서의 index 와 items[index] 를 key 로 묶어, LazyList 를 구성하는 item 에 직접 전달합니다.

 

@OptIn(ExperimentalFoundationApi::class)
internal class LazyListScopeImpl : LazyListScope {

    private val _intervals = MutableIntervalList<LazyListIntervalContent>()
    val intervals: IntervalList<LazyListIntervalContent> = _intervals

    private var _headerIndexes: MutableList<Int>? = null
    val headerIndexes: List<Int> get() = _headerIndexes ?: emptyList()

    override fun items(
        count: Int,
        key: ((index: Int) -> Any)?,
        contentType: (index: Int) -> Any?,
        itemContent: @Composable LazyItemScope.(index: Int) -> Unit
    ) {
        _intervals.addInterval(
            count,
            LazyListIntervalContent(
                key = key,
                type = contentType,
                item = itemContent
            )
        )
    }

 

LazyList 는 내부적으로 IntervalList 라는 객체를 사용합니다. 이는 화면에 보이는 범위에 해당하는 아이템들만 그려내기 위해 존재합니다. override 된 items() 메서드의 인자인 key 역시, 우리가 이전에 인자로 넣어주었던 key 와 같은 값입니다.

 

해당 key는 LazyLayoutItemProvider 에서 비로소 사용됩니다.

 

@ExperimentalFoundationApi
internal fun generateKeyToIndexMap(
    range: IntRange,
    list: IntervalList<LazyListIntervalContent>
): Map<Any, Int> {
    val first = range.first
    check(first >= 0)
    val last = minOf(range.last, list.size - 1)
    return if (last < first) {
        emptyMap()
    } else {
        hashMapOf<Any, Int>().also { map ->
            list.forEach(
                fromIndex = first,
                toIndex = last,
            ) {
                if (it.value.key != null) {
                    val keyFactory = requireNotNull(it.value.key)
                    val start = maxOf(first, it.startIndex)
                    val end = minOf(last, it.startIndex + it.size - 1)
                    for (i in start..end) {
                        map[keyFactory(i - it.startIndex)] = i
                    }
                }
            }
        }
    }
}

 

key 가 null 이 아닌 경우, 이를 keyToIndexMap 이라는 Map 객체에 데이터 객체와 index 를 매핑하여 저장하게 됩니다.  이후, 해당 Map 객체는 findIndexByKey() 메서드에 사용됩니다.

 

@ExperimentalFoundationApi
internal fun LazyLayoutItemProvider.findIndexByKey(
    key: Any?,
    lastKnownIndex: Int,
): Int {
    if (key == null) {
        // there were no real item during the previous measure
        return lastKnownIndex
    }
    if (lastKnownIndex < itemCount &&
        key == getKey(lastKnownIndex)
    ) {
        // this item is still at the same index
        return lastKnownIndex
    }
    val newIndex = keyToIndexMap[key]
    if (newIndex != null) {
        return newIndex
    }
    // fallback to the previous index if we don't know the new index of the item
    return lastKnownIndex
}

 

 

List 의 특정 Index 에 대해 할당하여야 할 객체를 우리가 지정해준 key 를 통해 keyToIndexMap 객체에서 찾아 반환합니다. 즉, key 인자만 설정해준다면 문제없이 LazyList 를 재구성할 수 있습니다.

 

key 인자를 넘겨 줄 경우, item 의 modifier 에 animateItemPlacement() 메서드를 적용하여 애니메이션을 추가할 수 있다.

 

해당 이슈는 공식 문서에 해결 방법이 잘 나와있습니다.

 

 

 

컴포저블 수명 주기  |  Jetpack Compose  |  Android Developers

컴포저블 수명 주기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 컴포저블의 수명 주기에 관해 알아보며 Compose에서 컴포저블에 재구성

developer.android.com