시작은 불편함에서..
Android 15부터 Edge-to-Edge가 기본으로 적용되면서, 이제 앱의 콘텐츠가 시스템 바 아래까지 확장되는 것이 표준이 되었어요. 덕분에 개발자들은 더 넓은 화면을 자유롭게 사용할 수 있게 되었습니다. 하지만 동시에 디자인에 맞춰 상태 바(Status Bar)의 색상을 화면과 자연스럽게 연결해야 하는 과제가 생겼어요😅
저도 이 작업을 진행하면서 고민이 많았어요. 이전에는 systemuicontroller 같은 라이브러리를 사용하면 간단히 해결됐지만 Android 16(Tiramisu)부터 Deprecated 되었고, 더 근본적인 문제가 있었어요.
LaunchedEffect나 DisposableEffect 안에서 시스템 바 색상을 변경하는 코드를 호출해야 했는데, 이 방식은 스크린의 컴포저블이 그려지는 시점과 시스템 바의 색상이 변경되는 시점이 완벽하게 일치하지 않았어요. 그래서 화면이 전환될 때마다 미세하게 혹은 확실하게 딜레이 되는 문제가 있었어요.
Before: 스크린 컴포넌트와 상태 바 색상이 동기화되지 않는 모습
어떻게 하면 팀원들이 이 기능을 더 쉽게 사용하고, 컴포저블이 그려지는 순간과 색상 변경을 완벽하게 동기화할 수 있을까 고민했어요. UX를 해치는 문제이기 때문이에요.
그러다 정말 간단하게 "그냥 그 영역에 직접 색칠하면 되잖아?" 라고 생각했습니다.
어떻게 할건데
LaunchedEffect는 컴포저블이 Composition을 통해 UI 구조를 만들고, 화면에 그려진(Draw) 이후에 실행되는 Side Effect입니다.
즉, 화면의 배경색이 먼저 그려지고, 그 직후에 LaunchedEffect 블록이 실행되어 시스템 바의 색상을 변경하는 코드가 동작하게 되는데요, 이 차이가 바로 UX저하의 원인이에요. 제가 원하는건 완벽하게 동기화가 되었으면 했습니다.
이 문제를 가장 Compose스럽게 푸는 방법은 Modifier를 활용하는 것이라고 생각했어요. 개발자가 복잡한 생명주기를 신경 쓰지 않고, .background()처럼 간단하게 사용하도록 하고자 했습니다.
제가 목표로 한 사용 방식은 아주 간단해요.
fun Modifier.statusBarColor(backgroundColor: Color): Modifier
이렇게 확장 함수를 만들면, 사용할 때는 modifier.statusBarColor(Color.Black)처럼 한 줄만 추가하면 됩니다. 아주 쉽지요?
이제 이 함수의 내부를 하나씩 채워볼게요. 함수는 크게 세 가지 일을 해야 해요.
- 지정한 배경색의 밝기에 따라 상태 바 아이콘(시간, 배터리 등)의 색상을 결정해요. (밝은 배경에 흰 아이콘이 보이면 안 되니까요.)
- 현재 디바이스의 상태 바 높이를 가져와요.
- 그 높이만큼, 지정한 색상으로 컴포저블의 상단 영역을 직접 그려요.
그래서 하나씩 해봤습니다.
fun Modifier.statusBarColor(backgroundColor: Color): Modifier = composed {
// 지정한 색상에 따라 아이콘을 전환한다.
// 현재 시스템 바의 높이를 가져온다.
// 높이만큼 지정한 색상으로 그린다.
}
먼저 필요한 값들을 가져와야 해요. 아이콘 색상 제어를 위한 Activity 정보, 배경색의 밝기(Luminance) 값, 그리고 상태 바의 높이가 필요해요.
fun Modifier.statusBarColor(backgroundColor: Color): Modifier = composed {
val activity = LocalActivity.current
// 색상의 휘도를 계산해 0.5f보다 크면(밝으면) 아이콘을 어둡게 설정해요.
val isDarkIcons = backgroundColor.luminance() > 0.5f
// WindowInsets으로 상태 바의 높이를 가져와요.
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// ...
}
이제 상태 바 아이콘의 색상을 제어해야 해요. 이 작업은 WindowCompat.getInsetsController를 통해 할 수 있는데, 이녀석도 Side Effect이므로 LaunchedEffect 안에서 처리했어요. backgroundColor가 바뀔 때마다 아이콘 색상도 바뀌어야 하니, isDarkIcons를 key로 넣어주었어요.
fun Modifier.statusBarColor(backgroundColor: Color): Modifier = composed {
// ... 이전 코드
val activity = LocalActivity.current
val isDarkIcons = backgroundColor.luminance() > 0.5f
LaunchedEffect(activity, isDarkIcons) {
val window = activity?.window ?: return@LaunchedEffect
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = isDarkIcons
}
// ...
}
마지막으로 가장 중요한 배경색을 그리는 부분이에요. Modifier.drawBehind를 사용하면 컴포저블이 그려지기 전에 드로잉 코드를 실행할 수 있어요. 이 코드는 컴포저블의 드로잉 단계(Draw Phase)에 포함되기 때문에, LaunchedEffect와 달리 시간 차 없이 함께 그려지게 됩니다 :)
fun Modifier.statusBarColor(backgroundColor: Color): Modifier = composed {
val activity = LocalActivity.current
val isDarkIcons = backgroundColor.luminance() > 0.5f
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
LaunchedEffect(activity, isDarkIcons) {
val window = activity?.window ?: return@LaunchedEffect
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = isDarkIcons
}
this.drawBehind {
drawRect(
color = backgroundColor,
topLeft = Offset.Zero,
size = Size(size.width, statusBarHeight.toPx())
)
}
}
이렇게 간단한 코드로 깜빡임 없는 상태 바 색상 변경 기능이 완성되었어요.
After: 스크린 컴포넌트와 상태 바 색상이 완벽하게 동기화되는 모습
그러나
하지만 이 확장함수를 사용할 때는 선언 순서가 중요해요.
Column(
modifier = Modifier
.statusBarColor(Color.Black) // (1) statusBarColor로 그리고
.fillMaxSize()
.background(Color.White) // (2) 그 위에 배경을 덮은뒤
.padding(paddingValues) // (3) 시스템 패딩을 적용
) {
// Composable 함수들..
}
만약 위 코드처럼 statusBarColor를 먼저 호출하고 그 뒤에 background를 호출하면, .background(Color.White)가 .statusBarColor(Color.Black)이 그린 영역을 다시 덮어버려서 상태 바 색상이 적용되지 않아요. Modifier는 선언된 순서대로 적용된다는 점을 기억해야 해요.
"그럼 패딩까지 확장 함수에 넣으면 안 되나요?????"라고 생각할 수도 있어요.
하지만 Edge-to-Edge 디자인은 상태 바 영역 아래로 콘텐츠가 이어지는 등 활용 가능성이 다양하다고 생각했어요. 그래서 패딩 값까지 이 함수가 제어하는 것은 단일 책임 원칙에 어긋나고, 유연성을 해친다고 판단해 적용하지 않았습니다.
Modifier.drawBehind를 활용해 컴포저블의 드로잉 단계에 직접 관여하는 방식으로 상태 바 색상 동기화 문제를 해결한 경험을 적어봤습니다.
이 방법으로 Deprecated 된 라이브러리를 대체하고, LaunchedEffect의 타이밍 이슈를 해결해 더 나은 사용자 경험을 제공할 수 있었어요. 무엇보다 팀원들이 복잡한 구현을 신경 쓸 필요 없이 modifier.statusBarColor(color) 한 줄로 기능을 사용할 수 있게 된 점이 가장 큰 성과라고 생각해요.
긴 글 읽어 주셔서 감사합니다😊
'Android > Compose' 카테고리의 다른 글
| [Android Compose] AboutLibraries로 오픈소스 라이선스 화면을 만들기 (0) | 2025.09.23 |
|---|---|
| [Android Compose] @Composable 종속성을 StateFlow로 바꿔보자 (0) | 2025.08.09 |
| [Android Compose] flowWithLifecycle은 언제 쓰면 좋을까? (0) | 2025.08.08 |
| [Android Compose] Effect Handlers 딥다이브 (0) | 2025.06.20 |
| [Android Compose] 상태 읽기 지연(Defer State Reads)으로 리컴포지션 최적화하기 (0) | 2025.06.18 |