[Android][Compose] Shimmer UI 적용기

윤찬·2025년 9월 29일

Android

목록 보기
29/37

유튜브나 인스타그램같은 것을 보게 되면 데이터가 나오기 전에 회색 모양의 모습이 나오는 것을 볼 수 있다.

보통 이런 UI를 Skeleton UI라고 하는데, Shimmer UI랑 차이점이 무엇일까??

검색을 통해 알아본 바로는 아래의 차이점이 있다.

Skeleton UI: 콘텐츠가 오기 전, 레이아웃의 뼈대(고정된 회색 블록) 를 보여 주는 패턴. 어디에 이미지/텍스트가 들어올지 자리를 미리 잡아 레이아웃 점프를 줄여요. 애니메이션은 필수 아님(대부분 정적).
Shimmer UI: 그 뼈대(placeholder) 위에 빛이 스치는 듯한 애니메이션 그라디언트를 얹어 “로드 중” 느낌을 주는 효과. 즉, 흔히 skeleton + shimmer 효과로 함께 쓰이지만, 개념적으로는 skeleton=구조, shimmer=효과.

찾아본 바로는 Shimmer UI는 Skeleton UI에서 애니메이션 효과를 주는 것이라고 생각하면 될 것같다.
인스타그램 같은 경우는 돋보기를 누르면 왼쪽에서 오른쪽으로 살짝 밝아지는 Shimmer 효과를 주는 것을 볼 수 있다. (그 외에도 살짝 밝아지거나 어두워지는 방향도 있음)

이런 기능은 추후에도 많이 구현이 될 것이라 생각하고 간단한 Shimmer UI를 구현해보자

참고 문헌

Youtube : https://www.youtube.com/watch?v=NyO99OJPPec
블로그 : https://heegs.tistory.com/174

Modifier에서 shimmerEffect 만들기

먼저 Modifier를 알아보면 아래와 같다.
관련 공식 문서링크는 아래와 같다.
https://developer.android.com/develop/ui/compose/modifiers?hl=ko

수정자를 사용하면 컴포저블을 장식하거나 강화할 수 있습니다. 수정자를 통해 다음과 같은 종류의 작업을 실행할 수 있습니다.

  • 컴포저블의 크기, 레이아웃, 동작 및 모양 변경
  • 접근성 라벨과 같은 정보 추가
  • 사용자 입력 처리
  • 요소를 클릭 가능, 스크롤 가능, 드래그 가능 또는 확대/축소 가능하게 만드는 높은 수준의 상호작용 추가

즉, 컴포저블의 크기를 측정하고 이에 대한 레이아웃 동작 및 모양을 변경하기 위해 Modifier를 확장자로 사용해서 shimmerEffect 함수를 구현했다.

shimmerEffect 함수 코드는 아래와 같다.

fun Modifier.shimmerEffect(): Modifier = composed {
    var size by remember {
        mutableStateOf(IntSize.Zero)
    }

    val transition = rememberInfiniteTransition()
    val startOffsetX by transition.animateFloat(
        initialValue = -2 * size.width.toFloat(),
        targetValue = 2 * size.width.toFloat(),
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000
            )
        )
    )

    background(
        brush = Brush.linearGradient(
            colors = listOf(
                Color(0xFFB8B5B5),
                Color(0xFF8F8B8B),
                Color(0xFFB8B5B5),
            ),
            start = Offset(startOffsetX, 0f),
            end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
        )
    )
        .onGloballyPositioned {
            size = it.size
        }
}

위에서 부터 천천히 알아보자.
먼저 size는 컴포저블의 크기를 측정한 값을 저장하는 용도로 사용이 되었다.

Box를 이용해 크기를 지정을 했다면 아래와 같이 x,y의 정보를 얻는 것이다.

이 정보를 아래 코드에 보면 onGloballyPositioned를 통해 얻을 수 있다. 이 값은 해당 컴포저블의 크기와 위치 정보를 가지고 있다. 만약 크기의 정보만 사용하고 싶은 경우는 onSizeChanged를 써서 값을 가져올 수 있다. 아마 onSizeChanged가 좀 더 가볍다고는 알고있다.

여기서는 size 정보만 가져오기 때문에 onSizeChanged로 대체 가능하다. onSizeChanged는 IntSize를 받을 수 있기 때문에 그대로 it을 하면 된다.

onSizeChanged {
	size = it
}

다음은 애니메이션 역할을 하는 transition코드다. transition은 애니메이션 반복처리를 할 때 주로 사용한다. 공식 문서 링크는 아래 참조하자.
https://developer.android.com/develop/ui/compose/animation/value-based?hl=ko#rememberinfinitetransition

val transition = rememberInfiniteTransition()
    val startOffsetX by transition.animateFloat(
        initialValue = -2 * size.width.toFloat(),
        targetValue = 2 * size.width.toFloat(),
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000
            )
        )
    )

위 코드처럼 Float값이 변경될 때 하는 애니메이션 이외에도 color및. T의 객체에 따른 애니메이션 동작이 가능하다.

animateFloat의 내부 코드는 아래와 같다. 참고로 내부 코드를 들어가면 간단한 사용 예시 코드를 보여주므로 참고해서 구현이 가능하다.

@Composable
fun InfiniteTransition.animateFloat(
    initialValue: Float,
    targetValue: Float,
    animationSpec: InfiniteRepeatableSpec<Float>,
    label: String = "FloatAnimation"
): State<Float> =
    animateValue(initialValue, targetValue, Float.VectorConverter, animationSpec, label)

initialValue과 targetValue는 초기 위치에서 targetValue까지 애니메이션이 동작하는 방향이다. 애니메이션은 animationSpec으로 동작이 이루어진다.

여기서 animationSpec은 InfiniteRepeatableSpec으로 되어 있다. 해당 관련 문서는 아래 링크를 참조하자.

https://developer.android.com/develop/ui/compose/animation/customize?hl=ko#infiniterepeatable

repeatMode로 리버스도 가능하게 해서 꽤나 유용하게 사용할 수 있다.


이제 이 애니메이션을 통해 background 코드를 작성하면 된다.

background(
        brush = Brush.linearGradient(
            colors = listOf(
                Color(0xFFB8B5B5),
                Color(0xFF8F8B8B),
                Color(0xFFB8B5B5),
            ),
            start = Offset(startOffsetX, 0f),
            end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
        )
    )

먼저 Brush를 사용하여 gradient 배경화면을 만들고 start와 end에 animationFloat인 startOffsetX를 사용하면 애니메이션 위치에 맞게 Brush가 이동이 되는 방법이다.

Brush에 관련된 문서는 아래 링크를 참고하자.
https://developer.android.com/develop/ui/compose/graphics/draw/brush?hl=ko

이제 이 modifier를 이용한 UI 컴포저블을 만들고 실행해보자.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            var isLoading by remember {
                mutableStateOf(true)
            }

            LaunchedEffect(Unit) {
                delay(5000)
                isLoading = false
            }
            ShimmerEffectTheme {
                Scaffold { innerPadding ->
                    LazyColumn(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(innerPadding),
                        verticalArrangement = Arrangement.spacedBy(8.dp)
                    ) {
                        items(20) {
                            ShimmerListItem(
                                isLoading = isLoading,
                                contentAfterLoading = {
                                    Row(
                                        modifier = Modifier
                                            .fillMaxWidth()
                                            .padding(16.dp)
                                    ) {
                                        Icon(
                                            imageVector = Icons.Default.Home,
                                            contentDescription = null,
                                            modifier = Modifier.size(100.dp)
                                        )
                                        Spacer(modifier = Modifier.width(16.dp))
                                        Text(
                                            text = "This is a long text to show that our shimmer display" +
                                                    "is looking perfectly fine"
                                        )
                                    }
                                },
                                modifier = Modifier.fillMaxWidth()
                            )
                        }
                    }
                }
            }
        }
    }
}


@Composable
fun ShimmerListItem(
    isLoading: Boolean,
    contentAfterLoading: @Composable () -> Unit,
    modifier: Modifier = Modifier
) {
	//isLoading이면 Shimmer UI 적용하기
    if(isLoading) {
        Row(
            modifier = modifier
                .padding(16.dp)
        ) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    //여기 Modifier에 shimmerEffect() 함수 사용
                    .shimmerEffect()
            )
            Spacer(modifier = Modifier.width(16.dp))

            Column(
                modifier = Modifier
                    .weight(1f)
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                        .shimmerEffect()
                )
                Spacer(Modifier.height(16.dp))
                Box(
                    modifier = Modifier
                        .fillMaxWidth(0.7f)
                        .height(20.dp)
                        .shimmerEffect()
                )
            }
        }
    } else {
        contentAfterLoading()
    }
}

한 5초동안 loading 상태 후 화면이 보이는 UI를 그렸다. 아래는 실행 화면이다.


만든 코드는 아래 깃허브를 통해 확인
https://github.com/Yoon-Chan/AndroidToyProjects/tree/main/ShimmerEffec

profile
좋은 개발자가 되기까지

0개의 댓글