Android
더 나은 유저 몰입감을 위해서는 Edge-to-edge 적용은 필수
김주엽(Groo) / Android Developer여기어때
2024년 12월 31일
원문에서 보기 ↗글. 김주엽(Groo) / Android Developer
이번 글에서는 Android Compose 환경에서 Edge-to-edge를 적용하는 방법에 대해서 알아볼게요.
Edge-to-edge란?

Status Bar와 Navigation Bar 영역까지 콘텐츠를 표시하여 화면 전체를 활용할 수 있는 UI 모드에요.
왜 적용해야 할까요?
화면의 공간을 최대한 활용하므로 유저들에게 더 몰입감 있는 경험을 제공할 수 있으며, 앱의 콘텐츠와 시스템 UI가 더 통합된 느낌을 줘요.
구글은 앞으로 Android 15 이상 단말에서 targetSdkVersion이 35 이상인 앱을 실행하면 강제로 Edge-to-edge를 적용해요. 따라서 앱의 targetSdkVersion을 35 이상으로 업데이트하기 전에 Edge-to-edge 대응은 필수라고 볼 수 있어요.
어떻게 적용하나요?
androidx.activity 라이브러리에서 지원해 주는 enableEdgeToEdge를 사용하면 돼요. 사용법은 엄청 간단한데요.
build.gradle 파일에 androidx.activity 라이브러리 의존성을 추가해 준 후
dependencies {
implementation("androidx.activity:activity-ktx:1.9.3")
}
Edge-to-edge를 적용하고 싶은 Activity의 onCreate에서 enableEdgeToEdge를 호출하면 끝이에요. 한 가지 주의할 점은 setContent 이전에 enableEdgeToEdge를 호출해야 해요.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent{
...
}
}
딥다이브 해보기
enableEdgeToEdge는 statusBarStyle과 navigationBarStyle을 파라미터로 전달받고 있어요.
@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
...
}
SystemBarStyle
두 파라미터의 타입인 SystemBarStyle은 statusBar와 navigationBar의 스타일을 관리하는 클래스라고 보면 돼요.
- lightScrim : 라이트 모드일 때 설정할 색상
- darkScrim : 다크 모드일 때 설정할 색상
- nightMode : 다크 모드 타입
- detectDarkMode : 현재 다크 모드 여부 반환
class SystemBarStyle private constructor(
private val lightScrim: Int,
internal val darkScrim: Int,
internal val nightMode: Int,
internal val detectDarkMode: (Resources) -> Boolean
) {
...
}
SystemBarStyle의 companion object에는 여러 팩토리 메서드가 존재해요.
1. auto
라이트, 다크 모드를 모두 지원할 때 사용해요.
- nightMode : MODE_NIGHT_AUTO
- detectDarkMode : 현재 테마가 다크 모드이면 true 반환
하지만 API 레벨에 따라서 statusBar, navigationBar 컬러 정책이 다르다는 특징이 있는데요.
API ≥ 29
- 파라미터로 전달받은 lightScrim, darkScrim과 무관하게 statusBar, navigationBar가 투명하게 설정돼요.
- 3버튼 또는 2버튼 네비게이션 모드에서는 navigationBar가 반투명하게 설정돼요.
즉, 파라미터로 원하는 색상을 전달해도 해당 색상이 적용되지 않는다는 거예요.
API ≤ 28
- statusBar는 투명하게 설정되지만 navigationBar는 파라미터로 전달받은 darkScrim 값으로 설정돼요.
즉, lightScrim 파라미터는 결국 사용되지 않아요.
@JvmStatic
@JvmOverloads
fun auto(
@ColorInt lightScrim: Int,
@ColorInt darkScrim: Int,
detectDarkMode: (Resources) -> Boolean = { resources ->
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
}
): SystemBarStyle {
return SystemBarStyle(
lightScrim = lightScrim,
darkScrim = darkScrim,
nightMode = UiModeManager.MODE_NIGHT_AUTO,
detectDarkMode = detectDarkMode
)
}
2. dark
다크 모드만 지원할 때 사용해요.
- nightMode : MODE_NIGHT_YES
- detectDarkMode : 항상 true 반환
이 경우, API 레벨에 관계없이 전달받은 색상이 그대로 적용돼요.
@JvmStatic
fun dark(@ColorInt scrim: Int): SystemBarStyle {
return SystemBarStyle(
lightScrim = scrim,
darkScrim = scrim,
nightMode = UiModeManager.MODE_NIGHT_YES,
detectDarkMode = { _ -> true }
)
}
3. light
라이트 모드만 지원할 때 사용해요.
- nightMode : MODE_NIGHT_NO
- detectDarkMode : 항상 false 반환
이 경우, API 레벨에 관계없이 전달받은 색상이 그대로 적용돼요.
@JvmStatic
fun light(@ColorInt scrim: Int, @ColorInt darkScrim: Int): SystemBarStyle {
return SystemBarStyle(
lightScrim = scrim,
darkScrim = darkScrim,
nightMode = UiModeManager.MODE_NIGHT_NO,
detectDarkMode = { _ -> false }
)
}
SystemBarStyle에는 두 개의 멤버 메서드가 추가로 존재하는데요.
1. getScrim
현재 테마가 다크 모드이면 darkScrim을 아니면 lightScrim을 반환해요. 이 메서드는 API ≤ 28에서 사용해요.
internal fun getScrim(isDark: Boolean) = if (isDark) darkScrim else lightScrim
2. getScrimWithEnforcedContrast
앞서 본 getScrim과 거의 동일하지만 nightMode가 MODE_NIGHT_AUTO이면 투명을 반환해요. 이전의 auto 팩토리 메서드에서 API ≥ 29에서는 statusBar와 navigationBar 색상이 light/dark Scrim 상관없이 모두 투명으로 설정된다고 했던 이유가 바로 이 코드 때문이에요. 이 메서드는 API ≥ 29에서 사용해요.
internal fun getScrimWithEnforcedContrast(isDark: Boolean): Int {
return when {
nightMode == UiModeManager.MODE_NIGHT_AUTO -> Color.TRANSPARENT
isDark -> darkScrim
else -> lightScrim
}
}
자, 그럼 다시 돌아와서 enableEdgeToEdge 내부 코드를 계속 살펴볼게요. 앞에서 봤던 SystemBarStyle의 detectDarkMode를 활용해 statusBar와 navigationBar의 현재 다크 모드 여부를 조회해요.
@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
val view = window.decorView
val statusBarIsDark = statusBarStyle.detectDarkMode(view.resources)
val navigationBarIsDark = navigationBarStyle.detectDarkMode(view.resources)
...
}
그리고 각 API 레벨별로 서로 다른 EdgeToEdgeApi를 만든 후 setUp과 adjustLayoutInDisplayCutoutMode 메서드를 호출해요.
@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
...
val impl = Impl ?: if (Build.VERSION.SDK_INT >= 30) {
EdgeToEdgeApi30()
} else if (Build.VERSION.SDK_INT >= 29) {
EdgeToEdgeApi29()
} else if (Build.VERSION.SDK_INT >= 28) {
EdgeToEdgeApi28()
} else if (Build.VERSION.SDK_INT >= 26) {
EdgeToEdgeApi26()
} else if (Build.VERSION.SDK_INT >= 23) {
EdgeToEdgeApi23()
} else if (Build.VERSION.SDK_INT >= 21) {
EdgeToEdgeApi21()
} else {
EdgeToEdgeBase()
}.also { Impl = it }
impl.setUp(
statusBarStyle, navigationBarStyle, window, view, statusBarIsDark, navigationBarIsDark
)
impl.adjustLayoutInDisplayCutoutMode(window)
}
EdgeToEdgeImpl
EdgeToEdgeApi는 EdgeToEdgeImpl 인터페이스의 구현체에요. 해당 인터페이스에는 setUp과 adjustLayoutInDisplayCutoutMode 메서드 두 개가 존재해요.
1. setUp
statusBar와 navigationBar의 정보를 받아서 기본적인 edgeToEdge 설정을 해요.
2. adjustLayoutInDisplayCutoutMode
디스플레이의 컷아웃 부분을 어떻게 조정할지 설정해요.
private interface EdgeToEdgeImpl {
fun setUp(
statusBarStyle: SystemBarStyle,
navigationBarStyle: SystemBarStyle,
window: Window,
view: View,
statusBarIsDark: Boolean,
navigationBarIsDark: Boolean
)
fun adjustLayoutInDisplayCutoutMode(window: Window)
}
이때 컷아웃은 디스플레이의 일부 영역을 차지 하는 하드웨어 요소를 뜻해요.

1번으로 표시된 부분은 전면 카메라를 위한 컷아웃 영역이에요.
EdgeToEdgeApi
이제 각 API 레벨별로 이 EdgeToEdgeImpl을 어떻게 구현했는지 볼게요.
API < 21
21 미만에서는 Edge-to-edge를 지원하지 않기 때문에 setUp 메서드 내부가 비어 있어요. 또한, 28 미만에서는 컷아웃도 지원하지 않기 때문에 adjustLayoutInDisplayCutoutMode 메서드 내부도 비어 있어요.
private open class EdgeToEdgeBase : EdgeToEdgeImpl {
override fun setUp(
statusBarStyle: SystemBarStyle,
navigationBarStyle: SystemBarStyle,
window: Window,
view: View,
statusBarIsDark: Boolean,
navigationBarIsDark: Boolean
) {
// No edge-to-edge before SDK 21.
}
override fun adjustLayoutInDisplayCutoutMode(window: Window) {
// No display cutout before SDK 28.
}
}
API 21, 22
Edge-to-edge에서 가장 중요한 부분이 나왔어요. decorFitsSystemWindows를 false로 설정해 줘야만 앱의 콘텐츠를 statusBar, navigationBar 영역까지 확장할 수 있어요.
그리고 FLAG_TRANSLUCENT_STATUS, FLAG_TRANSLUCENT_NAVIGATION을 통해서 statusBar와 navigationBar를 투명하게 설정하고 있어요. 즉, 21, 22에서는 파라미터로 전달받은 statusBarStyle, navigationBarStyle과 상관없이 statusBar와 navigationBar가 항상 투명하게 설정돼요.
@RequiresApi(21)
private class EdgeToEdgeApi21 : EdgeToEdgeBase() {
@Suppress("DEPRECATION")
@DoNotInline
override fun setUp(
statusBarStyle: SystemBarStyle,
navigationBarStyle: SystemBarStyle,
window: Window,
view: View,
statusBarIsDark: Boolean,
navigationBarIsDark: Boolean
) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
}
}
API 23, 24, 25
드디어 파라미터로 전달받은 statusBarStyle, navigationBarStyle을 활용해요. statusBarColor 같은 경우 getScrim 메서드를 호출해서 현재 테마 여부에 따라 알맞은 색상을 설정하고 있으나 navigationBarColor는 항상 darkScrim으로 설정해요.
isAppearanceLightStatusBars는 statusBar 내에 존재하는 아이콘, 텍스트 같은 정보들의 색상 관련 필드에요. 만약 이 값이 true로 설정되면 해당 정보들이 어두운 색상으로 설정되고 false이면 밝은 색상으로 설정돼요. 즉, statusBar가 light이면 어두운 색상으로 statusBar가 dark이면 밝은 색상으로 설정되어야 하므로 statusBarIsDark의 반대 값으로 설정해 줘요.
@RequiresApi(23)
private class EdgeToEdgeApi23 : EdgeToEdgeBase() {
@DoNotInline
override fun setUp(
statusBarStyle: SystemBarStyle,
navigationBarStyle: SystemBarStyle,
window: Window,
view: View,
statusBarIsDark: Boolean,
navigationBarIsDark: Boolean
) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = statusBarStyle.getScrim(statusBarIsDark)
window.navigationBarColor = navigationBarStyle.darkScrim
WindowInsetsControllerCompat(window, view).isAppearanceLightStatusBars = !statusBarIsDark
}
}
API 26, 27
이제 navigationBarColor도 getScrim 메서드를 호출해서 현재 테마 여부에 따라 알맞은 색상이 설정되도록 했어요. 그리고 isAppearnceLightNavigationBars도 앞에서 봤던isAppearanceLightStatusBars와 동일해요.
@RequiresApi(26)
private open class EdgeToEdgeApi26 : EdgeToEdgeBase() {
@DoNotInline
override fun setUp(
statusBarStyle: SystemBarStyle,
navigationBarStyle: SystemBarStyle,
window: Window,
view: View,
statusBarIsDark: Boolean,
navigationBarIsDark: Boolean
) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = statusBarStyle.getScrim(statusBarIsDark)
window.navigationBarColor = navigationBarStyle.getScrim(navigationBarIsDark)
WindowInsetsControllerCompat(window, view).run {
isAppearanceLightStatusBars = !statusBarIsDark
isAppearanceLightNavigationBars = !navigationBarIsDark
}
}
}
API 28
28부터는 컷아웃 모드가 지원되기 시작해서 이제 adjustLayoutInDisplayCutoutMode를 구현해요. 컷아웃 모드는 4가지가 존재하는데요.
- LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
- 기본값
- 일반적으로는 컷아웃 영역에 콘텐츠를 표시하지 않아요
- 제조사나 시스템 정책에 따라서 컷아웃 영역 활용법이 달라질 수 있어요
- LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
- 단말에서 짧은 변의 영역에 위치하는 컷아웃에 대해서만 콘텐츠를 확장해요.
- 세로 모드일 때는 상, 하단 그리고 가로 모드일 때는 좌, 우측이 짧은 변이 될 거예요.
- LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
- 컷아웃 영역에 콘텐츠를 절대 확장하지 않아요.
- 제조사나 시스템 정책에 영향을 받지 않아요.
- LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
- 크기, 위치 상관없이 모든 컷아웃 영역에 콘텐츠를 확장해요.
28에서는 LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES를 사용해요.
@RequiresApi(28)
private open class EdgeToEdgeApi28 : EdgeToEdgeApi26() {
@DoNotInline
override fun adjustLayoutInDisplayCutoutMode(window: Window) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
API 29
getScrim을 사용하지 않고 getScrimWithEnforcedContrast 함수를 사용해요.
@RequiresApi(29)
private open class EdgeToEdgeApi29 : EdgeToEdgeApi28() {
@DoNotInline
override fun setUp(
statusBarStyle: SystemBarStyle,
navigationBarStyle: SystemBarStyle,
window: Window,
view: View,
statusBarIsDark: Boolean,
navigationBarIsDark: Boolean
) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = statusBarStyle.getScrimWithEnforcedContrast(statusBarIsDark)
window.navigationBarColor =
navigationBarStyle.getScrimWithEnforcedContrast(navigationBarIsDark)
window.isStatusBarContrastEnforced = false
window.isNavigationBarContrastEnforced =
navigationBarStyle.nightMode == UiModeManager.MODE_NIGHT_AUTO
WindowInsetsControllerCompat(window, view).run {
isAppearanceLightStatusBars = !statusBarIsDark
isAppearanceLightNavigationBars = !navigationBarIsDark
}
}
}
API ≥ 30
컷아웃에 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS를 사용해요.
@RequiresApi(30)
private class EdgeToEdgeApi30 : EdgeToEdgeApi29() {
@DoNotInline
override fun adjustLayoutInDisplayCutoutMode(window: Window) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
}
}
API 별 EdgeToEdgeApi를 정리하면 이렇게 돼요.

UI가 겹치는 이슈는 어떻게 해결하나요?

enableEdgeToEdge를 적용하고 앱을 빌드해보면 콘텐츠가 statusBar, navigationBar들과 겹치는 이슈를 볼 수 있어요.
Edge-to-edge의 본래 목적이 콘텐츠를 시스템 UI 영역까지 확장하는 것이기 때문에, 이러한 동작은 어느 정도 예상한 결과라고 볼 수 있어요. 하지만 이렇게 콘텐츠가 시스템 UI와 겹치게 되면 사용자가 원하는 영역을 정확히 클릭하기 어렵거나, 시스템 UI에 가려져 중요한 정보를 확인하기 어려운 문제가 생겨요. 따라서 화면별로 시스템 UI와 겹쳐서는 안 되는 부분들을 부분적으로 조정해주는 작업이 필요해요.
WindowInsets
이때 사용할 수 있는 도구가 바로 androidx.compose.foundation에서 지원해 주고 있는 WindowInsets인데요. WindowInsets는 콘텐츠들이 시스템 UI에 가려지지 않도록 시스템 UI에 관한 정보를 제공해요. 아래는 대표적인 WindowInsets들이에요.
WindowInsets.statusBars
- 상태 표시줄에 대한 정보
WindowInsets.navigationBars
- 네비게이션바에 대한 정보
WindowInsets.systemBars
- 상태 표시줄, 네비게이션바, 자막 표시줄을 모두 포함한 정보
WindowInsets.ime
- 소프트웨어 키보드에 대한 정보
WindowInsets.displayCutout
- 컷아웃에 대한 정보
WindowInsets.safeDrawing
- 상태 표시줄, 네비게이션바, 자막 표시줄, 소프트웨어 키보드, 컷아웃을 모두 포함한 정보
WindowInsets는 인터페이스로서 4가지 메서드를 가지고 있어요. 해당 WindowInsets이 디바이스의 left, top, right, bottom 영역에서 얼마만큼의 크기를 차지하고 있는지 px로 반환해요.
@Stable
interface WindowInsets {
/**
* The space, in pixels, at the left of the window that the inset represents.
*/
fun getLeft(density: Density, layoutDirection: LayoutDirection): Int
/**
* The space, in pixels, at the top of the window that the inset represents.
*/
fun getTop(density: Density): Int
/**
* The space, in pixels, at the right of the window that the inset represents.
*/
fun getRight(density: Density, layoutDirection: LayoutDirection): Int
/**
* The space, in pixels, at the bottom of the window that the inset represents.
*/
fun getBottom(density: Density): Int
companion object
}
예를 들어 statusBars, navigationBars의 크기가 100px이라고 가정하면 아래 3가지 WindowInsets는 이렇게 값을 반환해요.
WindowInsets.statusBars
- getLeft : 0
- getTop : 100
- getRight : 0
- getBottom : 0
WindowInsets.navigationBars
- getLeft : 0
- getTop : 0
- getRight : 0
- getBottom : 100
WindowInsets.safeDrawing
- getLeft : 0
- getTop : 100
- getRight : 0
- getBottom : 100
이처럼 WindowInsets를 통해 시스템 UI에 대한 정보를 얻을 수 있으니 이제 UI 겹침 이슈도 해결할 수 있겠죠? 앞에서 봤던 화면을 기준으로 했을 때 타이틀에는 statusBars 크기만큼 상단 padding을 주고 FAB에는 navigationBars 크기만큼 하단 padding을 주면 돼요.
@Composable
fun Screen() {
val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() }
val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() }
Column {
Text(
modifier = Modifier.padding(top = statusBarHeightDp),
)
Box {
LazyColumn()
FloatingActionButton(
modifier = Modifier.padding(bottom = navigationBarHeightDp),
)
}
}
}

더 나아가 구글은 이런 padding 처리를 조금 더 편하게 할 수 있도록 Modifier에 여러 확장 함수를 제공해 주고 있어요.
- Modifier.statusBarsPadding()
- Modifier.navigationBarsPadding()
- Modifier.safeDrawingPadding()
@Composable
fun Screen() {
Column {
Text(
modifier = Modifier.statusBarsPadding(),
)
Box {
LazyColumn()
FloatingActionButton(
modifier = Modifier.navigationBarsPadding(),
)
}
}
}
하지만 한 가지 이슈가 남았는데요. 바로 그 이슈는 리스트 아이템을 끝까지 스크롤 했을 때 마지막 아이템이 navigationBar와 겹치는 것이에요.

이를 해결하기 위해서는 LazyColumn의 마지막 item으로 navigationBars 크기만큼의 Spacer를 추가하면 돼요.
@Composable
fun Screen() {
Column {
Text()
Box {
LazyColumn {
...
item {
Spacer(Modifier.navigationBarsPadding())
}
}
FloatingActionButton()
}
}
}

이전의 FAB처럼 LazyColumn의 modifier에서 navigationBarsPadding을 설정하면 되지 않을까? 라고도 생각할 수 있어요. 하지만 그렇게 적용하면 LazyColumn 컨테이너 자체가 navigationBar 위까지만 그려지기 때문에 Edge-to-edge를 적용한 목적이 흐려져요.

디자인 시스템과 Edge-to-edge
여기어때는 YDS(여기어때 디자인 시스템)를 기반으로 UI를 설계하고 있으며, 디자인 시스템을 기반으로 Edge-to-edge를 결합할 때는 화면별로 다양한 상태를 고려해야 해요.
statusBar

상단에 제휴점 사진이 노출될 때는 statusBar를 투명하게 설정해 유저가 사진에 몰입할 수 있도록 하고, 사진이 노출되지 않을 때는 statusBar를 하얗게 설정해 시각적 일관성을 유지하면서 제휴점의 세부 정보에 집중할 수 있도록 해요.
navigationBar

하단에 버튼이 노출되지 않을 때는 navigationBar를 투명하게 설정해 유저가 제휴점의 세부 정보에 몰입할 수 있도록 하고, 버튼이 노출될 때는 navigationBar를 하얗게 설정해 시각적 일관성을 유지해요.
이렇게 Edge-to-edge 환경에서 디자인 시스템을 사용하는 경우 각 화면의 UI 레이아웃과 콘텐츠 상태에 따라 statusBar와 navigationBar 설정을 유연하게 조정해야 해요.
마치며
기존 앱에 Edge-to-edge를 적용하는 것은 생각보다 고려해야 할 사항이 많아요. 충분한 시간을 갖고 UX팀과 협력하여, 유저들에게 더 나은 몰입감을 제공할 수 있는 방법을 함께 고민해보는 것이 중요해요. 글을 읽으시면서 궁금한 점이 있으시면 언제든지 댓글로 남겨주세요. 감사합니다 😊
글쓴이 정보
참고 자료