본문 바로가기
안드로이드 코틀린

[Kotlin][Android] 키보드 열고 닫을 때 view를 부드럽게 이동시키기

by teamnova 2022. 12. 3.

안녕하세요. 

오늘은 키보드가 나타나거나 사라질 때 view를 부드럽게 이동시키는 방법을 알아보겠습니다.

 

애니메이션은 API 레벨에 따라 다르게 작동합니다. 

  • API 30 이상 : 키보드 움직임과 동기화되어 뷰가 움직입니다.
  • API 21 ~ API 29  : 뷰가 키보드와 함께 이동하지만 약간의 지연이 있습니다.
  • API 20 이하 : 뷰가 키보드 위의 위치로 이동하고 애니메이션이 없습니다.  

총 2단계에 걸쳐 알아보겠습니다. 

  1. 키보드를 따라 뷰를 이동시키기
  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!!)
            }
        }
    }

}

 

키보드를 열면 다음과 같은 순서로 실행됩니다. 

  1. 텍스트 필드를 클릭하고 키보드가 표시되기 전에 onPrepare 메서드를 호출합니다. 여기에서 키보드가 보이는지 안 보이는지 확인합니다. 이 경우에는 differedInsets 플래그를 true로 설정됩니다. 
  2. onApplyWindowInsets메서드가 호출되고 systemBars()애니메이션이 끝날 때까지 삽입을 설정합니다.
  3. 키보드가 화면에 완전히 나타난 후 onEnd메소드가 호출되고 플래그를 다시 설정하고 false뷰에 삽입을 수동으로 전달합니다.
  4. 마지막으로 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 

 

GitHub - johncodeos-blog/MoveViewWithKeyboardAndroidExample: Move View with Keyboard in Android using Kotlin

Move View with Keyboard in Android using Kotlin. Contribute to johncodeos-blog/MoveViewWithKeyboardAndroidExample development by creating an account on GitHub.

github.com

입니다.