
동기
여름에 시작했던 사이드 프로젝트가 끝을 향해 달려가고 있습니다. 비록 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 를 추가하는 방식입니다. DrawModifierNode 와 LayoutModifierNode 를 활용해서 스켈레톤을 구성하는 행위 역시 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
)
이와 같이 간편하게 사용할 수 있습니다. 적용된 화면은 다음과 같습니다.
해결하고 보니 꽤 간단했던 문제 같은데, 생각보다 오래 걸렸습니다. 최초 설계가 잘못되었기 때문이겠습니다. 결국 첫 번째 접근 방식인 AutomaticSkeletonColumn 은 최종 코드에서 제거되었습니다.
Layout 을 이용해 하위 Composable 들을 일괄적으로 스켈레톤 처리하는 방식은 언뜻 편해 보이지만, 예외 사항을 해결하기 위한 마킹 Modifier.Node 가 추가되었다면 아주 사소한 예외 사항에도 코드가 크게 지저분해졌을겁니다.
반면 Modifier.skeleton() 은 각 Composable 명시적으로 선언하기 때문에, 코드를 읽는 사람이 별도의 컨텍스트 없이도 해당 UI 의 동작을 즉시 이해할 수 있습니다. 선언형 UI 프레임워크에 더 잘 맞는 구현이기도 하고요.
다만 큰 틀에서의 설계가 지금과 달랐다면 어땠을지 또 모르겠네요.
긴 글 읽어주셔서 감사합니다.
'Android > Trouble Shoot' 카테고리의 다른 글
| [Jetpack Compose] SubcomposeLayout 으로 UI 배치 문제 해결하기 (0) | 2025.11.28 |
|---|---|
| [Jetpack Compose] 커스텀 TooltipState 로 UX 개선하기 (0) | 2025.09.18 |
| [Jetpack Compose] NestedScroll 로 UX 개선하기 (0) | 2025.08.10 |
| [Jetpack Compose] onGloballyPositioned 를 onLayoutRectChanged 로 대체하기 (0) | 2025.07.16 |
| Appium + UiAutomator2 로 Android-Web 통합 테스트 자동화하기 (0) | 2025.06.05 |