[Android Compose] ImePadding 이중 패딩 문제 해결 방법 (+키보드 영역 조정)

2025. 2. 5. 12:42·Android/Compose

 

안녕하세요! 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의 소스 코드를 분석해봤는데요, 주로 살펴본 파일들은 다음과 같아요

  1. androidx.compose.foundation.layout.WindowInsets.android.kt
  2. 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)
        }
    }
}

여기서 알 수 있는 중요한 점은:

  1. Compose는 시스템의 WindowInsets 값을 정확하게 전달받고 있어요.
  2. WindowInsetsHolder는 IME의 상태와 크기 정보를 실시간으로 추적하고 있어요.

그런데 왜 이중 패딩이 생기는 걸까요?

문제는 두 가지 패딩 처리가 독립적으로 일어난다는 점이에요

  1. 시스템이 처리하는 부분
private class InsetsListener(
    val composeInsets: WindowInsetsHolder,
) : WindowInsetsAnimationCompat.Callback(
    if (composeInsets.consumes)
        DISPATCH_MODE_STOP
    else
        DISPATCH_MODE_CONTINUE_ON_SUBTREE
)

InsetsListener는 시스템으로부터 전달된 WindowInsets 애니메이션 이벤트를 Compose로 연결하고 적용하기 위한 중간 처리자 역할을 해요. 시스템이 직접 패딩을 적용하는 게 아니라, Compose 내부에서 이 정보를 활용해 레이아웃을 조정하는 방식이에요.

 

  1. 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())
    ) {
        // 콘텐츠
    }
}

 

잘 적용된 모습!

📝 정리!

  1. 문제의 핵심
    • IME 패딩이 시스템과 Compose에서 각각 독립적으로 처리되면서 이중으로 적용되고 있었어요
    • 이건 adjustResize 설정과는 무관한 현상이었어요
  2. 해결 방법
    • 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
'Android/Compose' 카테고리의 다른 글
  • [Android Compose] 상태 읽기 지연(Defer State Reads)으로 리컴포지션 최적화하기
  • [Android Compose] Figma의 타원형 Radial Gradient를 구현해보자
  • [Android Compose] Figma 그림자를 쉽게 만들어 보자
  • [Android Compose] TopBar 배경색 전환 애니메이션 구현 방법
한민돌
한민돌
Android 개발자가 되기까지.
  • 한민돌
    미래 가젯 연구소
    한민돌
  • 전체
    오늘
    어제
    • 분류 전체보기 (13) N
      • Android (1) N
        • Compose (8) N
        • Jetpack (2)
      • Kotlin (2)
        • Kotlin In Action (0)
      • 외부 활동 (0)
        • SOPT (0)
        • SOPT makers (0)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    onfailure
    coroutine
    imagedecoder
    jetpack
    Google Recommend Architecture
    lazyverticalgrid
    rememberupdatedstate
    runcatching
    imagecompress
    Multi-module
    producestate
    Android
    custom plugin
    build-logic
    effecthandler
    derivedstateof
    compose
    sideeffect
    coroutune
    LaunchedEffect
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
한민돌
[Android Compose] ImePadding 이중 패딩 문제 해결 방법 (+키보드 영역 조정)
상단으로

티스토리툴바