这可能是Android软键盘监听的最佳方案

2,678 阅读5分钟

先上效果图:

skb-global.gif

图穷匕见: 该文字整理自我的博客文字《可能是软键盘状态与高度监听最佳方案》《# 软键盘高度监测最佳实践》 源码参考 - skb-global

简单做一个补充,@GeekTR 同学提到了一个方式,就是利用WindowInsets API获取键盘高度。在经过简单实验以后,在Android 10及以上的系统版本中,确实是可以的,需要注意的是,键盘弹起过程中,会多次调用onApplyWindowInsets,这就必须通过“防抖”来解决,这就会遇到滞后性、误触性的问题;在低于Android 10的版本中,键盘弹起并不会触发onApplyWindowInsets方法,并且没有WindowInsets.Type.ime()这样专指键盘高度的insets,如果通过insets.systemWindowInsetBottom又容易受到底部导航栏变化的影响。所以,不再做WindowInsets的方案了,有兴趣探索的,可以查看feat/insets分支下的代码。

背景

监听Android的软键盘状态与高度总是很麻烦,因为官方并没有给一个api来做这个事,我们就只能利用系统的其他机制来实现。

先说一下之前尝试的旧方案:

在布局中添加一个空白的FrameLayout,通过这个FrameLayout的onSizeChanged或者addOnLayoutChangeListener ,来获取软键盘弹出前后的高度值差值来计算键盘的高度,由于尺寸变更的回调,可能在整个过程中,会多次触发,就需要使用一个延迟任务去测量布局的高度,即需要做防抖,如果在任务等待期间,又一次触发了尺寸变化的回调,说明布局还未稳定,则取消掉上一次测量任务,添加一个新的测量任务。 但是这种方案有如下问题:

  1. 侵入性,必须在布局中显式的放置测量布局;
  2. 滞后性,测量时机上延迟的,并不能在布局稳定的第一时间获取到键盘的高度;
  3. 误触性,由于获取键盘高度靠延迟任务,延迟的时间太长,则导致滞后性太严重,太短,则可能来不及取消上一个任务,测量高度就被错误触发;
  4. 不定性,由于设备可能会有横竖屏幕切换,因横竖屏切换导致的测量布局尺寸发生变化进而触发键盘高度事件,这个是不可接受的。

为了解决这些问题,我改成现在的监听方案 —— skb-global

一、安装

该库托管于jitpack,所以在使用前,请先引入jitpack

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}

然后添加依赖。

dependencies {
    implementation 'com.github.boybeak:skb-global:Tag'
}

最新版本为:version

二、使用

有两种使用方式:全局局部

2.1 全局使用

在使用前,需要先在Application中初始化SoftKeyboardGlobal

class App : Application() {

    override fun onCreate() {
        super.onCreate()
        SoftKeyboardGlobal.install(this, true)
    }
}

其中,第二个参数传入true,可以在UI界面显示一个高度指示器。

然后,在你应用中的任意位置,可以监听键盘的状态与高度,如下方式:

SoftKeyboardGlobal.addSoftKeyboardCallback(object : SoftKeyboardGlobal.SoftKeyboardCallback {
    override fun onOpen(height: Int) {
        Log.d(TAG, "onOpen height=$height")
    }

    override fun onClose() {
        Log.d(TAG, "onClose")
    }

    override fun onHeightChanged(height: Int) {
        Log.d(TAG, "onHeightChanged height=$height")
    }
})

2.2 局部使用

你可以在任意Activity, Fragment或者View中使用这种方式,只要能获取到Activity实例。以Activity为例,如下方式:

class MainActivity : AppCompatActivity() {

    private val observer by lazy { KeyboardObserver.create(this, true) }
    private val switchBtn: SwitchCompat by lazy { findViewById(R.id.switchBtn) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        switchBtn.setOnCheckedChangeListener { buttonView, isChecked ->
            if (isChecked) {
                observer.watch()
            } else {
                observer.unwatch()
            }
        }
    }
}

同样的,在创建KeyboardObserver时,第二个参数传入true可以显示一个键盘高度指示器,方便调试。

三、原理分析

该方案是在多年开发过程中,实践出来的最佳的方案。正如在摘要中说的,旧方案有四点问题,为了解决这些问题,我决定完全抛弃旧方案。

旧方案之所以出现这些问题,就是因为单一布局测量,1. 无法知道布局稳定的准确时机; 2. 键盘弹出时,弹出前的高度,可能因为横竖屏幕切换,导致变得不可靠。

进而我改成双布局测量,但是双布局都放置在原有布局中,同样避免不了因键盘弹出,同时遭到尺寸修改。

再进而,我改为不受键盘弹出影响的PopupWindow。用两个PopupWindow,一个用于测量当前屏幕的高度(实际上并不是屏幕高度,而是键盘底部的位置),称为Ruler——尺子,Ruler并不会随着键盘的弹出而改变尺寸;相反的,另外一个会跟随软键盘的弹出而改变尺寸,称为Cursor——游标。

两个PopupWindow的创建代码如下:

private fun makeRulerPopWin(activity: Activity) = PopupWindow(activity).apply {
    contentView = if (showDebug) {
        TextView(activity).apply {
            background = GradientDrawable().apply {
                this.setStroke(1.dp, Color.LTGRAY)
            }
            gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
            setTextColor(Color.RED)
        }
    } else {
        View(activity)
    }
    setBackgroundDrawable(null)
    width = if (showDebug) 80.dp else 1     // if set to 0, getGlobalVisibleRect will not work
    height = WindowManager.LayoutParams.MATCH_PARENT
    elevation = 0F

    isFocusable = false
    isTouchable = false
    isOutsideTouchable = false
}
private fun makeCursorPopWin(activity: Activity) = PopupWindow(activity).apply {
    contentView = if (showDebug) {
        FrameLayout(activity).apply {
            addView(
                View(activity).apply {
                    background = ColorDrawable(Color.RED)
                },
                FrameLayout.LayoutParams(
                    FrameLayout.LayoutParams.MATCH_PARENT,
                    1.dp,
                    Gravity.BOTTOM
                )
            )
        }
    } else {
        View(activity)
    }
    setBackgroundDrawable(null)

    width = if (showDebug) 80.dp else 1     // if set to 0, getGlobalVisibleRect will not work
    height = WindowManager.LayoutParams.MATCH_PARENT
    elevation = 0F

    softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
    inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED

    isFocusable = false
    isTouchable = false
    isOutsideTouchable = false
}

二者的关键区别就在于makeCursorPopWin时的这两行代码:

softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED

正是这两行代码,让Cursor会跟随键盘弹出而改变尺寸,尺寸改变后,再显示Ruler,必须是这样的时机,不然Ruler在某些厂商的系统中,也会跟随键盘尺寸发生变化,就失去了比较意义。等待Ruler显示后,会触发onLayoutChange,且只触发这一次,则只需要在这一次回调中,去检测二者的高度差值,即为键盘高度值。

这样做,可以避免侵入。

  1. 低侵入性,只需要调用watch与unwatch即可;
  2. 无滞后性,由于不采用延迟任务的方式,所以没有滞后性;
  3. 无误触性,同样是因为没有采用延迟任务的方式,所以没有误触性;
  4. 稳定性,由于采用的是双布局的差值比较,所以不会因为横竖屏幕切换导致的触发键盘高度事件;

想要更多细节,请查看代码:skb-global