본문 바로가기

Android/Jetpack Compose

[Android] Shimmer UI 구현하기

728x90

개발을 진행하면서 UI가 그려지기 위한 API의 응답을 기다리는 시간에 Loading Indicator를 통해 UI를 그려본 적이 있을 것이다.

 단순히 로딩을 돌려서 그 시간을 기다리게 만들 수 있을 뿐 아니라, Shimmer를 사용하여 조금 더 자연스럽고 세련된(?) 로딩 화면을 만들 수 있다.

 

필자도 지금까지는 대부분 API 응답을 기다릴 때 Loading 애니메이션을 넣어주고 로딩이 끝나면 UI가 한 번에 그려지는 형태로 개발을 진행했었는데, 이번에 Shimmer를 사용하여 UI를 미리 그려주고 API Response가 오면 화면을 갱신하는 형태로 개발을 진행하게 되었다.

 

따라서, 간단하게 Shimmer를 구현하는 방법과 사용할 때 주의할 점에 몇 가지에 대해 작성해두고자 한다.


우선,

Shimmer가 무엇인지 모른다면, Skeleton UI가 뭔지부터 알아야 한다.

 

Youtube에 들어가면 화면이 로딩되기 전 잠깐 이런 형태로 레이아웃 구조가 잡혀있으나 이미지가 나오지 않고 회색으로 프레임만 잡혀있는 것을 본 적이 있을 것이다. 

이와 같이, 실제 데이터가 존재하는 UI가 렌더링 되기 전, 그 화면의 프레임을 먼저 그려줌으로써 보다 자연스럽게 로딩 중임을 알려주고, 다음 화면으로 넘어갈 수 있도록 도와주는 UI를 Skeleton UI라고 부른다.

 

그렇다면, Shimmer UI는 무엇인가?

Shimmer의 뜻을 찾아보면 "반짝임" 이라는 것을 알 수 있을 것이다.

 

이처럼 Skeleton UI에 반짝임 효과를 주어 보다 자연스럽게 로딩 중임을 알려줄 수 있도록 구현한 UI가 Shimmer UI라고 할 수 있다.

 

이러한 UI를 사용하여 로딩중임을 표현한다면, 보여줄 컨텐츠 영역을 미리 그려주기 때문에 어떠한 형태로 화면이 나올지 사용자가 예상할 수 있을뿐더러 Shimmer 부분이 원하는 데이터로 바뀌어 보이는 것 이기 때문에 보다 자연스럽게 UI가 변경된다고 느낄 수 있을 것이다.

 

즉, 로딩에 대한 사용자가 기다린다고 생각이 드는 부분을 최소화 시키고 자연스럽게 다음 화면으로 연결시키므로 보다 사용성이 높은 UI/UX를 표현할 수 있게 된다.


Shimmer UI에 대해 알아보았으니, 간단하게 구현하는 방법에 대해 알아보자.

위에 Shimmer에 대한 설명을 위해 올려둔 영상을 보면, 대각선으로 색이 다른 레이어가 좌, 우로 움직이는 것을 볼 수 있다.

저렇게 움직이는 형태를 만들려면 저렇게 색이 다른 UI 레이어를 좌, 우로 움직이게끔 하면 될 것이다.라고 간단하게 생각할 수 있다.

 

그렇다면, 어떻게 색이 다른 레이어 UI를 만들고, 어떻게 움직일 것인가?를 생각해보면 방법이 잘 떠오르지 않을 것이다.

필자도 마찬가지로 잘 떠오르지 않아서, Shimmer 구현 방법에 대해 검색해보고 이렇게 구현하면 되는구나 하고 아차 싶었다.

 

간단하게 결론만 말하자면,

brush에서 사용할 수 있는 linearGradient를 사용하면서 그라데이션을 넣을 색상을 A, B, A 형태로 넣어주고, 이 그라데이션이 시작되는 위치를 좌, 우로 계속해서 변경해 준다면 우리가 생각하는 대로 애니메이션이 구현되게 된다.

 

방법을 알았으니 구현해보도록 하자.

 

우선, 

그라데이션에 대해서 구현해 보도록 하자.

 

각 Component의 Modifier.background를 보면, color를 넣는 것뿐 아니라 brush를 통해 다양한 Paint 속성을 넣어줄 수 있다.

그중에서 우리는 linearGradient를 넣을 것인데, 움직이는 UI의 위치에 따라서 다른 Gradient 속성을 넣어도 상관없으니 참고하길 바란다.

fun linearGradient(
    colors: List<Color>,
    start: Offset = Offset.Zero,
    end: Offset = Offset.Infinite,
    tileMode: TileMode = TileMode.Clamp
): Brush

 

linearGradient를 구현할 때 들어가는 파라미터는 다음과 같다.

그라데이션을 넣을 색상과, 시작, 끝 지점, 그리고 tileMode라는 것이 있는데 tileMode는 이번 구현에 사용하지 않을 것이므로 default 값으로 사용해도 상관없다.

 

우선 처음에는 어떤 형식으로 그라데이션이 들어가는지 확인해야하므로, 3가지 색상을 사용하도록 한다.

background(
    brush = Brush.linearGradient(
        colors = listOf(
            Color.Green.copy(alpha = 0.2f),
            Color.Black.copy(alpha = 0.9f),
            Color.Red.copy(alpha = 0.2f)
        )
    )
)

 

아주 간단하게 그라데이션 색상만 넣으면 다음과 같은 결과를 볼 수 있다.

 

3가지 색을 사용하며, 시작 지점은 0,0이고 끝나는 지점은 UI의 끝 지점인 맨 우측 하단이 되므로 위와 같은 UI를 볼 수 있다.

이 부분에 그라데이션의 검은 색상의 그려지는 조건을 좌에서 우로 이동하는 것처럼 변경하게 되면 우리가 원하는 UI가 나올 것이다.

 

그러면 검은색상이 움직일 수 있도록 애니메이션을 추가하도록 하자.

val transition = rememberInfiniteTransition(label = "")
val translateAnim by transition.animateFloat(
    initialValue = 0f,
    targetValue = 1000f,
    animationSpec = infiniteRepeatable(
        animation = tween(),
    ), label = "Shimmer"
)

 

자식 컴포넌트에 애니메이션을 추가할 수 있도록 도와주는 InfiniteTransition을 사용하여 애니메이션을 추가하도록 한다.

해당 데이터는 무한히 반복하며, 0에서부터 1000f까지 데이터가 변경되는 데이터라고 볼 수 있다.

이것을 그럼 어떻게 사용하는가?

 

linearGradient 에는 start, end 값이 Offset으로 되어있는데 이 데이터 클래스는 float 형태로 되어있는 x, y의 좌표 값을 사용하게 된다.

즉, 0에서부터 1000f까지 반복하는 이 translateAnim 데이터와 offset 값을 사용하여 애니메이션을 만들어주면 된다.

background(
    brush = Brush.linearGradient(
        colors = listOf(
            Color.Green.copy(alpha = 0.2f),
            Color.Black.copy(alpha = 0.9f),
            Color.Red.copy(alpha = 0.2f)
        ),
        start = Offset.Zero,
        end = Offset(x = translateAnim, y = translateAnim)
    )
)

 

아주 간단하게 이런 식으로 만들어보도록 하자.

어떻게 나올지 모르기 때문에 일단 때려 박아서 어떤 형태의 UI가 나오는지 확인하고 수정하는 것이 빠르다.

 

 

일단 움직이게 하는 것에는 성공했으므로 이것을 좀 디테일하게 수정해 주면 될 것이다.

 

보았을 때 가장 크게 수정이 필요한 부분은 다음과 같다.

  1. 속도
  2. 애니메이션의 타입

이것들은 translateAnim의 데이터가 변경될 필요가 있어 보인다.

속도가 너무 빠른 것은 말할 필요도 없거니와, 애니메이션도 좌에서 우로만 이동하는 것도 좋지만, 좌에서 우로, 우에서 좌로 왔다 갔다 하는 것이 더 자연스럽게 보일 것으로 생각된다.

 

이것들을 수정하는 것은 아주 간단하다.

val translateAnim by transition.animateFloat(
    initialValue = 0f,
    targetValue = 1000f,
    animationSpec = infiniteRepeatable(
        animation = tween(
            durationMillis = 1200,
            easing = FastOutSlowInEasing
        ),
        repeatMode = RepeatMode.Reverse
    ), label = "Material3 Shimmer"
)

 

업무를 진행하다 보면 생각보다 animationSpec 파라미터를 자주 볼 것인데, 이 친구 내부에 있는 데이터들을 변경함으로 원하는 애니메이션은 웬만하면 다 처리가 될 것이다.

 

우선 속도는 tween 내부의 durationMillis를 1200으로 설정하였다.

해당 값은 애니메이션이 끝날 때까지 걸리는 시간이라고 생각하면 되는데, default 값은 300이다. 따라서 적당히 느려진 형태로 보여주기 위해서 1200의 값을 주었다.

easing 값은 애니메이션을 어떤 형태로 자연스럽게 만들어주는지 설정할 수 있는 값이라고 생각하면 되는데, default 값을 그대로 사용하였다.

 

그리고 애니메이션 타입은 repeatMode를 통해 설정해 주었다.

RepeatMode.Reverse를 설정함으로써 좌 -> 우, 우 -> 좌로 반복할 수 있도록 데이터가 반대로 반복될 것이다.

이렇게 수정한 다음, 결과를 한번 확인해 보자.

 

 

일단 원하는 대로 느리고, 좌우 반복되도록 되었다. 

하지만 살짝 아쉽다고 느껴지는 것이 가운데 검은 색상이 왼쪽 끝, 오른쪽 끝까지 모두 이동했으면 좋겠으며, 그라데이션이 너무 퍼지지 않게 되었으면 좋겠다.

 

관련하여 애니메이션을 추가할 수 있을지 여러모로 확인해 보고 다음과 같은 코드를 발견하여 적용해 보았다.

val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp.dp
val shimmerWidth = screenWidthDp * 0.6f

    ... 

background(
    brush = Brush.linearGradient(
        colors = colorList,
        start = Offset(
            x = translateAnim - shimmerWidth.value,
            y = translateAnim - shimmerWidth.value
        ),
        end = Offset(x = translateAnim, y = translateAnim)
    )
)

 

현재 그리고자 하는 컨텐츠의 너비를 구하고, 적당한 비율을 곱해 범위를 줄인 다음 해당 데이터를 사용하는 형태이다.

 

이처럼 구현한 후 결과를 다시 확인해 보자.

 

 

원하는 대로 애니메이션과, 색상의 변화가 이루어진 것을 볼 수 있다.

 

자, 여기까지 구현을 했으므로 색상의 변화를 확인하기 위해 지정했던 색상들을 보다 자연스러운 색상으로 변경해 보도록 하자.

이 색상의 값들은 사용하고자 하는 컨텐츠의 색상에 따라, 혹은 디자이너의 요청에 따라 넣어주면 되는데, 필자는 LightGray 값을 사용하여 처리해 보도록 하였다.

listOf(
    Color.LightGray.copy(alpha = 0.2f),
    Color.LightGray.copy(alpha = 0.9f),
    Color.LightGray.copy(alpha = 0.2f)
)

 

이와 같이 색상을 넣어주었으며, alpha 값을 준 이유는 한 가지 색상에 투명도를 조절하여 변경하는 것이 여러 가지 확인해 본 결과 가장 자연스럽게 Shimmer UI를 그릴 수 있기 때문이다.

 

이제 결과물을 한번 확인해 보자.

 

 

3가지를 그린 이유는, 이전에 추가했던 0.6f의 값이 변경되면 어떻게 되는지 확인하기 위해서 겸사겸사 3가지로 나눠서 그려보았다.

우선 shimmerWidth에 0.2f, 0.6f, 1.0f 값을 곱한 값을 사용했으며 1.0f를 곱한 3번째 아이템의 경우 기존에 넓게 퍼져있던 그라데이션과 동일한 것을 알 수 있다.

 

이렇게 넓은 하나의 카드 섹션에 Shimmer를 추가한 것을 보니 살짝 어색할 수 있다.

그래서 같은 컴포넌트를 사용하여 실제로 사용할법한 UI를 만든 것이, 가장 처음에 Shimmer UI를 설명할 때 추가해 두었던 샘플 영상이다.

@Composable
fun ShimmerItem() {
    val transition = rememberInfiniteTransition(label = "")
    val translateAnim by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1200,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Reverse
        ), label = "Shimmer"
    )

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(
            defaultElevation = 2.dp
        ),
        shape = RoundedCornerShape(16.dp)
    ) {
        Row(modifier = Modifier.padding(16.dp)) {
            Spacer(
                modifier = Modifier
                    .size(80.dp)
                    .clip(RoundedCornerShape(10.dp))
                    .shimmer(shimmerAnimation = translateAnim)
            )

            Spacer(modifier = Modifier.width(16.dp))

            Column(modifier = Modifier.weight(1f)) {
                Spacer(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                        .clip(RoundedCornerShape(10.dp))
                        .shimmer(shimmerAnimation = translateAnim)
                )
                Spacer(modifier = Modifier.height(8.dp))
                Spacer(
                    modifier = Modifier
                        .fillMaxWidth(0.5f)
                        .height(15.dp)
                        .clip(RoundedCornerShape(10.dp))
                        .shimmer(shimmerAnimation = translateAnim)
                )
            }
        }
    }
}

 

이와 같이 구현이 되어있는 것이 

 

 

이 영상의 코드라는 것이다.

 

그런데,

위의 코드에는 지금까지 구현할 때 사용했던 background 내부의 brush가 보이지 않고, shimmer()라는 처음 보는 함수를 볼 수 있을 것이다.

그 이유는, Shimmer가 사용되는 각 컴포넌트마다 같은 코드를 구현해서 사용하는 것은 누가 봐도 비효율적이기 때문에 별도의 Modifier 함수로 빼내어서 구현해 두었다.

fun Modifier.defaultShimmer(
    colorList: List<Color> = listOf(
        Color.LightGray.copy(alpha = 0.2f),
        Color.LightGray.copy(alpha = 0.9f),
        Color.LightGray.copy(alpha = 0.2f)
    ),
    ratio: Float = 0.6f
): Modifier = composed {
    val transition = rememberInfiniteTransition(label = "")
    val translateAnim by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1200,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Reverse
        ), label = "Shimmer"
    )
    
    val configuration = LocalConfiguration.current
    val screenWidthDp = configuration.screenWidthDp.dp
    val shimmerWidth = screenWidthDp * ratio

    background(
        brush = Brush.linearGradient(
            colors = colorList,
            start = Offset(
                x = translateAnim - shimmerWidth.value,
                y = translateAnim - shimmerWidth.value
            ),
            end = Offset(x = translateAnim, y = translateAnim)
        )
    )
}

 

이처럼 필요한 데이터 값을 받아서 background 설정을 반환해 주도록 말이다.

 

필자는 애니메이션 관련한 값들은 여러 가지 변경해 보면서 확인하기 위해 별도로 빼낸 후, translateAnim를 받아서 background를 설정하도록 구현을 해두었지만, 보통 Shimmer UI를 사용할 때 같은 형태로만 사용하는 경우가 많으므로 이처럼 별도의 데이터를 받지 않고. shimmer() 하나만으로 Shimmer UI를 구현할 수 있도록 해둔다.


위와 같이 간단하게 Shimmer를 구현할 수 있는데,

추가로 자세한 설명은 하지 않겠지만 Material3의 Card를 사용할 때와 Material의 Card를 사용할 때 설정 값에 따라 보이는 UI가 다르기 때문에 간단하게 언급하고 넘어가도록 하겠다.

 

위에 보여준 샘플 코드를 보면 

Row(modifier = Modifier.padding(16.dp)) {
    Spacer(
        modifier = Modifier
            .size(80.dp)
            .clip(RoundedCornerShape(10.dp))
            .shimmer(shimmerAnimation = translateAnim)
    )
    ...
}

 

이와 같이 Shimmer가 들어가는 Spacer에도 각 아이템의 크기에 대한 값이 들어가 있다.

 

하지만, Material3가 아닌 Material의 Card를 사용하면 다음과 같이 사용이 가능하다.

androidx.compose.material.Card(
    modifier = Modifier
        .fillMaxWidth()
        .height(100.dp)
        .padding(8.dp),
    shape = RoundedCornerShape(14.dp)
) {
    Spacer(
        modifier = Modifier
            .clip(RoundedCornerShape(10.dp))
            .defaultShimmerBrush()
    )
}

 

Spacer에 대한 사이즈를 제공해주지 않아도, 알아서 우리가 원하는 것처럼 Card View의 모든 범위를 커버하는 사이즈로 Spacer가 그려지게 된다.

 

이렇게만 보면 그래서 뭐 어떻게 다른데?라고 생각할 수 있다.

androidx.compose.material.Card(
    modifier = Modifier
        .fillMaxWidth()
        .height(100.dp)
        .padding(8.dp),
    shape = RoundedCornerShape(14.dp)
) {
    Spacer(
        modifier = Modifier
            .clip(RoundedCornerShape(10.dp))
            .defaultShimmerBrush()
    )
}

androidx.compose.material3.Card(
    modifier = Modifier
        .fillMaxWidth()
        .height(100.dp)
        .padding(8.dp),
    shape = RoundedCornerShape(14.dp)
) {
    Spacer(
        modifier = Modifier
            .clip(RoundedCornerShape(10.dp))
            .defaultShimmerBrush()
    )
}

 

위의 코드 같이 같은 Card View지만 위에는 Material, 아래는 Material3를 사용한 것 외에는 모두 동일한 코드로 구현한 다음 결과를 보도록 하자.

 

 

이처럼 아래 Material3.Card를 사용한 경우 Shimmer가 들어간 Spacer의 영역이 보이지 않는다.

따라서, 정상적으로 동작하도록 하고 싶으면

androidx.compose.material3.Card(
    modifier = Modifier
        .fillMaxWidth()
        .height(100.dp)
        .padding(8.dp),
    shape = RoundedCornerShape(14.dp)
) {
    Spacer(
        modifier = Modifier
            .fillMaxWidth()
            .height(100.dp)
            .clip(RoundedCornerShape(10.dp))
            .defaultShimmerBrush()
    )
}

 

Spacer 영역의 크기도 명확하게 넣어주면 정상적으로 원하는 형태로 Shimmer가 보이는 것을 확인할 수 있을 것이다.


이것으로 Shimmer UI를 구현하는 방법에 대해 간단하게 알아보았다.

Animation 부분이 얼마나 디테일하게 보는가에 따라 한도 끝도 없이 시간이 오래 걸리는 작업이기는 하지만,

보통 짧은 시간 보여주는 UI이기도 하고, Loading 속도를 최소화시켜 사실 보여주지 않고 바로 Contents가 보이는 게 베스트 기 때문에 필자는 그렇게까지 디테일하게 애니메이션을 깎진 않았다.

 

적당히 디자인 시스템 혹은 디자이너의 요청에 따라 변경되는 몇 가지 부분을 파라미터로 받을 수 있도록 한번 구현하고 Util 함수로 빼놓는다면 별 다른 구현 없이 쭉 Shimmer를 사용할 수 있을 것 같아서 아주 유용하다고 생각된다.

 

필자는 이 샘플 코드를 만든 후에, 그대로 가져가서 필요한 부분을 적당히 커스텀하여 실 서비스에 적용시켰는데 생각보다 쓸만했고 애니메이션 부분을 제외하고는 커스텀을 하지 않아도 괜찮을 것 같다고 생각한다.

반대로, 애니메이션 부분을 커스텀하는 것은 상당히 어렵기 때문에.. 다른 부분에 Shimmer가 사용된다면 애니메이션 부분에 시간이 많이 투자될 것 같다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90