๐Ÿš€ ๋กœ๋”ฉ๋„ UX๋‹ค! Jetpack Compose Skeleton Loading ๊ตฌํ˜„ ๊ฐ€์ด๋“œ โœจ

์ด์ง„์˜ยท2025๋…„ 9์›” 2์ผ
post-thumbnail

โ“ ๋กœ๋”ฉ UI๋Š” ์™œ ํ•„์š”ํ• ๊นŒ?

์•ฑ์„ ๊ฐœ๋ฐœํ•˜๋‹ค ๋ณด๋ฉด ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋™์•ˆ ์ž ๊น์˜ ๊ณต๋ฐฑ์ด ์ƒ๊น๋‹ˆ๋‹ค.
๋งŒ์•ฝ ์ด๋•Œ ํ™”๋ฉด์ด ํ…… ๋น„์–ด ์žˆ๋‹ค๋ฉด ์‚ฌ์šฉ์ž๋Š” ๋ถˆ์•ˆํ•ด์ง‘๋‹ˆ๋‹ค.
"์•ฑ์ด ๋ฉˆ์ถ˜ ๊ฑด๊ฐ€?" ๋ผ๋Š” ์˜๋ฌธ์„ ๊ฐ–๊ฒŒ ๋˜์ฃ .

๊ทธ๋ž˜์„œ ๋ณดํ†ต์€ ProgressBar ๊ฐ™์€ ์Šคํ”ผ๋„ˆ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
ํ•˜์ง€๋งŒ ์Šคํ”ผ๋„ˆ๋งŒ ๋Œ๊ณ  ์žˆ์œผ๋ฉด ์‚ฌ์šฉ์ž๋Š” ๋‹จ์ˆœํžˆ "๋กœ๋”ฉ ์ค‘์ด๋‹ค" ๋ผ๋Š” ์‚ฌ์‹ค๋งŒ ์•Œ ๋ฟ,
๐Ÿ‘‰ ๋ฌด์—‡์„ ๊ธฐ๋‹ค๋ฆฌ๊ณ  ์žˆ๋Š”์ง€๋Š” ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.


๐ŸŽญ Skeleton Loading

์—ฌ๊ธฐ์„œ ๋“ฑ์žฅํ•˜๋Š” ๊ฒŒ Skeleton Loading(์Šค์ผˆ๋ ˆํ†ค ๋กœ๋”ฉ)์ž…๋‹ˆ๋‹ค.

๐Ÿ’ก Skeleton Loading์ด๋ž€?
์‹ค์ œ ์ฝ˜ํ…์ธ ์˜ ๋ ˆ์ด์•„์›ƒ์„ ํšŒ์ƒ‰ ๋ธ”๋ก ๋“ฑ์œผ๋กœ ๋ฏธ๋ฆฌ ๊ทธ๋ ค๋‘๊ณ ,
๊ทธ ์œ„์— ๋น›์ด ํ˜๋Ÿฌ๊ฐ€๋Š” ๋“ฏํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ๋„ฃ์–ด
์‚ฌ์šฉ์ž๊ฐ€ "๊ณง ์ด ์ž๋ฆฌ์— ์ฝ˜ํ…์ธ ๊ฐ€ ๋‚˜ํƒ€๋‚˜๊ฒ ๊ตฌ๋‚˜" ๋ผ๊ณ  ๋А๋ผ๊ฒŒ ํ•˜๋Š” ๊ธฐ๋ฒ•์ž…๋‹ˆ๋‹ค.

์ฆ‰, UX์ ์œผ๋กœ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐ์ดํ„ฐ์˜ ๋งฅ๋ฝ๊ณผ ๊ธฐ๋Œ€๊ฐ์„ ์ฃผ๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.


๐Ÿ› ๏ธ Jetpack Compose๋กœ ๊ตฌํ˜„ํ•˜๊ธฐ

์ €๋Š” ์ด๋ฒˆ์— Modifier ํ™•์žฅ์„ ํ™œ์šฉํ•ด์„œ
์•„์ฃผ ๊ฐ„๋‹จํžˆ shimmer ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋Š” SkeletonBox๋ฅผ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

@Composable
fun SkeletonBox(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit = {}
) {
    Box(
        modifier = modifier.shimmerBackground()
    ) {
        content()
    }
}

์—ฌ๊ธฐ์„œ๋Š” Box์— Modifier.shimmerBackground()๋ฅผ ๋ถ™์—ฌ์„œ
๋‚ด๋ถ€ ์ฝ˜ํ…์ธ ๊ฐ€ ์—†์–ด๋„ ๋ฐ˜์ง์ด๋Š” ๋ฐฐ๊ฒฝ์„ ๋ณด์—ฌ์ค„ ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.


๐ŸŽจ Modifier ํ™•์žฅ ํ•จ์ˆ˜

ํ•ต์‹ฌ์€ Modifier.shimmerBackground() ์ž…๋‹ˆ๋‹ค ๐Ÿ‘‡

@SuppressLint("SuspiciousModifierThen")
fun Modifier.shimmerBackground(): Modifier = composed {
    // 1) ๋ฌดํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํ…Œ์ด๋„ˆ ์ƒ์„ฑ
    val transition = rememberInfiniteTransition(label = "shimmerBackground")

    // 2) 0f โ†’ 1000f ๊นŒ์ง€ ์„ ํ˜•์œผ๋กœ ์ด๋™ํ•˜๋Š” ๊ฐ’์„ ๋ฌดํ•œ ๋ฐ˜๋ณต
    val translateAnimation by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1200,
                easing = LinearEasing
            ),
            repeatMode = RepeatMode.Restart
        ),
        label = "shimmerTranslate",
    )

    // 3) ๊ทธ๋ฆฌ๊ธฐ ๋‹จ๊ณ„: ์บ์‹œ ๊ฐ€๋Šฅํ•œ ๋ฆฌ์†Œ์Šค๋Š” ์บ์‹œํ•˜๊ณ , ๋งค ํ”„๋ ˆ์ž„ ๊ทธ๋ผ๋ฐ์ด์…˜์„ ๊ทธ๋ ค์คŒ
    return@composed this.then(
        drawWithCache {
            // 3-1) ๊ณ ์ •๋œ ์ปฌ๋Ÿฌ ๋ฆฌ์ŠคํŠธ๋Š” ์บ์‹œ
            val shimmerColors = listOf(
                Color.LightGray.copy(alpha = 0.9f),
                Color.LightGray.copy(alpha = 0.5f),
                Color.LightGray.copy(alpha = 0.9f)
            )

            // 3-2) ์‹ค์ œ ๊ทธ๋ฆฌ๊ธฐ: ์ฝ˜ํ…์ธ  "๋’ค"์— ์‚ฌ๊ฐํ˜•์„ ๊ทธ๋ผ๋ฐ์ด์…˜ ๋ธŒ๋Ÿฌ์‹œ๋กœ ์ฑ„์›€
            onDrawBehind {
                // 3-2-1) ๋Œ€๊ฐ์„ (์ขŒ์ƒโ†’์šฐํ•˜)์œผ๋กœ ํ๋ฅด๋Š” ์„ ํ˜• ๊ทธ๋ผ๋ฐ์ด์…˜
                val brush = Brush.linearGradient(
                    colors = shimmerColors,
                    start = Offset.Zero,
                    end = Offset(x = translateAnimation, y = translateAnimation)
                )

                // 3-2-2) ํ˜„์žฌ ๋ ˆ์ด์•„์›ƒ ์˜์—ญ ์ „์ฒด๋ฅผ ์ฑ„์›€
                drawRect(
                    brush = brush,
                    size = this.size
                )
            }
        }
    )
}

1) composed { ... } ๋ฅผ ์“ฐ๋Š” ์ด์œ 

  • Modifier ๋‚ด๋ถ€์—์„œ remember/์• ๋‹ˆ๋ฉ”์ด์…˜ ์ƒํƒœ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์“ฐ๊ธฐ ์œ„ํ•œ ์ง„์ž…์ ์ž…๋‹ˆ๋‹ค.
    ์ผ๋ฐ˜ Modifier ์ฒด์ธ์—์„œ๋Š” ์ปดํฌ์ง€์…˜ ์Šค์ฝ”ํ”„๊ฐ€ ์—†์–ด remember ๊ณ„์—ด API๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š”๋ฐ, composed { ... } ๋ธ”๋ก ์•ˆ์—์„œ๋Š” ์ปดํฌ์ง€์…˜ ์ˆ˜๋ช…์ฃผ๊ธฐ๋ฅผ ๊ฐ–๊ฒŒ ๋˜์–ด ์ƒํƒœ๋ฅผ ๊ธฐ์–ตํ•˜๊ณ  ์žฌ๊ตฌ์„ฑ(recomposition)์— ๋ฐ˜์‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ์ด ๋•๋ถ„์— rememberInfiniteTransition, animate* ๊ฐ™์€ Compose Runtime ์˜์กด API๋ฅผ Modifier ๊ตฌํ˜„ ์•ˆ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ๋˜ํ•œ Modifier๊ฐ€ ์„ค์น˜/ํ•ด์ œ๋  ๋•Œ์˜ ๋ผ์ดํ”„์‚ฌ์ดํด์„ ๋ถ„๋ฆฌํ•ด ์ฃผ๋ฏ€๋กœ, ์• ๋‹ˆ๋ฉ”์ด์…˜ ๊ฐ์ฒด๊ฐ€ ๋ถˆํ•„์š”ํ•˜๊ฒŒ ์žฌ์ƒ์„ฑ๋˜๊ฑฐ๋‚˜ ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜๊ฐ€ ๋‚˜๋Š” ๊ฒƒ์„ ํ”ผํ•˜๋Š” ๋ฐ ์œ ๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

2) rememberInfiniteTransition + animateFloat

  • rememberInfiniteTransition()์€ ๋ฌดํ•œ ๋ฐ˜๋ณต ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํ…Œ์ด๋„ˆ์ž…๋‹ˆ๋‹ค. ์ด ์ปจํ…Œ์ด๋„ˆ์— ์—ฌ๋Ÿฌ ํŠธ๋ž™(animateFloat, animateColor ๋“ฑ)์„ ๋“ฑ๋กํ•˜๋ฉด ๊ฐ๊ฐ์ด ๋…๋ฆฝ์ ์œผ๋กœ ๋ฐ˜๋ณต๋ฉ๋‹ˆ๋‹ค.
  • ์—ฌ๊ธฐ์„œ๋Š” animateFloat(0f โ†’ 1000f)๋กœ ๊ฐ’์„ ์„ ํ˜•(LinearEasing)์œผ๋กœ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , RepeatMode.Restart๋กœ ๋์—์„œ ๋‹ค์‹œ ์ฒ˜์Œ์œผ๋กœ ์ ํ”„ํ•ด ์žฌ์ƒํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ์–ป์€ ์‹œ๊ฐ„์— ๋”ฐ๋ฅธ ์‹ค์ˆ˜ ๊ฐ’(์—ฌ๊ธฐ์„œ๋Š” translateAnimation)์„ ๊ทธ๋ผ๋ฐ์ด์…˜์˜ ์ข…์  ์ขŒํ‘œ๋กœ ์‚ฌ์šฉํ•ด, ์‹œ๊ฐ์ ์œผ๋กœ ๋น›์ด ํ˜๋Ÿฌ๊ฐ€๋Š” ๋А๋‚Œ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

3) drawWithCache + onDrawBehind

  • drawWithCache๋Š” ๊ทธ๋ฆฌ๊ธฐ ๋ฆฌ์†Œ์Šค์˜ ์บ์‹œ ์ง€์ ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ปฌ๋Ÿฌ ๋ฆฌ์ŠคํŠธ๋‚˜ ๋ธŒ๋Ÿฌ์‹œ ๊ณ„์‚ฐ ๋“ฑ ๋งค ํ”„๋ ˆ์ž„ ๋™์ผํ•œ ๊ฐ’์€ ์บ์‹œํ•˜๊ณ , ์ž…๋ ฅ์ด ๋ฐ”๋€” ๋•Œ๋งŒ ์žฌ๊ณ„์‚ฐํ•ด ํผํฌ๋จผ์Šค ๋น„์šฉ์„ ์ค„์ž…๋‹ˆ๋‹ค.
  • onDrawBehind๋Š” ์ฝ˜ํ…์ธ  ๋’ค์ชฝ ๋ ˆ์ด์–ด์— ๊ทธ๋ฆฝ๋‹ˆ๋‹ค. ์ฆ‰, ์Šค์ผˆ๋ ˆํ†ค์„ ๋ฐฐ๊ฒฝ์ฒ˜๋Ÿผ ํ๋ฅด๊ฒŒ ํ•  ์ˆ˜ ์žˆ์–ด ๋ ˆ์ด์•„์›ƒ/ํ„ฐ์น˜ ์˜์—ญ์„ ํ•ด์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
    (์ฝ˜ํ…์ธ  ์œ„์— ๋ฎ๊ณ  ์‹ถ๋‹ค๋ฉด onDrawWithContent์—์„œ drawContent() ์ดํ›„์— ์˜ค๋ฒ„๋ ˆ์ด๋ฅผ ๊ทธ๋ฆฌ๋ฉด ๋ฉ๋‹ˆ๋‹ค.)

4) Brush.linearGradient์™€ ์ขŒํ‘œ

  • ์„ ํ˜• ๊ทธ๋ผ๋ฐ์ด์…˜์€ start: Offset, end: Offset ๋‘ ์ ์œผ๋กœ ๋ฐฉํ–ฅ๊ณผ ๊ธธ์ด๊ฐ€ ์ •ํ•ด์ง‘๋‹ˆ๋‹ค.
    start = Offset.Zero, end = Offset(x = translate, y = translate)์ด๋ฉด ์ขŒ์ƒ๋‹จ โ†’ ์šฐํ•˜๋‹จ(๋Œ€๊ฐ์„ ) ์œผ๋กœ ํ๋ฅด์ฃ .
  • ์ƒ‰ ๋ฐฐ์—ด์„ ๋ฐ-์–ด๋‘ก-๋ฐ(์˜ˆ: alpha 0.9 โ†’ 0.5 โ†’ 0.9)๋กœ ๋‘๋ฉด ๊ฐ€์šด๋ฐ๊ฐ€ ํ•˜์ด๋ผ์ดํŠธ์ฒ˜๋Ÿผ ๋ณด์ด๋ฉฐ, ์ด๋™ํ•˜๋Š” ์ข…์  ์ขŒํ‘œ์™€ ๊ฒฐํ•ฉ๋˜์–ด ์ƒค๋จธ(Shimmer) ํšจ๊ณผ๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค.
  • ์ขŒํ‘œ ๋‹จ์œ„๋Š” ํ”ฝ์…€(px) ์ด๋ฏ€๋กœ, ์ด๋™ ๋ฒ”์œ„(์˜ˆ: 1000f)๋Š” ํ™”๋ฉด/์ปดํฌ๋„ŒํŠธ ํฌ๊ธฐ์— ๋”ฐ๋ผ ์‹œ๊ฐ์  ์ฒด๊ฐ์ด ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ” ๋™์ž‘ ํ๋ฆ„ ์š”์•ฝ

  1. composed ์•ˆ์—์„œ ๋ฌดํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํ…Œ์ด๋„ˆ๋ฅผ remember ํ•œ๋‹ค.
  2. animateFloat๋กœ 0โ†’๋ชฉํ‘œ๊ฐ’๊นŒ์ง€ ๋ฐ˜๋ณต ์ฆ๊ฐ€ํ•˜๋Š” ์ง„ํ–‰ ๊ฐ’์„ ๋งŒ๋“ ๋‹ค.
  3. drawWithCache๋กœ ๋ถˆ๋ณ€ ๋ฆฌ์†Œ์Šค๋ฅผ ์บ์‹œํ•˜๊ณ , ๋งค ํ”„๋ ˆ์ž„ onDrawBehind์—์„œ ๊ทธ๋ผ๋ฐ์ด์…˜์„ ๊ทธ๋ฆฐ๋‹ค.
  4. ์ฆ๊ฐ€ํ•˜๋Š” ๊ฐ’์œผ๋กœ ๊ทธ๋ผ๋ฐ์ด์…˜์˜ ๋ฐฉํ–ฅ/์ข…์ ์„ ๊ฐฑ์‹ ํ•ด ๋น›์ด ํ๋ฅด๋Š” ๋“ฏํ•œ ๋ฐฐ๊ฒฝ์„ ๋งŒ๋“ ๋‹ค.

โœ… ์ด ์„ค๊ณ„๊ฐ€ ์ ํ•ฉํ•œ ์ด์œ 

  • ์ ์šฉ์„ฑ: Modifier ํ™•์žฅ์ด๋ฏ€๋กœ ์–ด๋–ค ์ปดํฌ์ €๋ธ”์—๋„ ์‰ฝ๊ฒŒ ๋ถ€์ฐฉ ๊ฐ€๋Šฅ.
  • ๋ ˆ์ด์–ด ์•ˆ์ „์„ฑ: onDrawBehind๋กœ ๋ฐฐ๊ฒฝ ๋ ˆ์ด์–ด์—์„œ ๋™์ž‘ โ†’ ๋ ˆ์ด์•„์›ƒ/ํ„ฐ์น˜์— ๊ฐ„์„ญ ์—†์Œ.
  • ์„ฑ๋Šฅ ๊ณ ๋ ค: drawWithCache๋กœ ๋ถˆํ•„์š” ํ• ๋‹น/๊ณ„์‚ฐ ์ตœ์†Œํ™”, ์• ๋‹ˆ๋ฉ”์ด์…˜์€ Compose ๋Ÿฐํƒ€์ž„์ด ๊ด€๋ฆฌ.
  • ์ผ๊ด€๋œ UX: ํ”„๋กœ์ ํŠธ ์ „๋ฐ˜์—์„œ ๋™์ผํ•œ ์Šค์ผˆ๋ ˆํ†ค ํŒจํ„ด์„ ์žฌ์‚ฌ์šฉํ•ด ๋กœ๋”ฉ ๊ฒฝํ—˜์„ ํ†ต์ผํ•  ์ˆ˜ ์žˆ์Œ.

๐ŸŽฌ ๋ฐ๋ชจ (GIF)

์œ„ ์‚ฌ์ง„๊ณผ ๊ฐ™์€ Skeleton Loading ์ ์šฉ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.


๐ŸŽฏ ๊ฒฐ๋ก 

  • ๋‹จ์ˆœ ์Šคํ”ผ๋„ˆ๋ณด๋‹ค Skeleton Loading์ด UX์ ์œผ๋กœ ํ›จ์”ฌ ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค.
  • Jetpack Compose์—์„œ๋Š” Modifier ํ™•์žฅ๋งŒ์œผ๋กœ ์†์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŠนํžˆ Paging3 ์ดˆ๊ธฐ ๋กœ๋”ฉ ์ƒํƒœ์— ์ ์šฉํ•˜๋ฉด,
    ์‚ฌ์šฉ์ž์—๊ฒŒ "์•ฑ์ด ๋ฉˆ์ถ˜ ๊ฒŒ ์•„๋‹ˆ๋ผ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ด๊ตฌ๋‚˜" ๋ผ๋Š” ์•ˆ์ •๊ฐ๊ณผ
    "๊ณง ์–ด๋–ค ์ฝ˜ํ…์ธ ๊ฐ€ ๋‚˜ํƒ€๋‚ ์ง€"์— ๋Œ€ํ•œ ๊ธฐ๋Œ€๊ฐ์„ ํ•จ๊ป˜ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ’ฌ ์—ฌ๋Ÿฌ๋ถ„์€ ๋กœ๋”ฉ UI๋ฅผ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ•˜์‹œ๋‚˜์š”?

  • Spinner?
  • Skeleton Loading?
  • ํ˜น์€ ๋˜ ๋‹ค๋ฅธ ๋ฐฉ์‹?

๐Ÿ’ก Skeleton Loading์€ ๋‹จ์ˆœํ•œ ๋กœ๋”ฉ ํ‘œ์‹œ ์ด์ƒ์˜ ๊ฐ€์น˜๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
โšก Jetpack Compose์˜ Modifier ํ™•์žฅ์„ ํ™œ์šฉํ•˜๋ฉด ๊ฐ„๋‹จํ•˜๋ฉด์„œ๋„ โœจ ์ผ๊ด€๋œ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

profile
Android Developer

0๊ฐœ์˜ ๋Œ“๊ธ€