안드로이드 위클리 공부 노트

#749

재질이 곧 비유다 — Heron이 Compose 하나로 Android·iOS에 유리 디자인을 입힌 방법

원문 Material is the metaphor: Pushing the limits of building with Material design cross platform with Jetpack Compose, Adetunji (TJ) Dahunsi

한 줄 요약

Bluesky 앱 Heron을 혼자 만드는 저자는 앱 전체를 "유리"라는 재질 하나로 비유하고, 높이에 따라 세 가지 유리를 정했다. 바닥의 무광 유리(타임라인 카드), 가운데의 새틴 유리(반투명 머리글), 맨 위의 플로트 유리(떠 있는 탭 막대와 버튼)다. 무광과 새틴은 Heron이 Compose로 직접 그려 Android와 iOS에서 똑같이 보이게 하고, 플로트는 일부러 각 플랫폼의 기본 부품을 그대로 쓴다. 앱 대부분은 자기 브랜드로 지키고, 가장 자주 만지는 탐색 막대 하나만 플랫폼에 맡기는 셈이다. 글의 뒷부분은 이 디자인을 실제로 그리면서 부딪힌 문제, 특히 화면 전환 애니메이션(공유 요소 전환)과 블러가 충돌하는 문제를 어떻게 숨겼는지를 다룬다.

원문은 저자의 개인 블로그에 실렸고 글 자체에는 라이선스 표시가 없다. 그래서 원문의 그림 12장(표지 1장, 경쟁 앱 화면 3장, 뼈대 그림 4장, 실제 화면 4장)과 영상 5개는 이 블로그에 복사하지 않고 원문 서버의 주소를 그대로 걸어 보여 준다. 원문 쪽에서 파일을 지우거나 옮기면 이 글에서도 보이지 않게 된다. 그래서 그림과 영상마다 무엇이 보이는지 글로도 적었다. 세 가지 유리를 보여 주는 뼈대 그림과, 화면 전환 중 블러를 다루는 순서는 설명을 더해 한국어 그림으로 다시 그렸다. 원문에는 코드가 없다. 대신 원문이 링크한 Heron 저장소는 Apache-2.0 라이선스라서, 원문이 설명한 부분의 실제 코드를 인용하고 출처를 붙였다(2026-10-22 기준 main 커밋 dca308b).

먼저 알아 둘 말

서론 — 플랫폼을 따를 것인가, 브랜드를 지킬 것인가

원문 표지 그림
원문 표지 그림. 원문 서버에서 불러옴 (원문)

1. 수렴한 디자인 — 짧은 글 앱들은 모두 같은 모습이 되었다

Twitter 타임라인 화면Bluesky 타임라인 화면Threads 타임라인 화면
왼쪽부터 Twitter, Bluesky, Threads의 타임라인. 원문 이미지, 원문 서버에서 불러옴 (원문)

2. 재질이 곧 비유다 — 세 가지 유리

Heron Android 다크 모드 뼈대 그림Heron iOS 다크 모드 뼈대 그림Heron Android 라이트 모드 뼈대 그림Heron iOS 라이트 모드 뼈대 그림
Heron의 재질을 보여 주는 뼈대 그림. 왼쪽부터 Android 다크, iOS 다크, Android 라이트, iOS 라이트. 원문 이미지, 원문 서버에서 불러옴 (원문)

아래 그림은 위의 Android 라이트 모드 뼈대를 다시 그리고, 옆에 세 층의 단면과 설명을 붙인 것이다.

왼쪽은 Heron 홈 화면의 뼈대로, 타임라인 카드(무광 유리)가 화면을 채우고 위쪽 탭 머리글(새틴 유리)이 내용을 반쯤 가리며 아래쪽 탭 막대와 글쓰기 버튼(플로트 유리)이 떠 있다. 오른쪽은 세 유리를 옆에서 본 단면으로 아래부터 무광, 새틴, 플로트 순서다
Heron의 세 가지 유리 층. 원문의 뼈대 그림(Android 라이트 모드)을 바탕으로 다시 그리고, 층의 설명을 더함 (원문)

3. 타임라인의 무광 유리

iPhone 18 라이트 모드의 무광 유리 타임라인Pixel 10 Pro Fold 다크 모드의 무광 유리 타임라인
왼쪽은 iPhone 18 라이트 모드, 오른쪽은 Pixel 10 Pro Fold 다크 모드의 무광 유리 타임라인. 원문 이미지, 원문 서버에서 불러옴 (원문)

4. 머리글과 앱 바의 새틴 유리

iPhone 18 프로필 사진 모음 화면의 새틴 유리 머리글Pixel 10 라이트 모드 사진 피드 화면의 새틴 유리 머리글
왼쪽은 iPhone 18의 프로필 사진 모음, 오른쪽은 Pixel 10 라이트 모드의 사진 피드. 머리글 뒤로 사진이 흐리게 비친다. 원문 이미지, 원문 서버에서 불러옴 (원문)

5. 탐색에는 플로트 유리

iOS의 liquid glass를 Heron의 플로트 유리로 쓴 모습. 원문 영상(약 28MB), 원문 서버에서 불러옴 (원문)

6. 재질 만들기 — 각 유리를 실제로 그린 방법

앞 장들이 "어떤 재질인가"였다면, 여기부터는 "어떻게 그렸나"다. 저자는 "가지고 있던 UI 꾀 보따리를 다 털었다"고 말한다. 원문은 유리마다 구현 방법을 먼저 쓰고, 이어서 같은 제목의 "공유 요소 전환의 별난 점" 절을 붙인다. 그 순서를 그대로 따른다.

① 타임라인 지연 — 휘는 프로필 사진

Heron의 모양 파일에서, 모양이 바뀔 때 이전 모양에서 새 모양으로 부드럽게 변형하는 부분이다.

@Composable
fun PolygonShape.animate(
    animationSpec: FiniteAnimationSpec<Float> = spring(),
): Shape {
    val updatedAnimationSpec by rememberUpdatedState(animationSpec)
    val progress = remember {
        mutableFloatStateOf(1f)
    }
    var previousShape by remember {
        mutableStateOf(this)
    }

    val currentShape = remember {
        mutableStateOf(this)
    }.apply {
        if (value != this@animate) {
            // TODO capture morphs that have not completed
            previousShape = value
            // Reset the progress
            progress.floatValue = 0f
        }
        value = this@animate
    }.value

    LaunchedEffect(currentShape) {
        animate(
            initialValue = 0f,
            targetValue = 1f,
            animationSpec = updatedAnimationSpec,
            block = { value, _ ->
                progress.floatValue = value
            },
        )
    }

    return remember(
        previousShape,
        currentShape,
    ) {
        EmptySizeSafeShape(
            delegate = MorphPolygonShape(
                start = previousShape,
                end = currentShape,
                progress = progress::floatValue,
            ),
        )
    }
}

출처: ui/core/.../ui/shapes/Shapes.kt (tunjid/heron@dca308b, Apache-2.0)

② 공유 요소 전환의 별난 점 — 사진이 원으로 펴진다

화면 전환 중 프로필 사진 모양이 변형되는 모습(Android). 원문 영상(약 16MB), 원문 서버에서 불러옴 (원문)

③ 새틴에 깃털 달기 — 머리글 블러

Pixel 10 Pro Fold에서 스크롤에 따라 바뀌는 새틴 유리. 원문 영상(약 2MB), 원문 서버에서 불러옴 (원문)

아래는 머리글 블러의 핵심이다. 세기를 정하는 지점(stop)을 세 개 찍는다.

fun Modifier.headerBlur(
    state: PaneHeaderState,
): Modifier = verticalProgressiveBlur { size ->
    val height = state.height(this)
    val currentRadius = state.currentRadius
    val fadeEnd =
        if (size.height <= 0f) 0f
        else (height / size.height).coerceIn(0f, 1f)
    val bandEnd =
        if (size.height <= 0f) 0f
        else ((height - state.fade.toPx()) / size.height).coerceIn(0f, fadeEnd)
    stop(
        fraction = 0f,
        radius = currentRadius,
    )
    stop(
        fraction = bandEnd,
        radius = currentRadius,
    )
    stop(
        fraction = fadeEnd,
        radius = 0.dp,
    )
}

출처: ui/scaffold/.../scaffold/PaneHeaderBlur.kt (tunjid/heron@dca308b, Apache-2.0)

이 블러를 Android에서 실제로 만드는 부분은 짧다. 공통 코드가 expect 로 선언하고, Android 쪽 actual 이 Compose의 그라디언트 블러 API에 넘긴다.

internal actual fun verticalProgressiveBlurEffect(
    fractions: FloatArray,
    radiiPx: FloatArray,
    count: Int,
    lineStartY: Float,
    lineEndY: Float,
    size: Size,
    density: Density,
    edgeTreatment: TileMode,
): RenderEffect? = with(density) {
    BlurRadiusSpec.linearGradient(
        start = DpOffset(
            x = 0.dp,
            y = lineStartY.toDp(),
        ),
        end = DpOffset(
            x = 0.dp,
            y = lineEndY.toDp(),
        ),
        // Allocates whenever the stops change.
        stops = List(count) { index ->
            BlurStop(
                fraction = fractions[index],
                radius = radiiPx[index].toDp(),
            )
        },
    )
}
    .createRenderEffect(
        size = size,
        density = density,
        edgeTreatment = edgeTreatment,
    )
    .takeIf(RenderEffect::isSupported)

출처: ui/core/.../ui/modifiers/Blur.android.kt (tunjid/heron@dca308b, Apache-2.0)

④ 공유 요소 전환의 별난 점 — 블러가 "툭" 튀어나온다

공유 요소 전환 중의 새틴 유리(Android). 뒤로 돌아올 때 블러가 서서히 들어온다. 원문 영상(약 4MB), 원문 서버에서 불러옴 (원문)
공유 요소 전환이 시작되면 방향을 본다. 앞으로 가면 아무것도 하지 않는다. 뒤로 가면 새틴 유리에 가려지는지 보고, 가려지지 않으면 평소대로, 가려지면 전환 동안 스크림만 그리다가 전환이 끝난 뒤 800밀리초에 걸쳐 블러를 켠다
공유 요소 전환 중 새틴 유리를 다루는 순서. 원문의 설명을 바탕으로 그림 (원문)

⑤ 덮어씌우기 — 탐색 막대의 플로트 유리

원문의 장 제목은 "Frosting Over"이고 본문에서도 "frost glass"라고 쓰지만, 앞에서 정한 이름은 "float glass"다. 같은 것을 가리키는 것으로 보고 이 글에서는 "플로트 유리"로 통일했다.

UIKitView(
    factory = {
        GlassTabBarView(
            onSelect = { index ->
                onSelect(index)
            },
            onCollapseSettled = { settled ->
                settledCollapse = settled
            },
        )
    },
    modifier = Modifier
        .fillMaxHeight()
        .width(interopWidth),
    update = { view ->
        view.configure(
            items = navItems,
            collapse = collapse,
            fullWidth = fullWidth,
            enabled = identityState.isStable,
            selectedColor = selectedColor,
            unselectedColor = unselectedColor,
        )
    },
    properties = UIKitInteropProperties(
        interactionMode = UIKitInteropInteractionMode.Cooperative(),
        isNativeAccessibilityEnabled = false,
        placedAsOverlay = true,
    ),
)

출처: ui/scaffold/.../scaffold/PlatformNavigationBar.ios.kt (tunjid/heron@dca308b, Apache-2.0)

⑥ 공유 요소 전환의 별난 점 — 고장이 곧 기능이 되다

iOS에서 liquid glass 탭 막대가 공유 요소 전환을 따라가는 모습. 원문 영상(약 20MB), 원문 서버에서 불러옴 (원문)

7. 정리 — Material은 여전히 버틴다

원문을 읽을 때 주의할 점

코드로 보기 — Aide 구조로 옮긴 스케치

Aide는 Android 전용이고 화면이 아직 거의 없다. Heron에서 바로 가져올 만한 것은 블러 코드가 아니라 "층마다 역할 이름을 붙이고, 탐색 막대는 플랫폼 기본 부품을 쓴다" 는 생각이다. 아래는 Task 4에서 만들 AideTheme 옆에 층 역할을 두는 직접 쓴 스케치다. 이름과 값은 예시이고, 실제 코드는 사용자가 쓴다.

// core/designsystem/.../theme/AideLayers.kt — 직접 쓴 스케치
package com.giwon.aide.core.designsystem.theme

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color

/**
 * Aide 화면의 세 층. 기능 코드는 색을 직접 고르지 않고 "이 요소는 어느 층인가"만 고른다.
 * - content: 할 일·일정·AI 질문 카드가 놓이는 바탕 층 (Heron의 무광 유리)
 * - header: 아바타 영역 위쪽처럼 내용이 그 아래로 스크롤되는 머리글 (Heron의 새틴 유리, 블러 없이 시작)
 * - navigation: 하단 탭 막대. Material 3 NavigationBar를 그대로 쓴다 (Heron의 플로트 유리)
 */
object AideLayers {
    val content: Color
        @Composable @ReadOnlyComposable
        get() = MaterialTheme.colorScheme.surfaceContainerLow

    val header: Color
        @Composable @ReadOnlyComposable
        get() = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)

    val navigation: Color
        @Composable @ReadOnlyComposable
        get() = MaterialTheme.colorScheme.surfaceContainer
}

배운 점

Aide에 적용하면

Aide는 Android 전용 앱이라 이 글의 절반(iOS liquid glass, Skia 블러 이식)은 해당이 없다. 가져올 것은 디자인을 정하는 방법과 탐색 막대에 대한 태도다. 이미 정한 결정 「첫 화면은 AI이고 캘린더가 아니다」, 「관습적인 todo 앱 UI를 만들지 않는다 — AI가 묻고 카드로 보여 준다」, 「탭 아이콘은 Vector Asset 3개만 core:designsystem 에」와 맞춰 썼다.

  1. Aide의 형용사 세 개를 먼저 정한다 — 가져온다. 시점은 Task 4(AideTheme 만들기) 전이다.
    • Heron처럼 "이 앱에서 느껴지길 바라는 말"을 먼저 정하면, 이후 색·모양·애니메이션 결정이 거기서 나온다.
    • 결정 기록에 이미 단서가 있다. 사용자는 구체(orb)가 "인간적이지 않고 차갑다"며 아바타를 골랐다. 그러니 "따뜻하다"는 들어갈 가능성이 높다. 나머지 둘은 사용자가 정한다. 정하면 decisions.md 에 남긴다.
  2. 화면을 세 층으로 나눠 이름을 붙인다 — 바꿔서 가져온다. 시점은 Task 4와 Task 8이다.
    • 위 스케치처럼 AideLayers(바탕 카드 · 머리글 · 탐색)를 core:designsystem 에 두고, 값은 Material 3 colorScheme 역할을 그대로 쓴다. 새 색을 만들지 않는다.
    • Task 8의 첫 화면 대시보드는 "AI가 묻는 질문을 예쁜 카드로 보여 준다"는 결정이 있다. 그 카드가 Heron의 무광 유리와 같은 바탕 층이다. Heron이 납작한 목록 대신 카드를 고른 이유(넓은 화면에서 격자로 바꿀 수 있다)도 Aide의 "관습적 todo 목록을 만들지 않는다"와 맞는다.
  3. 하단 탭 막대는 Material 3 NavigationBar 기본 부품을 그대로 쓴다 — 가져온다. 이미 Task 4 계획과 같다.
    • Heron의 "플랫폼 세금"과 같은 판단이다. 탭 막대를 꾸미지 않으면 Material이 다음 판에서 탐색 막대를 바꿔도 BOM만 올리면 따라간다.
    • 브랜드는 첫 화면의 아바타와 카드에 쓴다. 탭 막대에 쓰지 않는다.
  4. 머리글 블러(새틴 유리) — 지금은 안 한다.
    • Aide의 minSdk는 26이다. 블러는 Android 12(API 31) 이상에서만 보이므로, 블러 없는 모습을 어차피 따로 디자인해야 한다.
    • Heron이 쓰는 점진적 블러 API는 Compose 1.13 알파 계열에 있다. Aide는 Compose BOM 2026.02.01과 Kotlin 2.2.10에 머물기로 했고(2026-10-20 결정: 최신화는 Task 4 뒤 별도 PR), 이 버전에 같은 API가 있는지는 확인하지 않았다.
    • 1-a에는 접히는 머리글이나 퀵 리턴 앱 바가 계획되어 있지 않다. 블러가 필요해질 화면이 생기면, 그때 Compose 최신화 PR이 끝났는지와 함께 다시 본다. 그 전까지는 스케치의 "약간 비치는 단색 머리글"로 충분하다.
  5. 모양 변형(PolygonShape)으로 아바타 테두리 꾸미기 — 지금은 안 한다.
    • Heron의 휘는 프로필 사진은 재미있지만, Aide 아바타는 「AI 이미지 + Rive 상태 머신」으로 정했다. 모양 변화는 Rive 애니메이션이 맡는다. PolygonShape 도 foundation 1.13.0-beta01부터라 지금 BOM에는 없다(위 4번과 같은 이유).
  6. 공유 요소 전환 — 지금은 안 한다. 쓸 때는 Heron의 교훈을 기억한다.
    • 1-a의 화면 이동은 탭 3개와 일정 추가 화면 정도라 공유 요소 전환이 필요 없다.
    • 나중에 "카드를 누르면 커지며 상세 화면이 되는" 전환을 넣을 때, 전환 중인 요소는 오버레이에 그려져 뒤의 효과(블러·스크림)에서 빠진다는 점을 미리 알아 둔다.

출처