로그인 화면에 이메일 입력 필드를 하나 넣고 안드로이드에서 먼저 확인했을 때는 아무 문제가 없었습니다. 필드를 탭하면 키보드가 올라오면서 화면 전체가 자연스럽게 밀려 올라갑니다. 그런데 같은 코드를 iOS 시뮬레이터에서 돌리자 키보드가 입력 필드를 그대로 덮어버렸습니다. 포커스는 잡혀 있는데 뭘 치고 있는지 보이지 않는 상태입니다.
증상: imePadding을 넣어도 iOS에서만 그대로
처음 쓴 코드는 평범했습니다.
@Composable
fun LoginForm() {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("이메일") }
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("비밀번호") },
visualTransformation = PasswordVisualTransformation()
)
}
}
imePadding()은 안드로이드에서는 WindowInsets.ime 값을 받아 하단 패딩을 정확히 넣어줍니다. 키보드가 240dp를 차지하면 Column 하단에 240dp 패딩이 붙는 식입니다. iOS에서는 이 값이 그대로 0이었습니다. WindowInsets.ime.getBottom(density)를 로그로 찍어봐도 키보드가 떠 있는 동안 계속 0을 반환했습니다.
원인: 네이티브 키보드 알림과 Compose insets가 따로 돈다
안드로이드는 시스템이 WindowInsetsCompat을 통해 IME insets를 플랫폼 레벨에서 보고해주고, Compose가 그 값을 그대로 받아 씁니다. 반면 iOS에서 Compose Multiplatform이 그리는 화면은 UIViewController 하나 위에 올라간 캔버스에 가깝고, 키보드가 올라오고 내려가는 건 UIKit이 UIResponder 체인으로 처리하는 별개의 이벤트입니다. Compose 쪽 WindowInsets.ime가 이 이벤트를 구독해서 insets 값으로 환산해주는 경로가, 적어도 지금 쓰는 버전에서는 비어 있는 상태였습니다.
커뮤니티에서 같은 증상을 겪은 사례를 찾아보니 다들 비슷하게 imePadding() 단독으로는 안 되고, 키보드 높이를 직접 관찰해서 수동으로 패딩을 주는 쪽으로 우회하고 있었습니다. 결국 "Compose가 몰라서 못 그려주는 값"이라면, UIKit이 보내는 키보드 알림을 직접 받아서 Compose 쪽에 전달해주면 됩니다.
해결: NSNotificationCenter로 키보드 높이를 직접 흘려보내기
expect/actual로 플랫폼별 키보드 높이 옵저버를 만들었습니다. 안드로이드는 imePadding()이 이미 제대로 동작하므로 빈 구현으로 두고, iOS 쪽에만 실제 로직을 넣었습니다.
// commonMain
expect class KeyboardHeightObserver() {
val heightPx: StateFlow<Float>
fun start()
fun stop()
}
// androidMain — 이미 imePadding()이 동작하므로 흘려보낼 값이 필요 없음
actual class KeyboardHeightObserver actual constructor() {
actual val heightPx = MutableStateFlow(0f)
actual fun start() {}
actual fun stop() {}
}
// iosMain
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSValue
import platform.UIKit.UIKeyboardFrameEndUserInfoKey
import platform.UIKit.UIKeyboardWillHideNotification
import platform.UIKit.UIKeyboardWillShowNotification
import platform.UIKit.UIScreen
actual class KeyboardHeightObserver actual constructor() {
actual val heightPx = MutableStateFlow(0f)
private var showObserver: Any? = null
private var hideObserver: Any? = null
actual fun start() {
val center = NSNotificationCenter.defaultCenter
showObserver = center.addObserverForName(
name = UIKeyboardWillShowNotification,
`object` = null,
queue = null
) { notification ->
val info = notification?.userInfo ?: return@addObserverForName
val frameValue = info[UIKeyboardFrameEndUserInfoKey] as? NSValue
val heightPoint = frameValue?.CGRectValue()?.useContents { size.height } ?: 0.0
val scale = UIScreen.mainScreen.scale
heightPx.value = (heightPoint * scale).toFloat()
}
hideObserver = center.addObserverForName(
name = UIKeyboardWillHideNotification,
`object` = null,
queue = null
) { heightPx.value = 0f }
}
actual fun stop() {
val center = NSNotificationCenter.defaultCenter
showObserver?.let { center.removeObserver(it) }
hideObserver?.let { center.removeObserver(it) }
}
}
UIKeyboardFrameEndUserInfoKey로 받는 값은 포인트 단위라서 UIScreen.mainScreen.scale을 곱해 픽셀로 바꿔야 Compose의 toDp()와 단위가 맞습니다. 이 값을 commonMain에서 구독해서 직접 패딩을 계산합니다.
@Composable
fun LoginForm() {
val observer = remember { KeyboardHeightObserver() }
DisposableEffect(Unit) {
observer.start()
onDispose { observer.stop() }
}
val heightPx by observer.heightPx.collectAsState()
val density = LocalDensity.current
val bottomPadding by animateDpAsState(with(density) { heightPx.toDp() })
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomPadding)
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
// 입력 필드는 그대로
}
}
안드로이드는 heightPx가 항상 0이라 기존 동작과 차이가 없고, iOS는 키보드가 올라오는 순간 UIKeyboardWillShowNotification이 날아와 실제 높이만큼 패딩이 생깁니다. animateDpAsState를 끼워 넣은 건 값이 0에서 바로 점프하면 화면이 뚝뚝 끊기는 느낌이 나서, 키보드 애니메이션 시간(iOS 기본값 기준 0.25초 근처)과 비슷하게 맞춰준 겁니다.
남은 문제
이 방식은 키보드가 화면과 별개로 떠 있는 일반적인 레이아웃에서는 잘 맞지만, 화면 회전이나 iPad의 플로팅 키보드처럼 UIKeyboardFrameEndUserInfoKey가 화면 전체 기준 좌표를 주는 경우는 따로 처리해야 합니다. 지금 다루는 화면들은 세로 고정이라 당장은 넘어갔지만, 가로 모드를 지원하게 되면 이 부분은 다시 봐야 합니다. Compose Multiplatform 쪽에서 WindowInsets.ime가 iOS 네이티브 이벤트를 직접 받아 처리하게 되면 이 옵저버는 통째로 지울 수 있는 코드라, 릴리즈 노트에서 관련 변경이 나오는지는 계속 체크하고 있습니다.