Android开发之键盘交互适配之终极篇

612 阅读5分钟

Android应用中,比较常见的一个功能场景是在键盘上方放置一些View,给用户提供一些编辑相关的能力。又或者页面有些输入框,输入框需要在键盘起落的时候保证输入框完整可见。不管是哪种场景,这些view需要跟随键盘的起落事件而展示、隐藏。但是Android开发过程中键盘交互的事件是比较难“插手”的。本篇文章分享一下笔者在开发过程中自己的一些处理方法。

众所周知,Android开发中页面和键盘交互相关的是windowSoftInputMode。带window的页面都可以设置该属性,如Activity、Dialog、PopupWindow等。windowSoftInputMode的取值有四个:

  1. SOFT_INPUT_ADJUST_UNSPECIFIED
  2. SOFT_INPUT_ADJUST_RESIZE
  3. SOFT_INPUT_ADJUST_PAN
  4. SOFT_INPUT_ADJUST_NOTHING

这几个值的用法网上有很多博客有介绍,这里不再赘述。SOFT_INPUT_ADJUST_UNSPECIFIED这个值不会有人使用吧,这个值会带来不确定性。SOFT_INPUT_ADJUST_RESIZE这个值会挤压页面,同时它已经过时,大部分时候应该都不会再使用。现在大部分应用使用的是SOFT_INPUT_ADJUST_PANSOFT_INPUT_ADJUST_NOTHING。这两种模式各有好处又各带来了一些问题。

  1. SOFT_INPUT_ADJUST_NOTHING

    此模式下,页面在键盘起落的时候不会有任何动作变化,某些业务场景需要使用这种模式保证页面不会有一些奇怪的行文,但是有需要在键盘起落时候做一些其他处理,比如在键盘上方放置View。这时候适配的方案就要区分Android10及以下或以上版本。

    Android10以上版本

    Android11开始,Google提供了setWindowInsertAnimationCallback方法,这个方法搭配SOFT_INPUT_ADJUST_NOTHING模式可以很好的实现将View放置在键盘之上,并且动画非常丝滑,使用方法可以参考Google Android官方文档。

    Android10及以下版本

    setWindowInsertAnimationCallback方法是在(不包含)Android10以后的版本才生效的,尽管提供了ViewCompat,但是在低版本上面还是没用。这样的话低版本的Android设备只能另辟蹊径寻找适配方案了。

    方案一 网上的方案

    网上针对键盘事件的监听是通过在页面上弹出一个不可见的PopupWindow,通过把这个PopupWindow的window的windowSoftInputMode属性设置成SOFT_INPUT_ADJUST_RESIZE,然后监听PopupWindow的rootview的window.decorView.viewTreeObserver.addOnGlobalLayoutListener事件,最后在回调中通过屏幕高度、PopupWindow的rootview的高度等值计算的到一个差值来认为是键盘的高度值。这种方式是比较粗糙的,因为这样的计算需要考虑底部导航栏、顶部状态栏、沉浸式等问题。这个方案效果不是很好。

    方案二 改进的方案

    方案一虽然实现的最终效果可能不太好,不过也是有可取之处。基于方案一,在之前的某个项目笔者改进了一下,的出了一个还不错的方案。还是需要一个不可见的PopupWindow,通过把这个PopupWindow的window的windowSoftInputMode属性设置成SOFT_INPUT_ADJUST_PAN模式,然后给这个PopupWindow的rootview添加setOnApplyWindowInsetsListener监听,注意需要使用ViewCompat,因为是低版本。最后由于低版本的Android设备并不会在键盘起落的每次事件都会调用setOnApplyWindowInsetsListener回调,所以还是需要监听PopupWindow的rootview的window.decorView.viewTreeObserver.addOnGlobalLayoutListener事件,在这个事件里面调用requestApplyInsets方法,确保键盘起落的时候每次都会调用回调(有需要的话可以在回调里面对事件进行去重处理)。

    setOnApplyWindowInsetsListener监听获取的是系统返回来的各种系统窗口的事件数据信息,可以获取键盘的展示状态、状态栏、导航栏的数据等,都是比较准确的,避免像方案一中那样去计算获取一些错误值的问题。

  2. SOFT_INPUT_ADJUST_PAN

    此模式会比SOFT_INPUT_ADJUST_NOTHING更常用。而此模式下的键盘交互适配却更复杂一些。因为此模式下,键盘起落的时候,页面是有可能会跟随平移的。在获取终止的适配数值的时候需要知道页面伴随平移的值。笔者通过断点源码及阅读源码发现页面伴随键盘平移是由ViewRootImpl里面的Scrollor对象来执行的,因此只要拿到这个对象然后再获取该对象的mDeltaY变量即为页面的伴随平移的值。

    ViewRootImpl是系统类,应用开发是无法直接访问的,这时候就必须使用到反射技术了。由于一个window对应一个ViewRootImpl对象,而每一个window的rootview的parent刚好就是该window绑定的ViewRootImpl对象(可以自行去看源码)。因此反射代码:

    private fun getViewRootImpl(view: View): Any? {
        val parent: ViewParent? = view.rootView?.parent
        try {
            val viewRootImplClass = Class.forName("android.view.ViewRootImpl")
            if (viewRootImplClass.isInstance(parent)) {
                return parent
            }
        } catch (e: ClassNotFoundException) {
            Log.e(TAG, "$e")
        }
        return null
    }
    
    

    获取mDeltaY变量:

    
    private fun getRootViewScrollDistance(view: View): Int {
        val viewRootImpl = getViewRootImpl(view)
    
        viewRootImpl?.let {impl->
            try {
                val scrollerField : Field? = impl.javaClass.getDeclaredField("mScroller")
                scrollerField?.isAccessible = true
    
                val scroller = scrollerField?.get(impl)
                scroller?.let {
                    val deltaYField: Field? = scroller.javaClass.getDeclaredField("mDeltaY")
                    deltaYField?.isAccessible = true
                    return (deltaYField?.get(scroller) as? Float)?.toInt() ?: 0
                }
            } catch (e: Exception) {
    
            }
        }
        return 0
    }
    
    

    只要知道了页面伴随平移的值,再通过setOnApplyWindowInsetsListener监听,便可以很好的适配键盘起落事件了(低版本要调用requestApplyInsets方法)确保回调事件正常。

    因为页面伴随键盘起落的时候使用的scrollTo方法(详见ViewRootImpl源码),所以如果适配过程中页面需要二次平移的话,建议还是用scrollTo方法。笔者尝试了看起来是比较顺畅。

    EditText的适配

    如果使用scrollTo方法进行二次平移,页面包含有EditText的话,在EditText输入的时候,页面会自动回滚,这是因为EditText的输入监听里面会触发ViewRootImpl里面的滚动方法。可以重写EditText,然后覆盖其中的requestRectangleOnScreen方法,并返回true。

    以上,几乎纯文字的描述,比较枯燥,整体方案不算难,容易理解。动手试一试即可体验