안드로이드 Compose - Keyboard 열림/닫힘 이벤트 처리를 위한 라이브러리



개인 광고 영역

Android Compose에서 keyboard가 노출되었는지 알 수 있는 방법은 과거 안드로이드 view 높이 변경으로 가능하다.

공식적인 안드로이드 11부터는 아래의 코드로 확인이 가능하지만 minSdk 버전 제약이 있으니 아직은 과거 형태를 공존시키는 게 좋다.

Android 11부터 활용 가능

Virtual keyboard show/hide event - stack overflow

view.setWindowInsetsAnimationCallback(object : WindowInsetsAnimation.Callback {
    override fun onEnd(animation: WindowInsetsAnimation) {
        super.onEnd(animation)
        val showingKeyboard = view.rootWindowInsets.isVisible(WindowInsets.Type.ime())
        // now use the boolean for something
    }
})

Compose에서도 특별히 keyboard 노출 정보를 제공하는 라이브러리가 없어서 stack overflow를 통해 확인하면 대부분 기존 view 높이를 기반한 코드를 활용하고 있다.

그래서 Compose에서 활용 가능한 형태로 맵핑하고 이 이벤트를 간단하게 활용할 수 있는 라이브러리를 배포하였다.

이 글에서 알아볼 내용

  • View 높이 변경을 측정하여 keyboard 이벤트 처리하는 방법
  • CompositionLocalProvider 활용법


Compose keyboard state 확인 라이브러리 디펜던시

keyboard 상태를 확인할 수 있는 라이브러리를 만들었는데 컴포즈 버전에 따라 아래와 같이 활용 가능하다.

버전을 컴포즈 버전과 맞게 해둔건 컴포즈 디펜던시가 매우 강하기 때문에 그냥 매핑해두었다.

버전에 따라 활용하도록 배포 자체도 컴포즈 버전에 따랐다. 특별한일이 없다면 1.4.0 버전만 추가 배포될 것 같다.

Compose 1.3.2를 활용하는 경우

// Compose 1.3.2
// Compose foundation 1.3.1
// Compose Compiler 1.3.2
// Kotlin 1.7.20
implementation("tech.thdev:extensions-compose-keyboard-state:1.3.2")

Compose 1.4.0-alpha03 활용하는 경우

// Compose 1.4.0-alpha031
// Compose Compiler 1.4.0-alpha02
// Kotlin 1.7.21
implementation("tech.thdev:extensions-compose-keyboard-state:1.4.0-alpha03")

당연히 compose 디펜던시가 필수이다.


코드 초기화

코드는 크게 설정 부분과 사용 부분으로 나눠진다. 이를 공통화 시키기 위해 CompositionLocalProvider에 추가하였다.

작업 중인 서비스에서는 공통 Activity에서 이를 미리 처리하고 있지만, 각각을 활용하는 경우라면 아래와 같은 방식으로 활용하면 되겠다.

CompositionLocalProvider(  // 1
     LocalMutableExKeyboardStateSourceOwner provides MutableExKeyboardStateSource() // 2
) {
     Scaffold(
         modifier = Modifier
             .removeFocusWhenKeyboardIsHidden()  // 3
     ) {
     }
}

CompositionLocalProvider에 context들을 등록할 수 있는데 그 대상을 LocalMutableExKeyboardStateSourceOwner을 추가한다.

이때 provides를 통해 초깃값을 설정하는데 MutableExKeyboardStateSource()을 적용하면 되겠다.

그리고 Compose 활용에 최상위 틀인 Scaffold의 modifier에 등록하는 과정을 거치면 keyboard state 이벤트를 받을 수 있다.

등록만 해주면 나머진 알아서 동작하는데 아래 이미지를 참고

sample


코드 활용

등록한 keyboard 이벤트는 이제 위치에 상관없이

val keyboardState by mutableKeyboardStateSource.keyboardState()

DisposableEffect(showKeyboard) {
    // Use keyboard state
    onDispose {
    }
}

의 형태로 활용할 수 있다. 꼭 DisposableEffect을 함께 사용해야 하는 것은 아니고,

val keyboardStateMessage = remember { mutableStateOf("") }
val keyboardShow by LocalMutableExKeyboardStateSourceOwner.current.collectIsKeyboardAsState()
LaunchedEffect(keyboardShow) {
    keyboardStateMessage.value = "Show keyboard".takeIf { keyboardShow } ?: "Hide keyboard"
}

처럼 활용하는 것도 가능하다.


Modifier 확장

이런 코드를 미리 Composable에 맵핑해둬야 할 이유는 특별히 없으니 Modifier를 확장하여 동작하도록 만들었다.

Modifier를 확장하는 방법은 composed {}를 활용할 수 있다.

fun Modifier.removeFocusWhenKeyboardIsHidden(): Modifier = composed {
    this
        // Modifier 추가 등록
}

이는 Google Compose Modifier 내부를 참고하여 맵핑하였다.


View 높이 확인

View 높이 확인하는 방법은 어렵지 않은데 이미 너무 오래된 코드를 그대로 활용하는 것이다.

internal fun View.isKeyboardOpen(): Boolean {
    val rect = Rect()
    getWindowVisibleDisplayFrame(rect)
    val screenHeight = rootView.height
    val keypadHeight = screenHeight - rect.bottom
    return keypadHeight > screenHeight * 0.15
}

매우 흔하게 볼 수 있는 코드를 그대로 활용한다.

이 부분을 Composable로 그대로 감싸 활용하는데, View가 필요하다. Compose View는 LocalView를 통해 접근 가능하다.

@Composable
internal fun rememberKeyboardOpen(): State<ExKeyboardState> {
    val view = LocalView.current

    return produceState(initialValue = view.isKeyboardOpen().toState()) {
        val viewTreeObserver = view.viewTreeObserver
        val listener = ViewTreeObserver.OnGlobalLayoutListener {
            value = view.isKeyboardOpen().toState()
        }
        viewTreeObserver.addOnGlobalLayoutListener(listener)

        awaitDispose {
            viewTreeObserver.removeOnGlobalLayoutListener(listener)
        }
    }
}

View 상태 변화는 Compose Side-Effects 중 하나인 ProduceState를 활용한다.

나머진 View에 OnGlobalLayoutListener를 다는 형태로 특별한 부분은 없다.


화면 터치 시 Keyboard 닫기

Keyboard를 닫히도록 처리하고, 이때 focus도 함께 날려줘야 한다. 컴포즈를 통한 focus 날리는 방식은 여러 개가 있지만 내가 필요한 방법으론 이 코드가 가장 적합했다.

그전에 Compose의 Keyboard 닫히는 이벤트 동작을 정리해 보면

TextField를 터치하면 키보드가 노출되는데

  • back 버튼을 한번 누르면 : 키보드가 닫힌다.
  • back 버튼을 한 번 더 누르면 : focus가 날아간다.

백 2번을 눌러야 focus가 날아간다. 하지만 focus 설정에 따라 또 다른데 이건 개발 테스트에서 확인해 보면 다양하게 나온다.


MutableInteractionSource를 활용한다면

이게 가장 쉬울 것 같아서 접근해 보았는데, MutableInteractionSource는 N 개가 생성되어야 한다. 만약 싱글 형태로 TextField를 관리한다면 모든 이벤트가 정상 동작하지 않는다.

예를 들어 키보드가 열려있고, 포커스를 한 번에 다 날리고 싶다면

MutableInteractionSourceTextField별로 만들어두고, 이를 Scaffold에서 모두 바라보고 있다가 이벤트가 발생하면 emit 해주는 식으로 처리해야 한다.

하나로 통일해 쓴다면 포커스 문제가 발생한다.

사실 관리하기 매우 귀찮아진다. 그래서 그냥 TextField가 생성되는 시점에 하나씩 알아서 만들어지는 걸 그대로 활용하도록 내버려 두었다.


FocusManager

그냥 FocusManager를 활용하는 편이 좋다. FocusManager는 CompositionLocalProvider에 등록되어 있고, 하나만 사용된다.

그리고 TextField에서도 그대로 활용된다.

더 좋은 점은 next focus를 가리켜야 할 때도 LocalFocusManger.current.moveFocus(FocusDirection.Next)만 해주면 다음 TextField로 넘어가 준다.

clear는 LocalFocusManger.current.clear()만 하면 초기화 시켜준다.

왜 동작하냐면 이미 내부 CoreTextField안에서 textFieldFocusModifier를 통해 FocusManager와 연동되어있기 때문이다.

그래서 위에서 언급한 MutableInteractionSource보다는 FocusManager를 활용하는 편이 모두에게 행복할 수 있다.

사실 이 덕분에 focus 처리가 xml보다 더 쉽게 처리된다.


FocusRequester

포커스를 제공하고 싶다면 FocusRequester()를 활용하면 되겠다. 모든 Composable의 Modifier에는 focusRequester를 등록할 수 있다.

안쪽 코드에서 여러 번 활용하지만 이 값은 싱글 형태가 아니기 때문에 필요할 때 활용하면 되겠다.

Modifier에 focusRequester를 등록해두고, 포커스를 이동하고 싶다면 focusRequester.requestFocus()만 해주면 되겠다.


정리하면

특정 TextField에 포커스가 On 상태가 된다. Scaffold를 터치하거나, 키보드 닫힌 이벤트 또는 터치 이벤트를 받으면 clearFocus()를 처리한다.

LaunchedEffect(keyboardState) { // keyboardState 상태 변화시
    mutableKeyboardStateSource.emit(keyboardState)
}

LaunchedEffect(key1 = keyboardState.isOpen(), key2 = isFocused) { // keyboardState 상태 변화와 focus 변화 시
    if (isFocused) {
        if (keyboardState.isOpen()) {
            keyboardAppearedSinceLastFocused = true
        } else if (keyboardAppearedSinceLastFocused) {
            clear()
        }
    }
}

나머지 코드는 CompositionLocalProvider에 등록하기 위한 작업 코드들이다.


전체 코드는


마무리

Compose keyboard 컨트럴은 생각보다 쉽고, 생각보다 복잡하다.

너무 많은 방식을 제공해 주고 있다 보니 상황에 따라 어떤 걸 활용할지 결정해야 한다.

그래서 그냥 일관성 있게 LocalFocusManger를 활용하도록 만들고, 전체적인 이벤트는 ScaffoldModifier등록으로 대체하였다.

그리고 키보드 이벤트가 필요한 경우를 위해 만든 라이브러리이다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments