안드로이드 WindowInsets으로 키보드 애니메이션 구현하기 (2)

#2 — WindowInsets 사용해보기

Ji Sungbin
성빈랜드
11 min readJan 8, 2022

--

1편에서는 WindowInsets의 개념에 대해 알아보았습니다. 이제 직접 키보드 애니메이션을 구현해 보겠습니다. 최종 결과는 아래 영상처럼 나오게 됩니다.

시작하기 전에! IME이란 Input Method Editor를 뜻합니다. 자세한 정보는 여기를 방문해 주세요.

우선 간단한 XML을 먼저 작성해주겠습니다. 전체 코드가 길어 간단하게 표현했습니다. (전체 코드는 여기서 확인하실 수 있습니다)

각 뷰 아이디별 설명은 다음과 같습니다. (중요한 3개만 설명)

  • conversation_recyclerview: 대화 목록이 표시될 리사이클러뷰
  • message_holder: 메시지 입력 레이아웃 컨테이너
  • message_edittext: 메시지가 입력될 에딧텍스트

WindowInsets 사용 준비

이제 WindowInsets를 사용해 보겠습니다. 하지만 Insets는 SDK 29 에서 추가되었습니다. 모든 사람들이 최신 버전 안드로이드를 사용했으면 좋겠지만 현실은 그렇지 않으므로 우리는 AndroidX의 Compat을 통해 SDK 21 부터 가능하게 구현할 것입니다. 또한, WindowInsets 를 사용하기 위해선 fitsSystemWindows 를 false로 설정해야 합니다. 이에 대한 공식문서 설명은 다음과 같습니다.

If set to false, the framework will not fit the content view to the insets and will just pass through the WindowInsetsCompat to the content view.

false로 설정하면 프레임워크가 content view를 insets에 맞추지 않고 WindowInsetsCompat를 통해 content view로 전달합니다.

WindowInsets는 루트 뷰에서 자식 뷰로 계속 전파되는데 중간에 consume을 받게 되면 그 이후로 나오는 자식 뷰들은 WindowInsets 이벤트를 받지 못하게 됩니다. fitsSystemWindows 를 기본값인 true로 두게 되면, 안드로이드 기본값 insets를 적용하면서 consume을 받습니다. 따라서 WindowInsets가 작동하지 않게 됩니다. 앱을 만들고 별도 설정 없이 실행했을때 UI가 시스템 영역(상태바, 네비게이션 바)에 가려지지 않는 이유가 이러한 이유 때문입니다.

따라서, 우선 다음과 같이 fitsSystemWindows를 false로 설정해주어야 합니다.

패딩 적용하기

하지만 위 과정을 적용하면 당연하게 다음 사진처럼 UI가 시스템 영역과 겹치게 됩니다.

이 문제는 루트 뷰에 각각 상태바와 네비게이션 바 높이 만큼 패딩을 더해주어 겹친 영역을 해결해줄 수 있습니다. 또한 우리는 키보드가 화면에 표시됐을 때, 키보드의 높이 만큼도 올려주어야 합니다. 이를 정리해보면 다음과 같습니다.

  • 상태바와 네비게이션 바의 높이 만큼은 루트 뷰에 항상 패딩을 주어야 한다.
  • 키보드가 올라왔을 때, 올라온 높이 만큼 루트 뷰도 같이 올려줘야 한다.

이 두 가지 조건을 한 번에 충족시킬 수 있게 WindowInsets 요소들을 구현하는 클래스를 만들어 보겠습니다. 우리는 재사용성을 위해 한 가지의 InsetsType에만 국한되는게 아닌 모든 InsetsType과 작동될 수 있게 만들겠습니다.

or 비트연산자를 통해 쉽게 구현할 수 있습니다.

OnApplyWindowInsetsListener라는 새로운 인터페이스가 등장했습니다. 이는 뷰에 기본적으로 설정돼 있는 onApplyWindowInsets을 커스텀 하기 위한 인터페이스 입니다. onApplyWindowInsets는 공식문서에서 다음과 같이 설명하고 있습니다.

Called when the view should apply WindowInsets according to its internal policy.
내부의 정책에 따라 뷰가 insets을 적용해야 할 때 호출됨.

위 코드처럼 비교적 짧게 패딩은 설정해줄 수 있습니다. 위 코드를 다음과 같이 ViewCompat으로 루트 뷰에 적용해 줍니다.

여기까지의 결과는 다음과 같습니다.

뷰에 패딩에 적용됐음을 확인하기 위해 메시지 끝에 번호를 임시적으로 추가했습니다.

키보드가 올라가면 키보드 높이만큼 성공적으로 패딩이 적용된걸 확인할 수 있습니다. 하지만 애니메이션이 없어 매우 이상합니다. 따라서 이제 키보드에 따라 뷰가 같이 올라가는 애니메이션을 구현해 보도록 하겠습니다.

애니메이션 구현하기

애니메이션은 아래 코드와 같이 View.translateX 를 활용하여 쉽게 구현할 수 있습니다.

WindowInsetsAnimationCompat.Callback이 나왔고, dispatchMode라는 새로운게 등장했습니다.

WindowInsetsAnimationCompat.Callback에는 대표적으로 다음과 같은 3가지의 메서드가 있습니다.

  • onPrepare: insets 애니메이션이 시작되려고 할 때 그리고 애니메이션이 끝나고 뷰가 배치되기 전에 호출됩니다.
  • onProgress: 애니메이션 실행의 일부로 insets이 변경될 때 호출됩니다.
  • onEnd: insets 애니메이션이 종료되면 호출됩니다.

dispatchMode는 WindowInsetsAnimationCompat.Callback의 dispatch이며 총 2가지가 존재합니다.

  • DISPATCH_MODE_CONTINUE_ON_SUBTREE: 애니메이션 이벤트가 뷰 계층의 하위 트리로 전파되어야 함
  • DISPATCH_MODE_STOP: 이후 디스패치를 중지함. 이 경우 전달된 애니메이션과 관련된 모든 애니메이션 콜백은 계층 구조의 하위 트리로 전파되는 것이 중지됩니다.

이 경우 우리는 디스패치를 넘겨줘야 하는 하위 뷰가 아직까지는 더 없으므로 기본값으로 DISPATCH_MODE_STOP을 사용했습니다. 위 코드를 다음과 같이 ViewCompat으로 적용해 줍니다.

키보드가 올라오면 message_holder와 conversation_recyclerview가 올라가므로 둘 다 적용해 줬습니다. 여기까지의 결과는 다음과 같습니다.

하지만 뭔가 이상합니다. message_holder의 애니메이션이 시작되기 전부터 이미 패딩이 키보드 높이만큼 적용돼 있습니다. 따라서 애니메이션과 패딩이 동시에 적용되면서 애니메이션을 적용하기 전보다 더 못생겨 졌습니다. 이젠 애니메이션을 아름답게 만들어 보겠습니다.

애니메이션 아름답게 만들기

위 문제를 해결하기 위해선 키보드의 높이만큼 패딩을 바로 주는게 아닌, 애니메이션이 끝난 후에 패딩을 넣어줘야 합니다. 애니메이션의 상태를 알기 위해 WindowInsetsAnimationCompat.Callback를 사용해 줍시다.

위에서 만들어줬던 RootViewInsetsCallback을 RootViewDeferringInsetsCallback으로 이름을 바꿔주고, WindowInsetsAnimationCompat.Callback를 추가로 상속받아 줬습니다.

onPrepare과 onEnd를 활용하여 키보드 높이만큼 패딩을 추가해줘야 하는지 결정해줄 수 있습니다. 이 결정값을 담고 있을 boolean형 변수를 만들어 주고, onPrepare에서 true를, onEnd에서 false를 주는 식으로 우선 구현해 줍니다.

이렇게 deferredInsets 변수에 키보드 높이만큼 패딩을 추가해줘야 하는지를 나타내는 boolean을 담아줬습니다. 이제 이 변수를 이용하여 Insets이 적용되는 onApplyWindowInsets 부분에서 처리해 주어야 합니다. 하지만 애니메이션이 끝난 이후에(onEnd가 호출된 이후에) onApplyWindowInsets는 다시 호출되지 않습니다. 따라서 우리는 onEnd 에서 onApplyWindowInsets를 직접 호출해 주어야 합니다.

이는 View.requestApplyInsets()를 통해 해줄 수 있지만 속도가 느려 flicker가 발생할 수 있습니다. 다른 방법인 ViewCompat.dispatchApplyWindowInsets(View view, WindowInsetsCompat insets) 를 통해 수동으로 디스패치를 요청해야 합니다. 그러기 위해서 맨 처음 실행되는 onApplyWindowInsets에서 view와 insets를 onEnd에서 처리를 위해 다른 변수에 저장해주어야 합니다.

또한 onApplyWindowInsets에서 처리할 insets를 deferredInsets 변수에 맞게 설정하는 로직도 같이 구현해야 합니다. 이렇게 모두 구현하게 되면 최종적으로 다음과 같이 클래스가 완성됩니다.

여기까지의 결과는 다음과 같습니다.

드디어 우리가 원하던 아름다운 키보드 애니메이션이 완성되었습니다 🤩 🥳. 하지만 아직 끝이 아닙니다(?!). 아까부터 계속 message_edittext의 포커싱이 거슬립니다. 더 완벽함을 위해 키보드가 내려갔을땐 message_edittext의 포커싱이 자동으로 제거되게 해보겠습니다.

EditText 포커싱 자동 제어하기

키보드가 내려갔을땐 message_edittext의 포커싱이 제거되고, 올라갔을땐 포커싱되게 하려면 무엇을 사용하면 좋을까요? 이번에도 역시 전 부터 쭉 사용했던 WindowInsetsAnimationCompat.Callback를 사용하여 구현할 수 있습니다. 우리는 애니메이션이 끝난 onEnd 부분에서

  • IME가 표시되고 계층 구조에 현재 포커스가 있는 뷰가 없으면 뷰에 포커스를 요청합니다.
  • IME가 표시되지 않고 현재 뷰에 포커스가 맞춰져 있으면 포커스를 지웁니다.

위와 같은 2가지 작업을 해줌으로써 EditText의 포커싱을 자동으로 제어할 수 있습니다. 이를 구현하면 다음과 같습니다.

이 클래스 역시 ViewCompat을 통해 message_edittext에 적용해 줍니다.

하지만! 유감스럽게도 이는 작동하지 않습니다. 아마 디스패치가 전달되지 않는것 같습니다. 지금까지 우리가 적용한 ViewCompat들을 보겠습니다.

12번째 ViewCompat을 보면 message_holder에 접근하고 있고, message_edittext는 message_holder의 자식에 속합니다. 우리는 TranslateDeferringInsetsAnimationCallback의 dispatchMode의 기본값을 DISPATCH_MODE_STOP로 했다는걸 잊으면 안됩니다. 따라서 디스패치가 여기서 끝나고, message_holder의 자식인 message_edittext까지 디스패치가 전달되지 않게 됩니다. 이를 고치기 위해 dispatchMode 인자를 DISPATCH_MODE_CONTINUE_ON_SUBTREE로 지정해주어 디스패치가 message_edittext까지 닿게 함으로써 해결할 수 있습니다.

그 결과는 아래와 같습니다.

여기까지 완벽한 애니메이션을 가진 키보드를 만들어 보았습니다. 이미 글이 충분히 긴데 더 이상 길어지는걸 막기 위해 conversation_recyclerview와 키보드를 동기화 하는 작업은 3편에서 해보도록 하겠습니다. 끝까지 읽어주셔서 감사합니다.

이러한 과정이 너무 길어 저처럼 귀찮으신 분들을 위해 라이브러리로 만들었습니다.

지금까지의 모든 과정을 아래와 같이 Activity 확장함수 한 줄로 구현할 수 있습니다.

현재 최신 버전은 1.0.0 이며 아래 한 줄 추가로 이용할 수 있습니다.

모바일(Android/iOS) 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

1편부터 2편까지 쓰는데 거의 13시간이 걸렸네요! 이 글이 도움이 되셨다면 clap 부탁드립니다 👏👏

안스 갓겜

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.