안녕하세요! Android 개발자 한민재입니다.
이번에 Spoony라는 프로젝트를 진행하면서 겪었던 이슈를 다뤄보려고 해요. Spoony Android는 Only Compose로 개발했어요. 그 중, 게시글 등록 화면을 개발하던 중 마주친 짜증나는 문제와 그 해결 방법을 공유하려고 합니다! Compose로 개발하시는 분들이라면 한 번쯤 마주칠 수 있는 문제라 생각되어서, 제가 겪은 경험과 해결 과정을 정리해보았어요.
🤔 어떤 문제가 있었나요?
게시글 등록 화면을 개발하던 중이었는데요. Modifier.imePadding()을 사용했을 때 키보드 위에 이상하게 큰 하얀 여백이 생기는 현상을 발견했어요.
특이한 점은 이 현상이 android:windowSoftInputMode="adjustResize"의 설정 여부와 관계가 있을까? 라고 생각했었지만 적용 여부와는 상관없이 발생했어요. 저는 어떤 패딩이 이중적용 되는 문제라고 가정했고, 왜 이런 현상이 발생하는지 궁금해서 Compose의 내부 구현을 자세히 들여다보기 시작했어요.
🔍 원인을 파헤쳐봤어요
Android의 Window 시스템은 어떻게 동작하나요?
IME가 등장할 때 Window 시스템은 다음과 같은 과정을 거쳐요
( 다소 간략하게 작성한 부분이 있어요.)
InputMethodManager
↓
InputMethodService
↓
WindowManager (WindowInsets 생성 및 전파)
↓
WindowInsetsController를 통한 이벤트 처리
Compose 기반 앱에서는 개발자가 직접 InputMethodManager나 WindowManager를 호출하지 않더라도,
시스템 내부에서는 위와 같은 과정을 통해 IME 관련 창 및 인셋이 관리돼요.
이해를 돕기 위해 내부 동작의 시뮬레이션 코드를 작성해봤어요.
(아래 코드는 실제 Compose 코드에 포함되진 않지만 내부 동작을 이해하기 위한 참고 자료에요.)
[1] 앱에서 입력 요청이 발생하는 단계 (InputMethodManager)
class MyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 전통적인 View 기반에서는 특정 뷰에 포커스를 주고 직접 키보드를 요청해요.
val focusView: View = findViewById(R.id.focus_view)
focusView.requestFocus()
showKeyboard(focusView)
}
private fun showKeyboard(view: View) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
// InputMethodManager는 내부적으로 InputMethodService와 통신하여 키보드를 준비해요.
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
}
}
[2] InputMethodService가 IME 창을 생성하고 WindowManager가 WindowInsets를 관리하는 단계
class InputMethodServiceSimulator : InputMethodService() {
override fun onCreateInputView(): View {
// IME 뷰 생성
return layoutInflater.inflate(R.layout.input_view, null)
}
fun showInputMethod(inputView: View) {
// 실제로 IME를 띄우려면 InputMethodManager를 사용해야 해요
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)
}
}
class WindowManagerSimulator {
// 시스템에서 onApplyWindowInsetsListener나 InsetsController로 전달받은 insets를 그대로 사용
fun onImeShown(systemInsets: WindowInsetsCompat) {
propagateInsetsToViews(systemInsets)
}
private fun propagateInsetsToViews(insets: WindowInsetsCompat) {
// 각 뷰는 전달받은 insets 정보를 바탕으로 레이아웃 조정
// Compose에서는 Modifier.imePadding() 등이 이 역할을 대신해줘요.
}
}
[3] WindowInsetsController를 통한 IME 표시/숨김 및 애니메이션 처리
class ImeWindowInsetsController(private val window: Window) {
private val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
fun showIme() {
windowInsetsController.show(WindowInsetsCompat.Type.ime())
}
fun hideIme() {
windowInsetsController.hide(WindowInsetsCompat.Type.ime())
}
fun setupImeAnimation() {
// WindowInsetsAnimation.Callback을 사용하여 애니메이션 처리
val cb = object : WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(insets: WindowInsets, runningAnimations: MutableList<WindowInsetsAnimation>): WindowInsets {
// 애니메이션 진행에 따른 UI 업데이트
return insets
}
}
window.decorView.setWindowInsetsAnimationCallback(cb)
}
}
그럼 Compose는 WindowInsets를 어떻게 처리할까요?
Compose의 소스 코드를 분석해봤는데요, 주로 살펴본 파일들은 다음과 같아요
- androidx.compose.foundation.layout.WindowInsets.android.kt
- androidx.compose.foundation.layout.WindowInsets.kt
WindowInsets.android.kt를 보면 다음과 같은 구현이 있어요
internal class WindowInsetsHolder private constructor(
insets: WindowInsetsCompat?,
view: View
) {
val ime = systemInsets(
insets,
WindowInsetsCompat.Type.ime(),
"ime"
)
internal fun update(windowInsetsCompat: WindowInsetsCompat, types: Int) {
if (types == 0 || types and type != 0) {
insets = windowInsetsCompat.getInsets(type)
isVisible = windowInsetsCompat.isVisible(type)
}
}
}
여기서 알 수 있는 중요한 점은:
- Compose는 시스템의 WindowInsets 값을 정확하게 전달받고 있어요.
- WindowInsetsHolder는 IME의 상태와 크기 정보를 실시간으로 추적하고 있어요.
그런데 왜 이중 패딩이 생기는 걸까요?
문제는 두 가지 패딩 처리가 독립적으로 일어난다는 점이에요
- 시스템이 처리하는 부분
private class InsetsListener(
val composeInsets: WindowInsetsHolder,
) : WindowInsetsAnimationCompat.Callback(
if (composeInsets.consumes)
DISPATCH_MODE_STOP
else
DISPATCH_MODE_CONTINUE_ON_SUBTREE
)
InsetsListener는 시스템으로부터 전달된 WindowInsets 애니메이션 이벤트를 Compose로 연결하고 적용하기 위한 중간 처리자 역할을 해요. 시스템이 직접 패딩을 적용하는 게 아니라, Compose 내부에서 이 정보를 활용해 레이아웃을 조정하는 방식이에요.
- Compose가 처리하는 부분
actual val WindowInsets.Companion.ime: WindowInsets
@Composable
@NonRestartableComposable
get() = WindowInsetsHolder.current().ime
그런데 Compose는 시스템이 전달한 WindowInsets를 기반으로 Modifier.imePadding()을 통해 UI에 패딩을 적용해요.
시스템과 Compose가 각기 독립적으로 Insets을 해석/처리하는 구조라 중복된 패딩처럼 보이는 현상이 생긴거에요.
💡 어떻게 해결했을까요?
이 문제를 해결하기 위해 다음과 같은 커스텀 Modifier를 만들었어요
fun Modifier.advancedImePadding() = composed {
var consumePadding by remember { mutableStateOf(0) }
onGloballyPositioned { coordinates ->
consumePadding = coordinates.findRootCoordinates().size.height -
(coordinates.positionInWindow().y + coordinates.size.height).toInt().coerceAtLeast(0)
}
.consumeWindowInsets(
PaddingValues(bottom = with(LocalDensity.current) { consumePadding.toDp() })
)
.imePadding()
}
이 코드가 어떻게 동작하는지 하나씩 살펴볼까요?
1. 위치 추적
onGloballyPositioned { coordinates ->
consumePadding = coordinates.findRootCoordinates().size.height -
(coordinates.positionInWindow().y + coordinates.size.height).toInt().coerceAtLeast(0)
- onGloballyPositioned:
- 컴포넌트가 Window 내에서 어디에 위치하는지 정확히 파악할 수 있도록 도와줘요.
- 이 콜백은 컴포넌트가 화면에 배치된 후 최종 위치와 크기 정보를 받아올 때 호출돼요.
- findRootCoordinates():
- 전체 Window의 크기와 위치를 기준으로 컴포넌트의 위치를 계산하는 데 활용해요.
- 현재 컴포넌트가 속한 최상위(root) 컴포넌트의 좌표를 반환해요.
- positionInWindow()와 size.height:
- 이후 패딩을 계산할 때, 전체 Window 높이와의 차이를 통해 실제 필요한 여백을 산출하는 기준이에요.
- 컴포넌트의 시작 위치와 높이를 이용해, 컴포넌트 하단의 Window 내 위치를 구해요.
2. WindowInsets 처리
.consumeWindowInsets(
PaddingValues(bottom = with(LocalDensity.current) { consumePadding.toDp() })
)
- consumeWindowInsets():
- 이 함수는 해당 컴포넌트에서 인셋 정보를 처리하고 하위 컴포넌트로 전파되지 않도록 차단하는 역할을 해요. 시스템에서 전달된 WindowInsets를 실제로 소비하는 건 아니며, 전파 범위를 제어하는 용도에요.
- PaddingValues를 통한 패딩 적용:
- 정확하게 계산된 패딩 값만을 적용함으로써, 시스템 인셋과 중복되어 적용되는 것을 막아 깔끔한 UI를 유지해요.
- 앞서 계산된 consumePadding 값을 현재 화면의 밀도(LocalDensity.current.density)로 나눈 후, dp 단위로 변환해 적용해요.
무슨 소리냐구요?
한줄요약: 계산된 패딩 값으로 WindowInsets를 소비하고, 중복 적용을 방지해요.
🎯 실제로 어떻게 사용하나요?
Spoony 게시글 등록 화면에서는 이렇게 사용했어요
@Composable
fun RegisterStepOneScreen(/*...*/) {
Column(
modifier = Modifier
.fillMaxSize()
.advancedImePadding()
.verticalScroll(rememberScrollState())
) {
// 콘텐츠
}
}
잘 적용된 모습!
📝 정리!
- 문제의 핵심
- IME 패딩이 시스템과 Compose에서 각각 독립적으로 처리되면서 이중으로 적용되고 있었어요
- 이건 adjustResize 설정과는 무관한 현상이었어요
- 해결 방법
- consumeWindowInsets()로 중복 적용을 방지했어요
- 정확한 패딩 계산으로 자연스러운 UI를 구현할 수 있었어요
여기까지가 제가 겪은 IME 패딩 문제와 해결 과정이었는데요. 혹시 비슷한 문제를 겪고 계신 분들께 도움이 되었으면 좋겠어요!
더 좋은 해결 방법이나 잘못된 정보 혹은 의견이 있으시다면 댓글로 공유해주시면 감사하겠습니다 :)
2025/6/18 추가
화면이 구성되면서 애니메이션 도중 consumePadding이 음수가 되면 앱이 크래시가 발생하는 문제를 발견했어요. Dp로 변환하는 과정에 음수값이 들어오면 크래시가 나는 문제가 있어요!
consumePadding = (rootCoordinate.size.height - bottom).toInt().coerceAtLeast(0)
이렇게 수정해서 해결했습니다~!
📚 참고한 자료들
- Android WindowInsets 공식 문서
- Compose Foundation 소스 코드
- WindowInsetsAnimation API 문서
'Android > Compose' 카테고리의 다른 글
[Android Compose] Effect Handlers 딥다이브 (0) | 2025.06.20 |
---|---|
[Android Compose] 상태 읽기 지연(Defer State Reads)으로 리컴포지션 최적화하기 (0) | 2025.06.18 |
[Android Compose] Figma의 타원형 Radial Gradient를 구현해보자 (0) | 2025.06.06 |
[Android Compose] Figma 그림자를 쉽게 만들어 보자 (1) | 2025.05.14 |
[Android Compose] TopBar 배경색 전환 애니메이션 구현 방법 (0) | 2025.02.05 |