안녕하세요.
오늘은 키보드가 나타나거나 사라질 때 view를 부드럽게 이동시키는 방법을 알아보겠습니다.
애니메이션은 API 레벨에 따라 다르게 작동합니다.
- API 30 이상 : 키보드 움직임과 동기화되어 뷰가 움직입니다.
- API 21 ~ API 29 : 뷰가 키보드와 함께 이동하지만 약간의 지연이 있습니다.
- API 20 이하 : 뷰가 키보드 위의 위치로 이동하고 애니메이션이 없습니다.
총 2단계에 걸쳐 알아보겠습니다.
- 키보드를 따라 뷰를 이동시키기
- 뷰의 이동을 더 부드럽게 하기
1. 키보드를 따라 뷰를 이동시키기
키보드의 상태에 따라 뷰를 이동시키는 방법은 두가지가 있습니다.
첫번째는, AndroidManifest.xml 내부에 adjustPan 플래그를 설정하는 것입니다.
<!-- ... -->
<activity
android:name=".SomeActivity"
android:windowSoftInputMode="adjustPan"
android:exported="true" />
<!-- ... -->
두번째는, 프로그래밍 방식으로 설정하는 것인데, 필요하지 않을 때 비활성화할 수 있기 때문에 권장됩니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ...
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
// ...
}
}
하지만 이 방법은 API 30부터 더이상 사용되지 않기 때문에 WindowInsets를 대신 사용해야합니다.
위와 같은 방법 대신, 코드의 재사용성을 향상시키고 한 곳에서 관리 하기 위해 다른 클래스에 interface listeners를 만들어 보겠습니다.
1. InsetsWithKeyboardCallback이라는 이름의 클래스 만들기
2. OnApplyWindowInsetsListener 라는 인터페이스를 상속시킵니다.
class InsetsWithKeyboardCallback : OnApplyWindowInsetsListener {
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
// Add code later ...
}
}
3. 이제 이 onApplyWindowInsets방법에서 시스템 막대 insets 및 키보드(IME) insets를 가져오고 아래쪽 패딩에 대한 insets을 결합하므로 키보드를 열거나 닫을 때 보기가 위/아래로 이동합니다.
class InsetsWithKeyboardCallback : OnApplyWindowInsetsListener {
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
// System Bars' Insets
val systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
// System Bars' and Keyboard's insets combined
val systemBarsIMEInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.ime())
// 시스템 표시줄과 키보드의 결합된 하단 삽입을 사용하여 키보드에 의해 가려지지 않도록 뷰를 이동합니다.
v.setPadding(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, systemBarsIMEInsets.bottom)
return WindowInsetsCompat.CONSUMED
}
}
4. 또한 클래스를 초기화할 때 fitsSystemWindows를 false로 설정 하고 insets이 작업을 수행하도록 해야 합니다.
그리고 Activity의 Window 에 대한 액세스 권한이 필요하기 때문에 이를 클래스 매개변수로 추가합니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener {
init {
WindowCompat.setDecorFitsSystemWindows(window, false)
}
// onApplyWindowInsets method code ....
}
5. 마지막으로 Android API 레벨 29 이하를 실행하는 기기를 지원하기 위해 앞에서 언급한 플래그를 추가합니다.
init {
WindowCompat.setDecorFitsSystemWindows(window, false)
// For better support for devices API 29 and lower
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
6. 이제 리스너를 설정해줍니다.
MainActivity 에서 뷰의 root에 InsetsWithKeyboardCallback 리스너를 설정합니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val root = findViewById<ConstraintLayout>(R.id.content_id)
val insetsWithKeyboardCallback = InsetsWithKeyboardCallback(window)
ViewCompat.setOnApplyWindowInsetsListener(root, insetsWithKeyboardCallback)
}
}
여기까지 하면 API 30 이상에서 애니메이션은 키보드 애니메이션을 완벽하게 추적하지만 API 레벨이 21-29인 경우 시스템의 IME 애니메이션을 모방하는 일부 코드를 작성해야 합니다.
이를 위해 WindowInsetsAnimation.Callback 을 사용할 것입니다.
7. InsetsWithKeyboardCallback 클래스로 돌아가서 after 를 추가하고 아래 코드를 추가해줍니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener, WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
init {
// ...
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
// ...
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
// Add code later
}
}
또한 onPrepare 및 onEnd 메서드를 추가합니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener, WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
init {
// ...
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
// ...
}
//애니메이션이 시작되려고 할 때 호출(지금 상황에선 키보드가 화면에 나타나거나 사라지려고 할 때 )
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
super.onPrepare(animation)
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
// Add code later
}
//애니메이션이 종료되면 호출(지금 상황에선 키보드가 화면에서 완전히 보이거나 없어졌을 때)
override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
}
}
키보드 애니메이션을 모방하려면 systemBars() 삽입(애니메이션 중)과 systemBars() + ime() 삽입(애니메이션이 끝난 후) 사이의 삽입을 변경해야 하며 이 때 플래그를 나누기 위해 deferredInsets이라는 변수를 생성하겠습니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener, WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
private var deferredInsets = false
init {
// ...
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
// ...
}
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
// IME가 보이지 않으면 WindowInsetsCompat.Type.ime() 삽입을 연기합니다.
deferredInsets = true
}
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
// Add code later
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (deferredInsets && (animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
// IME 애니메이션이 완료되고 IME 삽입이 지연되면 플래그를 재설정합니다.
deferredInsets = false
}
}
}
다음으로, 뷰 삽입 변경 사항에 애니메이션 효과를 주기를 원하지 않기 때문에 onProgress메서드에서 삽입을 다시 반환합니다 .
// ...
override fun onProgress(insets: WindowInsetsCompat, runningAnims: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
return insets
}
// ...
그런 다음 onApplyWindowInsets메서드로 돌아가서 이전 코드를 플래그 값에 따라 올바른 삽입을 반환하는 코드로 바꿉니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener, WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
private var deferredInsets = false
init {
// ...
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val types = when {
// 지연 플래그가 활성화되면 systemBars() 삽입만 사용합니다.
deferredInsets -> WindowInsetsCompat.Type.systemBars()
// 지연 플래그가 비활성화되면 systemBars() 및 ime() 삽입의 조합을 사용합니다.
else -> WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.ime()
}
val typeInsets = insets.getInsets(types)
v.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, typeInsets.bottom)
return WindowInsetsCompat.CONSUMED
}
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
// ...
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
// ...
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
// ...
}
}
마지막으로 정상적인 디스패치가 너무 늦게 일어나 시각적 깜박임을 만들기 때문에 수동으로 디스패치해야 합니다.
-> onEnd 메서드에 뷰 와 최신 인셋 을 저장한 다음 전달해야 합니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener, WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
private var deferredInsets = false
private var view: View? = null
private var lastWindowInsets: WindowInsetsCompat? = null
init {
// ...
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
view = v
lastWindowInsets = insets
// ...
return WindowInsetsCompat.CONSUMED
}
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
// ...
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
// ...
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (deferredInsets && (animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
// ...
// 일반 디스패치 주기에서 처리하도록 하면 너무 늦게 발생하여 시각적 깜박임이 발생하므로 삽입을 수동으로 디스패치합니다.
// 그래서 뷰에 가장 최근의 WindowInsets을 보냅니다.
if (lastWindowInsets != null && view != null) {
ViewCompat.dispatchApplyWindowInsets(view!!, lastWindowInsets!!)
}
}
}
}
최종 코드는 아래와 같습니다.
class InsetsWithKeyboardCallback(window: Window) : OnApplyWindowInsetsListener, WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
private var deferredInsets = false
private var view: View? = null
private var lastWindowInsets: WindowInsetsCompat? = null
init {
WindowCompat.setDecorFitsSystemWindows(window, false)
// API 29 이하의 디바이스를 위해
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
view = v
lastWindowInsets = insets
val types = when {
// 지연 플래그가 활성화되면 systemBars() 삽입만 사용합니다.
deferredInsets -> WindowInsetsCompat.Type.systemBars()
// 지연 플래그가 비활성화되면 systemBars() 및 ime() 삽입의 조합을 사용합니다.
else -> WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.ime()
}
val typeInsets = insets.getInsets(types)
v.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, typeInsets.bottom)
return WindowInsetsCompat.CONSUMED
}
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
// IME가 표시되지 않으면 WindowInsetsCompat.Type.ime() 삽입을 연기합니다.
deferredInsets = true
}
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (deferredInsets && (animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
// IME 애니메이션이 완료되고 IME 삽입이 지연되면 플래그를 재설정합니다.
deferredInsets = false
// 일반 디스패치 주기에서 처리하도록 하면 너무 늦게 발생하여 시각적 깜박임이 발생하므로 삽입을 수동으로 디스패치합니다.
// 따라서 뷰에 가장 최근의 WindowInsets을 보냅니다.
if (lastWindowInsets != null && view != null) {
ViewCompat.dispatchApplyWindowInsets(view!!, lastWindowInsets!!)
}
}
}
}
키보드를 열면 다음과 같은 순서로 실행됩니다.
- 텍스트 필드를 클릭하고 키보드가 표시되기 전에 onPrepare 메서드를 호출합니다. 여기에서 키보드가 보이는지 안 보이는지 확인합니다. 이 경우에는 differedInsets 플래그를 true로 설정됩니다.
- onApplyWindowInsets메서드가 호출되고 systemBars()애니메이션이 끝날 때까지 삽입을 설정합니다.
- 키보드가 화면에 완전히 나타난 후 onEnd메소드가 호출되고 플래그를 다시 설정하고 false뷰에 삽입을 수동으로 전달합니다.
- 마지막으로 onApplyWindowInsets 메서드가 다시 호출되고systemBars() 과 ime() 의 insets의 조합으로 다시 설정합니다.
8. 액티비티로 돌아가 리스너를 다시 설정해줍니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val root = findViewById<ConstraintLayout>(R.id.content_id)
val insetsWithKeyboardCallback = InsetsWithKeyboardCallback(window)
ViewCompat.setOnApplyWindowInsetsListener(root, insetsWithKeyboardCallback)
ViewCompat.setWindowInsetsAnimationCallback(root, insetsWithKeyboardCallback)
}
}
2. 뷰의 이동을 더 부드럽게 하기
이제 지금까지 했던 작업을 더 부드럽게 움직이도록 해보겠습니다.
WindowInsetsAnimation.Callback을 다시 사용하여 이 작업을 수행 합니다.
1. InsetsWithKeyboardAnimationCallback이라는 이름의 클래스를 새로 만들어줍니다.
class InsetsWithKeyboardAnimationCallback(private val view: View) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
// Add code later
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
super.onEnd(animation)
}
}
onProgress에서 ime 및 시스템 막대 삽입을 가져오고 차이를 계산해서 결과를 뷰에 넘겨줍니다.
class InsetsWithKeyboardAnimationCallback(private val view: View) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val diff = Insets.subtract(imeInsets, systemInsets).let {
Insets.max(it, Insets.NONE)
}
view.translationX = (diff.left - diff.right).toFloat()
view.translationY = (diff.top - diff.bottom).toFloat()
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
// ...
}
}
그리고 onEnd메서드에서 애니메이션이 끝난 후 뷰의 translation값을 재설정합니다.
class InsetsWithKeyboardAnimationCallback(private val view: View) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
//...
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
// 애니메이션이 완료된 후 translation 값을 재설정합니다.
view.translationX = 0f
view.translationY = 0f
}
}
2. 이제 MainActivity 로 돌아가서 버튼에 대한 리스너를 설정합니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ...
val loginButton = findViewById<Button>(R.id.login_button)
val insetsWithKeyboardAnimationCallback = InsetsWithKeyboardAnimationCallback(loginButton)
ViewCompat.setWindowInsetsAnimationCallback(loginButton, insetsWithKeyboardAnimationCallback)
}
}
참고한 프로젝트는
https://github.com/johncodeos-blog/MoveViewWithKeyboardAndroidExample
입니다.
'안드로이드 코틀린' 카테고리의 다른 글
[Kotlin][Android] Jetpack Compose로 진행중 애니메이션 만들어보기 (0) | 2022.12.18 |
---|---|
[Kotlin] onBackPressed() deprecated , 대체할 메서드는? (0) | 2022.12.12 |
[Kotlin][Android] DataBinding + RecyclerView 함께 사용해보기 (0) | 2022.11.18 |
[Kotlin][Android] Jetpack Compose에서 폰트 추가해서 사용하기 (0) | 2022.11.03 |
[Kotlin][Android] 인앱 업데이트 (0) | 2022.10.19 |