
앱 개발하면서 가장 헷갈렸던 것 중 하나가 바로 Window Insets였다.
어떤 컴포넌트는 status bar, navigation bar와 겹쳐지는데 또 어떤 컴포넌트는 안 겹치고...
예를 들어 Scaffold나 ModalBottomSheet를 사용할 때는 따로 padding을 지정하지 않아도 자식들이 navigation bar 위(y축)에 배치되는데, 일반적인 다른 컴포넌트들을 사용할 때는 겹쳐서 배치된다.
그래서 헷갈렸던 개념을 한 번에 정리하고 가고자 한다.
Window Insets는 시스템 UI 영역을 담고 있는 객체이다.
이를 통해 앱의 요소가 시스템의 영역과 겹치게 하거나, 겹치지 않게 할 수 있다.
안드로이드에서 시스템 UI 영역은 대표적으로 아래와 같은 것들이 있다.
시간, 알림, 배터리 등이 표시되는 상단 영역
뒤로 가기, 백그라운드 전환 등을 담당하는 하단 영역.
Status Bar와 Navigation Bar를 합쳐서 System Bar라고 부른다.
키보드 영역.
키보드가 올라올 때만 영역을 차지하며, 올라오지 않은 상태에선 영역을 차지하지 않는다.
뒤로 가기나 홈 화면으로의 이동을 수행하는 제스처 인식 영역.
홀펀치, 곡면과 같은 디스플레이 잘림 영역

앱이 Android 15 (API 수준 35)를 타겟팅하는 경우 Android 15를 실행하는 기기에서 앱은 기본적으로 전체 화면으로 표시됩니다.
https://developer.android.com/about/versions/15/behavior-changes-15?hl=ko#edge-to-edge
안드로이드 버전 15인 기기에서 targetSDK가 35 이상인 앱을 실행하면 앱은 System Bar 영역까지 자동으로 사용하게 된다.
(그 미만의 버전에서는 enableEdgeToEdge를 호출해야 했다)
Jetpack Compose에서는 Modifier의 확장 함수를 통해 Window Insets 기능들이 제공된다.
이들을 실제 동작을 통해 어떻게 적용되는지 확인해보자. (테스트는 targetSDK 35, API 15 버전 디바이스에서 진행)
상단 status bar 영역 크기만큼의 Padding을 나타낸다.
Box(
Modifier
.fillMaxSize()
) {
Box(
Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Red)
)
}

빨간 영역이 최상단에 붙는다.
Box(
Modifier
.statusBarsPadding()
.fillMaxSize()
) {
Box(
Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Red)
)
}

빨간 영역이 최상단에서 status bar의 높이만큼 아래로 내려온다.
하단 navigation bar 영역 크기만큼의 Padding을 나타낸다.
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Red)
)
}

빨간 영역이 디바이스 최하단에 붙는다. (겹쳐짐으로 인해 네비게이션 바가 약간 빨개진 모습)
Box(
Modifier
.navigationBarsPadding()
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Red)
)
}

navigationBar의 높이만큼 빨간 영역이 올라온다.
위 예시에선 3-Button 네비게이션 바를 이용했는데, 제스처 네비게이션 바를 이용할 경우에도 동작은 동일하다.

기본적으로 제스처 바의 높이가 3-Button보다 작기 때문에, navigationBarsPadding을 적용했을 때의 빨간 영역의 높이가 살짝 더 낮게 표현된다.
그리고, navigationBarsPadding과 statusBarsPadding을 합친 systemBarsPadding()도 존재한다.
키보드 영역에 대한 Padding을 나타낸다.
키보드가 화면 상에서 보이는만큼을 Padding 값으로 가진다.
즉, 키보드가 화면에 존재하지 않을 경우 imePadding 값은 0이다.
Column(
Modifier
.fillMaxSize()
) {
TextField(
value = "",
onValueChange = {},
placeholder = { Text("TextField") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 100.dp)
)
Spacer(Modifier.weight(1f))
Spacer(
Modifier
.background(Color.Red)
.height(100.dp)
.fillMaxWidth()
)
}


키보드가 올라왔을 때, 빨간 영역이 가려진다.
Column(
Modifier
.fillMaxSize()
) {
TextField(
value = "",
onValueChange = {},
placeholder = { Text("TextField") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 100.dp)
)
Spacer(Modifier.weight(1f))
Spacer(
Modifier
.imePadding()
.background(Color.Red)
.height(100.dp)
.fillMaxWidth()
)
}

빨간 영역이 키보드의 높이만큼 올라온다.
당연하겠지만,
imePadding을 부여하더라도, 배치될 공간이 부족하다면 완벽하게 키보드 위까지 올라오지 않을 수 있다는 사실을 기억해야 한다.
시스템 제스처의 수행 영역이다.
Column(
Modifier
.fillMaxSize()
) {
Spacer(
Modifier
.fillMaxSize()
.background(Color.DarkGray)
)
}

Column(
Modifier
.fillMaxSize()
.systemGesturesPadding()
) {
Spacer(
Modifier
.fillMaxSize()
.background(Color.DarkGray)
)
}


좌우 제스처가 사라지기 때문에, 좌우 패딩값이 0이 된다.
홀펀치, 곡면과 같은 디스플레이 잘림 영역이다.
Column(
Modifier
.fillMaxSize()
.displayCutoutPadding()
) {
Spacer(
Modifier
.fillMaxSize()
.background(Color.DarkGray)
)
}

상단의 전면 카메라 렌즈 부분만큼 패딩으로 주어진다.
Window Insets를 사용할 때 가장 중요한 개념이 바로 consume이다.
Window Insets는 시스템 UI에 의해 가려진 영역 정보를 담고 있다.
그런데 여러 컴포저블이 이 inset 값을 동시에 사용하면 중복 padding이 생길 가능성이 존재한다.
따라서 어디선가 이 inset을 이미 사용했다면, 이후 다른 곳에서는 해당 inset을 다시 사용하지 못하도록 제어해야 한다.
그리고 Compose에선 consume이라는 개념으로 이를 지원한다.
이는 부모 컴포저블에서 이미 소비한 inset은 자식 컴포저블에서 다시 사용할 수 없도록 만드는 역할을 한다.
위에서 사용했던 statusBarsPadding, navigationBarsPadding 등을 사용하면, consume을 직접 신경 쓸 필요가 없다.
알아서 consume 처리해주기 때문이다.
가령 아래 코드처럼 statusBarsPadding을 2번 적용하더라도,
Box(
Modifier
.statusBarsPadding()
.fillMaxSize()
) {
Box(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(100.dp)
.background(Color.Red)
)
}

이렇게 padding은 실제로는 한 번만 적용된다.
단, 부모-자식의 아닌 경우에는 consume 개념이 적용되지 않는다.
Box(
Modifier
.fillMaxSize()
) {
Box(
Modifier
.align(Alignment.TopStart)
.fillMaxWidth(.5f)
.statusBarsPadding()
.height(100.dp)
.background(Color.Red)
)
Box(
Modifier
.align(Alignment.TopEnd)
.fillMaxWidth(.5f)
.statusBarsPadding()
.height(100.dp)
.background(Color.Blue)
)
}

(빨강, 파랑 모두 각자 statusBarsPadding이 적용된 모습)
WindowInsets.statusBars.asPaddingValues()
그리고 위와 같은 코드로 여러 Window Insets의 실제 수치를 얻을 수 있다.
하지만 consume이 자동으로 처리되지 않기 때문에, 별다른 이유가 없다면 기본적으로 제공되는 statusBarsPadding과 같은 함수를 이용하는 것이 안전하다.
참고로, 특정 Inset의 소비는 아래의 Modifier 확장 함수를 이용할 수 있다.
Modifier.windowInsetsPadding(WindowInsets.systemBars) // systemBarsPadding이 소비됨
Jetpack Compose에선 Material Component를 이용했을 때 Window Insets가 자동으로 적용되는 경우가 있다.
Scaffold, TopAppBar 같은 것들이 그러하다.
이 컴포넌트들은 따로 Insets를 지정해주지 않아도 system insets를 자동 적용한다.
(필자는 Insets 개념을 잘 몰랐을 때, 이거 때문에 자주 헷갈렸다)
Scaffold를 사용하면 종종 마주치는 것이 innerPadding이다.

innerPadding을 사용하지 않으면 무려 빨간 줄 경고를 준다. (안 써도 실행은 된다)
innerPadding은 Scaffold가 자동으로 계산한 system insets 값이다.
예를 들어, topBar가 있을 경우 topBar 영역 크기만큼 상단 여백을 주고, bottomBar가 있으면 하단 여백을 부여한다.
알맞은 여백을 부여해서 컨텐츠가 정확히 topBar와 bottomBar 사이에 위치하도록 하는 것이다.
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFFFF7171),
titleContentColor = Color.White,
),
title={ Text("Top Bar") }
)
},
bottomBar = {
BottomAppBar(
containerColor = Color(0xFF4786FF),
contentColor = Color.White,
){
Text("Bottom Bar")
}
}
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.padding(innerPadding) // innerPadding 부여
.background(Color.DarkGray)
) {
Spacer(Modifier
.fillMaxWidth()
.background(Color.Blue)
.height(200.dp)
)
}
}

2. innerPadding 부여X (topBar에 가려져 컨텐츠 일부가 잘림)

Scaffold에 topBar가 지정되지 않으면 innerPadding은 statusBar Padding을 포함한다. (bottomBar는 navigationBar Padding)
Scaffold(
bottomBar = {
BottomAppBar(
containerColor = Color(0xFF4786FF),
contentColor = Color.White,
){
Text("Bottom Bar")
}
}
) { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Color.DarkGray)
)
}

statusBar Padding만큼 컨텐츠가 내려왔다.
TopAppBar와 같은 몇몇 Material Component는 Window Insets가 이미 적용되어 있으므로 따로 더 신경쓸 것이 없지만, TopAppBar를 사용하지 않고 Top Bar를 커스텀할 경우 Window Insets를 수동으로 관리해야 한다.
Scaffold(
topBar = {
Row(Modifier.fillMaxWidth().background(Color(0xFFFF7474))) {
Text("Top Bar")
}
},
bottomBar = {
BottomAppBar(
containerColor = Color(0xFF4786FF),
contentColor = Color.White,
){
Text("Bottom Bar")
}
}
) { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Color.DarkGray)
)
}

statusBar Padding을 적용하지 않아 Top Bar의 컨텐츠(Text)가 일부 잘렸다.
Scaffold(
topBar = {
Row(Modifier
.fillMaxWidth()
.background(Color(0xFFFF7474))
.statusBarsPadding() // Window Insets 관리
) {
Text("Top Bar")
}
},
bottomBar = {
BottomAppBar(
containerColor = Color(0xFF4786FF),
contentColor = Color.White,
){
Text("Bottom Bar")
}
}
) { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Color.DarkGray)
)
}
