软键盘弹出时输入框顺滑移动

360 阅读5分钟

一、背景

最近在做体验优化 发现在IM发消息调起键盘时顶上输入框的动画比较生硬,会有几帧录出底部的页面,类似某红薯这样(动画放慢了10倍),输入框比键盘弹出速度快,就会露出一两帧底部的背景

xhs键盘.gif

乍一看还能接受,但多切几次会发现会漏出window的颜色,有点闪眼睛(尤其是如果windowBackground设置 transparent 的话会漏出底部的Activity)

而导致这种问题的原因也很简单,就是什么也不做,仅使用系统的 windowSoftInputMode API来声明软键盘弹出时的样式为adjustPan来实现键盘弹出时的样式

二、windowSoftInputMode

windowSoftInputMode属性有六个stateXXX的值和三个adjustXXX的值。 state前缀的值控制软件输入法在Activity成为用户焦点时的可见性,adjust前缀控制软键盘在窗口的显示方式。

说明
stateUnspecified不指定软键盘的状态(隐藏还是可见)。 将由系统选择合适的状态,或依赖主题中的设置。这是对软键盘行为的默认设置。
stateUnchanged当 Activity 转至前台时保留软键盘最后所处的任何状态,无论是可见还是隐藏。
stateHidden当用户选择 Activity 时(StartActivity)隐藏软键盘。
stateAlwaysHidden当 Activity 的主窗口有输入焦点时始终隐藏软键盘。(不论是打开还是关闭Activity)
stateVisible与stateHidden相反,当用户选择 Activity 时(StartActivity)打开软键盘。
stateAlwaysVisible与stateAlwaysHidden相反
adjustResize始终调整 Activity 主窗口的尺寸来为屏幕上的软键盘腾出空间。(给根布局加Padding
adjustPan不调整 Activity 主窗口的尺寸来为软键盘腾出空间, 而是自动平移窗口的内容,使当前焦点永远不被键盘遮盖,让用户始终都能看到其输入的内容
adjustUnspecified系统会根据窗口的内容是否存在任何可滚动其内容的布局视图(RecyclerView/ScrollView)来自动选择上面两个其中一种模式

blog.csdn.net/shanshui911…

三、解决方案

那么如何让动画更流畅呢?网上有很多解决思路是基于监听软键盘弹出的距离自己去滚动实现的,但是看了下效果并不是很理想,又看了下发现google在Android11上对WindowInsets API的更新(不会向低版本兼容)提供了解决的方案

1.关于WindowInsets

juejin.cn/post/703842…

2.实现软键盘平滑动画

可以clone官方的demo,修改MainActiviy的布局为自己的代码,看效果是否符合预期

class RootViewDeferringInsetsCallback(
    val persistentInsetTypes: Int,
    val deferredInsetTypes: Int
) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE),
    OnApplyWindowInsetsListener {
    init {
        require(persistentInsetTypes and deferredInsetTypes == 0) {
            "persistentInsetTypes and deferredInsetTypes can not contain any of " +
                    " same WindowInsetsCompat.Type values"
        }
    }

    private var view: View? = null
    private var lastWindowInsets: WindowInsetsCompat? = null

    private var deferredInsets = false

    override fun onApplyWindowInsets(
        v: View,
        windowInsets: WindowInsetsCompat
    ): WindowInsetsCompat {
        // Store the view and insets for us in onEnd() below
        view = v
        lastWindowInsets = windowInsets

        val types = when {
            // When the deferred flag is enabled, we only use the systemBars() insets
            deferredInsets -> persistentInsetTypes
            // Otherwise we handle the combination of the the systemBars() and ime() insets
            else -> persistentInsetTypes or deferredInsetTypes
        }

        // Finally we apply the resolved insets by setting them as padding
        val typeInsets = windowInsets.getInsets(types)
        v.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, typeInsets.bottom)

        // We return the new WindowInsetsCompat.CONSUMED to stop the insets being dispatched any
        // further into the view hierarchy. This replaces the deprecated
        // WindowInsetsCompat.consumeSystemWindowInsets() and related functions.
        return WindowInsetsCompat.CONSUMED
    }

    override fun onPrepare(animation: WindowInsetsAnimationCompat) {
        if (animation.typeMask and deferredInsetTypes != 0) {
            // We defer the WindowInsetsCompat.Type.ime() insets if the IME is currently not visible.
            // This results in only the WindowInsetsCompat.Type.systemBars() being applied, allowing
            // the scrolling view to remain at it's larger size.
            deferredInsets = true
        }
    }

    override fun onProgress(
        insets: WindowInsetsCompat,
        runningAnims: List<WindowInsetsAnimationCompat>
    ): WindowInsetsCompat {
        // This is a no-op. We don't actually want to handle any WindowInsetsAnimations
        return insets
    }

    override fun onEnd(animation: WindowInsetsAnimationCompat) {
        if (deferredInsets && (animation.typeMask and deferredInsetTypes) != 0) {
            // If we deferred the IME insets and an IME animation has finished, we need to reset
            // the flag
            deferredInsets = false

            // And finally dispatch the deferred insets to the view now.
            // Ideally we would just call view.requestApplyInsets() and let the normal dispatch
            // cycle happen, but this happens too late resulting in a visual flicker.
            // Instead we manually dispatch the most recent WindowInsets to the view.
            if (lastWindowInsets != null && view != null) {
                ViewCompat.dispatchApplyWindowInsets(view!!, lastWindowInsets!!)
            }
        }
    }
}
class TranslateDeferringInsetsAnimationCallback(
    private val view: View,
    val persistentInsetTypes: Int,
    val deferredInsetTypes: Int,
    dispatchMode: Int = DISPATCH_MODE_STOP
) : WindowInsetsAnimationCompat.Callback(dispatchMode) {
    init {
        require(persistentInsetTypes and deferredInsetTypes == 0) {
            "persistentInsetTypes and deferredInsetTypes can not contain any of " +
                    " same WindowInsetsCompat.Type values"
        }
    }

    override fun onProgress(
        insets: WindowInsetsCompat,
        runningAnimations: List<WindowInsetsAnimationCompat>
    ): WindowInsetsCompat {
        // onProgress() is called when any of the running animations progress...

        // First we get the insets which are potentially deferred
        val typesInset = insets.getInsets(deferredInsetTypes)
        // Then we get the persistent inset types which are applied as padding during layout
        val otherInset = insets.getInsets(persistentInsetTypes)

        // Now that we subtract the two insets, to calculate the difference. We also coerce
        // the insets to be >= 0, to make sure we don't use negative insets.
        val diff = Insets.subtract(typesInset, otherInset).let {
            Insets.max(it, Insets.NONE)
        }

        // The resulting `diff` insets contain the values for us to apply as a translation
        // to the view
        view.translationX = (diff.left - diff.right).toFloat()
        view.translationY = (diff.top - diff.bottom).toFloat()

        return insets
    }

    override fun onEnd(animation: WindowInsetsAnimationCompat) {
        // Once the animation has ended, reset the translation values
        view.translationX = 0f
        view.translationY = 0f
    }
}
/**
 * 设置输入法流畅弹出收起的动画
 * @param translateView 需要在界面移动的view,一般就是recyclerView和输入框,其余没设置的view会在最后动画结束的时候自动归位
 */
fun setWindowAnim(
    activity: FragmentActivity,
    rootView: View,
    translateView: List<View>
) {
    WindowCompat.setDecorFitsSystemWindows(activity.window, false)

    val deferringInsetsListener = RootViewDeferringInsetsCallback(
        persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
        deferredInsetTypes = WindowInsetsCompat.Type.ime()
    )
    // RootViewDeferringInsetsCallback is both an WindowInsetsAnimation.Callback and an
    // OnApplyWindowInsetsListener, so needs to be set as so.
    // 动画结束的时候  会再次触发ApplyWindowInsets重置rootView的padding,然后子view的的位移动画置为0就可以完美适配动画
    ViewCompat.setWindowInsetsAnimationCallback(rootView, deferringInsetsListener)
    // 设置这个可以把默认的底部导航栏边距给忽略掉
    ViewCompat.setOnApplyWindowInsetsListener(rootView, deferringInsetsListener)

    for (view in translateView) {
        ViewCompat.setWindowInsetsAnimationCallback(
            view,
            TranslateDeferringInsetsAnimationCallback(
                view = view,
                persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
                deferredInsetTypes = WindowInsetsCompat.Type.ime())
        )
    }
}

最后效果如下:

Screen\_recording\_20241218\_194906.gif

看完官网的方案,其实解决思路就是两个:

  • setOnApplyWindowInsetsListener忽略导航栏,在WindowInsetsAnimationCallback中再次触发ApplyWindowInsets重置rootView的padding,子view的动画结束后需要重置位移为0
  • 只设置setWindowInsetsAnimationCallback,子view正常做位移动画即可

我对官方的实例做了封装,具体代码地址可以参考下

如果不是即时通讯软件,IM模块这样我觉得完全够用了,看了很多产品包括ins也是这种解决方案

至于如何兼容Android11以下的系统,可以参考下面的实现juejin.cn/post/709633…,但也仅限API>21