Primary/Compose

[Compose] Movable Content

해스끼 2022. 8. 13. 12:00

Composition 단계에서 UI 트리의 내용을 옮길 수 있다면 많은 이득을 얻을 수 있다. Recomposition 과정 속에서 composable을 다시 만들지 않고 그대로 옮김으로써 내부 상태를 보존할 수 있기 때문이다. 다음 코드를 보자.

@Composable
fun MyApplication() {
    if (Mode.current == Mode.Landscape) {
        Row {
           Tile1()
           Tile2()
        }
    } else {
        Column {
           Tile1()
           Tile2()
        }
    }
}

``Mode``의 값이 바뀌면 ``Tile1()``과 ``Tile2()`` 내부의 모든 값이 초기화된다. 하지만 tile 두 개를 하나의 composition 묶음으로 생각하면 어떨까?

@Composable
fun MyApplication() {
    val tiles = remember {
        movableContentOf {
            Tile1()
            Tile2()
        }
    }
    if (Mode.current == Mode.Landscape) {
        Row { tiles() }
    } else {
        Column { tiles() }
   }
}

이렇게 하면 ``Tile1()``과 ``Tile2()``이 온전히 재사용되므로 내부의 상태 역시 보존된다.

 

Movable content의 movable은 UI 트리에서 composable을 원래 상태 그대로 자유롭게 움직일 수 있음을 뜻한다. 

또다른 사용 예시

Movable content를 잘 사용하면 미묘한 성능 및 로직 이슈를 해결할 수 있다. 다음 코드를 보자.

@Composable
fun <T> NaiveTwoColumns(items: List<T>, composeItem: @Composable (item: T) -> Unit) {
    val half = items.size / 2
    Row {
        Column {
            for (item in items.take(half)) {
                composeItem(item)
            }
        }
        Column {
            for (item in items.drop(half)) {
                composeItem(item)
            }
        }
    }
}

``items``를 두 개의 열로 나누어 배치하는 코드이다. 이 코드의 문제점은 ``items``이 하나만 바뀌어도 ``NaiveTwoColumns`` 전체가 recomposition된다는 점이다. 마찬가지로 ``composeItem(item)``도 모두 다시 실행되고, 내부 상태가 모두 사라지게 된다.

 

``item``에 key를 설정하면 될까?

@Composable
fun <T> KeyedTwoColumns(items: List<T>, composeItem: @Composable (item: T) -> Unit) {
    val half = items.size / 2
    Row {
        Column {
            for (item in items.take(half)) {
                key(item) {
                    composeItem(item)
                }
            }
        }
        Column {
            for (item in items.drop(half)) {
                key(item) {
                    composeItem(item)
                }
            }
        }
    }
}

Key를 설정하면 불필요한 recomposition을 대폭 줄일 수 있다. 하지만 아직 문제가 완전히 해결되지 않았다. 두 ``Column`` 사이에 key가 공유되지 않기 때문이다.

 

``movableContentOf`` 함수를 사용하면 UI 트리에서 아이템을 다시 만드는 대신 위치만 바꿀 수 있고, 따라서 내부 상태도 보존된다.

@Composable
fun <T> ComposedTwoColumns(items: List<T>, composeItem: @Composable (item: T) -> Unit) {
    val half = items.size / 2
    val composedItems =
        items.map { item -> item to movableContentOf { composeItem(item) } }.toMap()

    Row {
        Column {
            for (item in items.take(half)) {
                composedItems[item]?.invoke()
            }
        }
        Column {
            for (item in items.drop(half)) {
                composedItems[item]?.invoke()
            }
        }
    }
}

마지막으로 ``composedItems``를 적절히 remember하기만 하면 recomposition이 발생해도 아이템과 내부 상태를 보존할 수 있다. ``List<T>``의 확장 함수로 완성하면 다음과 같다.

fun <T> List<T>.movable(
  transform: @Composable (item: T) -> Unit
): @Composable (item: T) -> Unit {
    val composedItems = remember(this) { mutableMapOf<T, () -> Unit>() }
    return { item: T -> composedItems.getOrPut(item) { movableContentOf { transform(item) } } }
}

사용 예시는 다음과 같다.

NaiveTwoColumns(items, items.movable { ItemContent(it) })

공부하면서 보니 생각보다 쓸 일이 많지는 않을 듯하다. 그래도 알아두면 좋으니까.

참고

 

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/design/movable-content.md

 

cs.android.com