본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] 동적 스켈레톤 적용기

Unsplash, Andy Holmes.

동기

여름에 시작했던 사이드 프로젝트가 끝을 향해 달려가고 있습니다. 비록 1차 개발 마무리이긴 하지만요. 아무튼, 이후로는 추가 기능 구현과 유지보수만 하면 돼서, 투입해야 하는 시간이 많이 줄어들 것 같아 다행입니다.

 

얼마 전, 대부분의 기능 구현이 끝나고 정리만 하던 시점에, 갑작스런 스켈레톤 추가 논의가 이루어졌습니다. 로딩과 관련된 다양한 UI 가 있는데, 화면 전체를 감싸는 형식의 UI 들이 대부분이라 사용자 입장에서 피로도가 있다는 것이 주된 근거였습니다. 이후 추가 구현이 확정되었고, 이에 대해 구현하여야 했습니다.

 

나이브 어프로치로서 if-else 구문 사용을 고려했으나, 기존 코드에 if-else 구문을 적용해야 하다 보니, 코드 깊이도 깊어지고 새로운 UI 코드도 작성해야 해서, 코드 복잡도가 크게 상승할 것 같아 이건 아닌 듯 싶었습니다.

 

그 다음으로 생각했던게, Layout-Composable 을 사용해서 해당 Composable 의 하위에 있는 모든 Composable 들의 Placeable 객체를 획득하여 그 자리에 스켈레톤을 그려내는 방식을 고려했습니다.

 

결론적으로 이 방식은 사용되지 않았고, 다른 방식으로 문제를 해결했습니다. 오늘은 그 과정에 대해 기록하고자 합니다.


Layout 으로 접근하기

Jetpack Compose 에는 Layout 이라는 이름의 Composable 이 있습니다. Box, Row, Column 을 구성하는 데에도 사용되는 매우 기본적인 Composable 입니다. 이름 그대로 Layout 을 구성하는 데 사용되며, Box, Row, Column 등 기본으로 제공되는 Layout-Composable 을 사용하여 문제를 해결하기에는 조금 까다롭다 싶을 때 사용하기 좋습니다.

 

저는 하위 Composable 의 크기와 위치 정보를 획득해서, 조건에 부합하는 경우에는 하위 Composable 이 배치될 자리에 같은 크기의 스켈레톤을 그려 넣는 코드를 작성하였습니다. 코드는 다음과 같습니다. 설명은 코드에 포함하였습니다.

@Composable
fun AutomaticSkeletonColumn(
    condition: Boolean,
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Arrangement.Start,
    content: @Composable () -> Unit
) {
    // Layout 에 포함된 하위 Composable 의 영역을 담기 위한 StateList
    val childRects = remember { mutableStateListOf<Pair<Offset, Size>>() }

    Box(modifier = modifier) {
        Layout(content = content) { measurables, constraints ->
            val spacingPx = verticalArrangement.spacing.roundToPx()
            // Measurable.measure() 메서드를 통해 Placeable 객체로 매핑
            val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) }
            val totalChildrenHeight = placeables.sumOf { it.height } + (placeables.size - 1).coerceAtLeast(0) * spacingPx
            val maxWidth = placeables.maxOfOrNull { it.width } ?: 0
            // 전체 Layout 너비
            val layoutWidth = maxWidth.coerceIn(
                minimumValue = constraints.minWidth,
                maximumValue = constraints.maxWidth
            )
            // 전체 Layout 높이
            val layoutHeight = totalChildrenHeight.coerceIn(
                minimumValue = constraints.minHeight,
                maximumValue = constraints.maxHeight
            )
            // VerticalArrangement 값에 따라 배치 위치가 달라지므로, 이를 저장하는 IntArray 
            val mainAxisPositions = IntArray(placeables.size)
            
            with(verticalArrangement) {
                // VerticalArrangement 값에 따라 Y 좌표를 자동 계산
                arrange(
                    totalSize = layoutHeight,
                    sizes = placeables.map { it.height }.toIntArray(),
                    outPositions = mainAxisPositions
                )
            }

            layout(layoutWidth, layoutHeight) {
                val newRects = mutableListOf<Pair<Offset, Size>>()

                placeables.forEachIndexed { index, placeable ->
                    // HorizontalAlignment 에 따른 X 좌표를 계산
                    val x = when (horizontalAlignment) {
                        Alignment.Start -> 0
                        Alignment.CenterHorizontally -> (layoutWidth - placeable.width) / 2
                        else -> layoutWidth - placeable.width
                    }
                    val y = mainAxisPositions[index] // 앞서 계산한 Y 좌표 획득

                    // 스켈레톤을 그리기 위해 현재 자식 Composable 의 좌표와 크기 정보를 리스트에 추가
                    newRects.add(Offset(x.toFloat(), y.toFloat()) to Size(placeable.width.toFloat(), placeable.height.toFloat()))

                    // 스켈레톤을 보여 줄 조건에 부합하지 않는 경우 실제 배치
                    if (!condition) {
                        placeable.placeRelative(x, y)
                    }
                }

                // 무한히 Recomposition 되는 경우를 방지하기 위한 로직
                val isChanged = childRects.size != newRects.size || childRects.zip(newRects).any { (old, new) -> old != new }

                if (isChanged) {
                    childRects.clear()
                    childRects.addAll(newRects)
                }
            }
        }

        // 스켈레톤을 보여 줄 조건에 부합하는 경우 Composable 을 배치하지 않고 스켈레톤을 Canvas 로 그려 배치
        if (condition) {
            Canvas(modifier = Modifier.matchParentSize()) {
                childRects.forEach { (offset, size) ->
                    drawRoundRect(
                        color = Color(0xFFE0E0E0),
                        topLeft = offset,
                        size = size,
                        cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx())
                    )
                }
            }
        }
    }
}

 

코드가 꽤 길긴 하지만, 단순하게 Column 을 구현하고 조건에 따라 스켈레톤을 보여주는 게 전부입니다. 이를 사용해서 UI 를 간단히 구현한 테스트 영상입니다.


스켈레톤이 필요하지 않은 Composable 에 대한 처리

하나의 큰 Layout 내에서도 스켈레톤이 필요한 Composable 이 있고, 그렇지 않은 Composable 이 있습니다. 현재는 모든 Composable 에 대해 스켈레톤을 표시하고 있기 때문에, 스켈레톤 표시 여부를 마킹해 시스템에 알릴 필요가 있었습니다.

 

당장 떠오른 방식은 Modifier.Node 를 추가로 구현해서 마킹하는 방식이었습니다. 즉, Composable 에 내부 상태를 추가하는 방법입니다. Modifier.Node 에 대해서는 아래 포스트에서 자세히 설명하고 있으니, 참고하시면 좋을 것 같습니다.

 

[Jetpack Compose] composed {} 와 Modifier.Node 로 Modifier Chain 최적화하기

동기Jetpack Compose 를 사용해서 UI 를 개발하다 보면, Modifier 의 확장 함수를 구현하여야 하는 경우가 더러 있습니다. 불필요하게 길어지는 Modifier Chaining 을 방지하기 위해서나, 자주 사용하게 되는

blothhundr.tistory.com

 

상위 Layout 이 스켈레톤을 표시하려고 하기 때문에, 하위 Composable 에는 스켈레톤을 표시하지 않아야 하는 경우를 마킹하려 했습니다. 하지만 이러한 구현은 결국, 거시적인 관점에서 코드 복잡도가 크게 상승할 수 밖에 없다고 생각했는데요. 이유는 다음과 같습니다.

  • 상위 Layout 에 대한 이해가 별도로 필요 (선언형 UI 의 직관성을 헤침)
  • 스켈레톤을 표시하기 위한 Layout 을 구현했음에도 불구하고, 하위 Composable 마킹을 위한 Modifier.Node 를 추가로 구현해야 함 (전체 코드량 증가)
  • 마킹 Modifier.Node 는 상위에 AutomaticSkeletonColumn 이 없어도 호출될 수 있으나, 아무런 기능을 하지 않음
  • 만약 AutomaticSkeletonColumn 이 매우 길어져서 코드가 한 눈에 모두 들어오지 않으면 스켈레톤이 적용된다는 사실을 알 수 없음. 특히 이 경우, AutomaticSkeletonColumn 의 개념이 없으면 왜 스켈레톤이 표시되는지 알 수 없음 (가독성 저하)

설계의 오류를 뒤늦게 깨닫고, 방향을 변경하였습니다. AutomaticSkeletonColumn 코드를 제거하고, 그냥 스켈레톤이 표시되어야 하는 Composable 에 Modifier.Node 를 추가하는 방식입니다. DrawModifierNodeLayoutModifierNode 를 활용해서 스켈레톤을 구성하는 행위 역시 Modifier 에서 진행할 수 있도록 구현합니다.


구현

class SkeletonNode(
    var condition: Boolean,
    var color: Color,
    var widthDp: Dp,
    var heightDp: Dp,
    var radiusDp: Dp
) : DrawModifierNode, LayoutModifierNode, Modifier.Node() {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val width = if (condition && widthDp > 0.dp) widthDp.roundToPx() else null
        val height = if (condition && heightDp > 0.dp) heightDp.roundToPx() else null

        val placeable = measurable.measure(
            constraints.copy(
                minWidth = width ?: constraints.minWidth,
                maxWidth = width ?: constraints.maxWidth,
                minHeight = height ?: constraints.minHeight,
                maxHeight = height ?: constraints.maxHeight
            )
        )

        return layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
        }
    }

    override fun ContentDrawScope.draw() {
        if (condition) {
            drawRoundRect(
                color = color,
                size = size,
                cornerRadius = CornerRadius(radiusDp.toPx())
            )
        } else {
            drawContent()
        }
    }
}

 

먼저, Modifier.Node 입니다. DrawModifierNode 를 사용했습니다. 구현을 위해 검색을 진행하다보니 CacheDrawModifierNode 라는 Modifier.Node 도 있었는데, 복잡한 그리기 작업과 그에 따른 최적화에 사용되는 클래스이기에 사용하지 않기로 하였습니다. LayoutModifierNode 는 Row, Column 등의 Composable 에서 스켈레톤이 지정된 위치에 올바르게 배열되도록하기 위해 사용하였습니다.

 

로직은 보시면 아시겠지만 매우 간단합니다. 조건에 맞으면 RoundRect 를 그려 표시하고, 그렇지 않은 경우 원래의 컨텐츠를 그립니다.

 

너비와 높이를 Dp 로 받아, 값이 전달되는 경우에는 전달되는 값을 사용하고, 그렇지 않은 경우에는 기존 Composable 이 차지하는 영역을 사용하도록 합니다.

data class SkeletonElement(
    val condition: Boolean,
    val color: Color,
    val widthDp: Dp,
    val heightDp: Dp,
    val radiusDp: Dp
) : ModifierNodeElement<SkeletonNode>() {

    override fun create(): SkeletonNode = SkeletonNode(
        condition = condition,
        color = color,
        widthDp = widthDp,
        heightDp = heightDp,
        radiusDp = radiusDp
    )

    override fun update(node: SkeletonNode) {
        node.condition = condition
        node.color = color
        node.widthDp = widthDp
        node.heightDp = heightDp
        node.radiusDp = radiusDp
        node.invalidateMeasurement()
        node.invalidateDraw()
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "skeleton"
        properties["condition"] = condition
        properties["color"] = color
        properties["widthDp"] = widthDp
        properties["heightDp"] = heightDp
        properties["radiusDp"] = radiusDp
    }
}

 

다음, Element 입니다. Modifier.Node 에 값을 준비했으므로, 이에 맞는 값을 파라미터로 전달 받아 적용만 해주면 되겠습니다. 이후 update() 메서드가 호출되면 condition 파라미터를 재할당하고 invalidateMeasurement() 를 호출하여 측정 후 invalidateDraw() 메서드를 호출하여 다시 그릴 수 있도록 합니다. 

 

update() 시에는 모든 값을 재할당해줍니다. 변하지 않는 값들은 어차피 Smart-Recomposition 에 의해 최적화 되기 때문에, 동적인 상황에서도 잘 동작할 수 있도록 하였습니다.

fun Modifier.skeleton(
    condition: Boolean,
    color: Color = ColorBgSurface,
    widthDp: Dp = 0.dp,
    heightDp: Dp = 0.dp,
    radiusDp: Dp = 8.dp
): Modifier = this.then(
    SkeletonElement(
        condition = condition,
        color = color,
        widthDp = widthDp,
        heightDp = heightDp,
        radiusDp = radiusDp
    )
)

 

마지막으로, 실제로 호출하게 되는 Modifier.skeleton() 메서드입니다. SekeletonElement 객체를 생성해서 then() 메서드로 Modifier 에 Node 를 추가하도록 합니다.


적용

Text(
    modifier = Modifier
        .padding(top = 4.dp)
        .skeleton(
            condition = isLoading,
            widthDp = 36.dp,
            heightDp = 22.dp
        ),
    text = value,
    style = Body1_S.copy(lineHeightStyle = LineHeightStyle.Default.copy(trim = LineHeightStyle.Trim.FirstLineTop)),
    color = ColorTextDefault
)

 

이와 같이 간편하게 사용할 수 있습니다. 적용된 화면은 다음과 같습니다. 

3초 대기 후 UI 전환

 

해결하고 보니 꽤 간단했던 문제 같은데, 생각보다 오래 걸렸습니다. 최초 설계가 잘못되었기 때문이겠습니다. 결국 첫 번째 접근 방식인 AutomaticSkeletonColumn 은 최종 코드에서 제거되었습니다.

 

Layout 을 이용해 하위 Composable 들을 일괄적으로 스켈레톤 처리하는 방식은 언뜻 편해 보이지만, 예외 사항을 해결하기 위한 마킹 Modifier.Node 가 추가되었다면 아주 사소한 예외 사항에도 코드가 크게 지저분해졌을겁니다.

 

반면 Modifier.skeleton() 은 각 Composable 명시적으로 선언하기 때문에, 코드를 읽는 사람이 별도의 컨텍스트 없이도 해당 UI 의 동작을 즉시 이해할 수 있습니다. 선언형 UI 프레임워크에 더 잘 맞는 구현이기도 하고요.

 

다만 큰 틀에서의 설계가 지금과 달랐다면 어땠을지 또 모르겠네요.

긴 글 읽어주셔서 감사합니다.