自己造轮子--IM相关底部输入框处理以及仿微信式软键盘弹出交互

2,323 阅读9分钟

前言

在做IM相关功能的时候,想到过一些奇奇怪怪的底部输入框的处理方式,一开始还挺满意直到特意留意到了微信的这块处理,手中的饼忽然就不香了。接下来会给出之前自己的处理方案以及仿微信交互的实现方案。

方案一:啥都不干版

直接设置ActivitywindowSoftInputModeadjustResizeadjustPan能够满足最基本的使用。但也只是满足最基本的使用,在交互上体验相当不好。

方案二:双列表

这个是自己琢磨出来的一种,其实就是太懒了不愿去处理View的各种动画以及高度测量,所有的行为都直接扔给RecyclerView去处理。

顾名思义看布局就是两个列表,一个列表用来展示IM中的消息,另一个列表用来展示输入框以及扩展输入内容,例如Emoji、扩展菜单等,然后由adjustResize来自动调整消息列表的高度。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/message_pool"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/message_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:overScrollMode="never" />

</LinearLayout>

关于显示输入框的适配器,简单粗暴通过一个变量来控制扩展菜单,菜单类型可以按需求进行扩展。

var extend: String? = null
    set(value) {
        if (field != value) {
            if (value != null && field == null) {
                notifyItemInserted(1)
            } else if (value == null && field != null) {
                notifyItemRemoved(1)
            } else if (value != null && field != null) {
                notifyItemChanged(1)
            }
            field = value
        }
    }

override fun getItemCount(): Int {
    if (extend == null) return 1
    return 2
}
override fun getItemViewType(position: Int): Int {
    if (position == 0) return 0
    return 1
}

在菜单切换事件中有一个优化的点,就是调用隐藏输入法方法后通过postDelayed方法延迟触发切换扩展菜单

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return when (viewType) {
        0 -> InputViewHolder(parent).apply {
            inputView?.apply {
                setOnFocusChangeListener { _, hasFocus ->
                    if (hasFocus) extend = null
                }
                setOnEditorActionListener { v, actionId, _ ->
                    if (actionId == EditorInfo.IME_ACTION_SEND) {
                        onSend?.invoke(v.text.toString())
                        v.text = ""
                        true
                    } else {
                        false
                    }
                }
            }
            extendView?.setOnClickListener {
                clearEditInputStatus()
                it.postDelayed({ extend = if (extend == null) "各种扩展菜单" else null }, 200)
            }
        }
        else -> ExtendViewHolder(parent)
    }
}

然后给消息列表加一个滑动关闭输入法的检测

addOnScrollListenerBy(onScrollStateChanged = { _: RecyclerView, newState: Int ->
                if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
                    (mMessageInput?.findViewHolderForAdapterPosition(0) as? InputAdapter.InputViewHolder)?.apply {
                        clearEditInputStatus()
                    }
                    mInput.extend = null
                }
            })

最终效果如图,可以看出相比方案一至少菜单显示起来方便了,缺点就是交互和View的切换不够平滑。(GIF压制原因可能不太流畅,请以实机效果为准)

示例图 1

关联方案:弹窗式输入框

某些场景(例如发表评论)下底部的输入框不再适用,需要点击某些按钮之后再弹出输入框以及输入法,这时候采用弹窗式输入法就比较好。

关于这个弹窗有一些比较注意的点:

  • 弹窗的根布局要使用RelativeLayout保证能够撑开整个页面,同时为根布局添加该点击事件来保证点击外部时能够关闭弹窗
findViewById<View>(R.id.input_outside)?.setOnClickListener {
    if (it.id != R.id.input_layout) dismiss()
}
  • 搭配相应的主题模式和代码来保证弹窗能够全屏显示
<style name="InputDialog">
    <item name="android:windowFrame">@null</item>
    <item name="android:windowIsFloating">true</item>
    <item name="android:windowIsTranslucent">false</item>
    <item name="android:windowFullscreen">false</item>
    <item name="android:windowNoTitle">true</item>
    <item name="android:background">#00000000</item>
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:backgroundDimEnabled">false</item>
</style>

弹窗创建的时候赋予window的Layout参数

window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)

最后在显示弹窗的时候需要给予相应的宽度

inputDialog.window?.attributes?.apply {
    width = screenWidth
    inputDialog.window?.attributes = this
}
inputDialog.setCancelable(true)
inputDialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
inputDialog.show()
  • 通过OnLayoutChangeListener监听输入法的变化,实现对弹窗的自动关闭

示例图 2

方案三:仿微信式交互

微信的输入法交互是真的不错,流畅的弹出以及回落,特别是在扩展菜单的切换。微信具体是怎么实现的研究半天也研究不明白,不过好在有其他大佬做相关研究,后续内容大致参照FreddyChen/KulaKeyboard以及相关issues,本文会做相关解析。其实现的基本原理是通过一个看不见的popwindow来测量输入法的高度,而activity本身则是adjustNothing的,其弹出交互动画则是围绕测量出的输入法高度进行的。

首先在activity创建的时候为acitivty添加一个看不见的popwindow,并且它的高度是填充满整个activity的。为其注册onLayout监听实现测量输入法高度的事件,并且通过lifecycle绑定生命周期,在activity被销毁的时候关闭popwindow。

init {
    val contentView = View(activity)
    width = 0
    height = ViewGroup.LayoutParams.MATCH_PARENT
    setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
    softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
    inputMethodMode = INPUT_METHOD_NEEDED
    contentView.viewTreeObserver.addOnGlobalLayoutListener(this)
    setContentView(contentView)
    
    activity.window.decorView.post {
        showAtLocation(activity.window.decorView, Gravity.NO_GRAVITY, 0, 0)
    }
    
    activity.lifecycle.addObserver(this)
}

然后在onGlobalLayout中实现输入法高度的测量,关于这部分的处理在FreddyChen/KulaKeyboard issue 5中有其他开发者提出了相关优化方案,并给出了相关优化代码示例,我个人也只能稍作理解与处理,目前也没法测试相关的优化行为是否能正常运作。

关于输入法高度测量结合上述issue我个人认为的一些需要优化的点:

  • 横竖屏不同状态下需要考虑的导航栏的高度。
  • 在调整decorViewsystemUiVisibility导致导航栏高度变动对输入法高度测量的影响,以及对状态栏变动触发的layout事件的过滤。
  • 重复触发的layout事件的过滤。
  • 极少部分存在物理导航栏的手机对输入法高度测量的影响

说实话我认为可能还存在没有考虑到的问题,以及对相关问题的处理方案并不完全正确,所以如有错误欢迎指教,相关实现说明均在注释中。

override fun onGlobalLayout() {
    //这部分是参照 FreddyChen/KulaKeyboard issue 5 中给出的代码编写的,属于半懂不懂的部分
    val min = displayRect.bottom.coerceAtMost(displayRect.right)
    val max = displayRect.bottom.coerceAtLeast(displayRect.right)
    if (max.toDouble() / min.toDouble() >= 1.2) {
        when (activity.requestedOrientation) {
            ActivityInfo.SCREEN_ORIENTATION_PORTRAIT,
            ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT -> {
                if (displayRect.right > displayRect.bottom) return
            }
            ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
            ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE -> {
                if (displayRect.bottom > displayRect.right) return
            }
        }
    }
    
    //过滤重复触发onGlobalLayout的事件
    contentView.getWindowVisibleDisplayFrame(currentDisplayRect)
    if (currentDisplayRect.bottom == lastDisplayRect.bottom) return
    
    //判断导航栏的高度
    //HIDE_NAVIGATION 的时候导航栏是被隐藏的
    //displayRect.height() != screenRect.height() 是对存在物理导航栏的手机的判断
    //这一点能否在所有手机上正常运作还待确定,目前手头上的测试机工作正常
    //以及横屏状态下导航栏在侧边
    val isShowNavigation =
        (0 == (activity.window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)) ||
                displayRect.height() != screenRect.height()
    val navigationBarHeight = when (activity.rotation) {
        Surface.ROTATION_0, Surface.ROTATION_180 -> if (isShowNavigation) context.navigationBarHeight() else 0
        else -> 0
    }
    // 先不管导航栏显示不显示,先减去导航栏的高度
    // 这个高度是最小限度,在输入法没有显示的时候,当前显示矩阵的bottom无论如何都不会小于这个高度
    val excludeNavigation = screenRect.bottom - navigationBarHeight
    // 当 currentHeightDiff >= 0 的时候软键盘可能隐藏,否则软键盘可能处于显示状态
    // 还要综合判断是不是状态栏和导航栏改变导致的
    // 如果存在虚拟导航栏的时候,不显示输入法是一般都是为0,但是是物理导航栏的话,存在导航栏高度,那么这个值会大于0
    val currentHeightDiff = currentDisplayRect.bottom - excludeNavigation
    //前后两次显示矩阵的高度差
    val aroundHeightDiff = currentDisplayRect.bottom - lastDisplayRect.bottom
    if (
        (currentHeightDiff >= 0 && aroundHeightDiff <= mDeviationHeight) ||//两次高度变动属于状态栏或导航栏的变动
        (currentHeightDiff >= 0 && currentDisplayRect.bottom < excludeNavigation) || //当前的高度在最低线内部
        (currentHeightDiff < 0 && currentDisplayRect.bottom >= (screenRect.bottom - mDeviationHeight) && excludeNavigation != 0) //存在一个最低的显示高度
    ) {
        //这些都是状态栏或者导航栏发生变化的事件过滤
        return
    }
    
    keyboardHeight =
        if (currentHeightDiff == 0) screenRect.bottom - lastDisplayRect.bottom - navigationBarHeight
        else screenRect.bottom - currentDisplayRect.bottom - navigationBarHeight
        
    //当界面首次打开的时候由于没有lastDisplayRect,所以键盘高度会变成整个屏幕的高度,其实这个回调是没必要的,所以通过一个flag直接过滤掉
    if (currentHeightDiff >= 0 && !isFirst) {
        isFirst = true
    } else {
        onKeyBoardEvent?.invoke(currentHeightDiff < 0, keyboardHeight)
    }
    lastDisplayRect.set(currentDisplayRect)
}

其中部分相关变量说明

  • displayRect:这里的高度是排除了排除了导航栏的高度,无论导航栏是否显示。
private val displayRect by lazy { Rect(0, 0, activity.displayWidth, activity.displayHeight) }

val Activity.displayWidth: Int
    get() =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            windowManager.currentWindowMetrics.bounds.width()
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getMetrics(this) }.widthPixels

val Activity.displayHeight: Int
    get() =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            windowManager.currentWindowMetrics.bounds.height()
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getMetrics(this) }.heightPixels
  • screenRect:这个高度是整个屏幕的高度。
private val displayRect by lazy { Rect(0, 0, activity.displayWidth, activity.displayHeight) }

val Activity.screenWidth: Int
    get() =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            DisplayMetrics().apply { display?.getRealMetrics(this) }.widthPixels
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getRealMetrics(this) }.widthPixels

val Activity.screenHeight: Int
    get() =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            DisplayMetrics().apply { display?.getRealMetrics(this) }.heightPixels
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getRealMetrics(this) }.heightPixels

在完成输入法高度的测量后,需要针对相应事件来通过动画展示相关内容,详细布局为以下内容。在输入法弹出时需要改变相关View(这里是input_extend_container)的高度,然后配置相关View(这里是input_bottom)的translationY保证动画起始状态时需要展示的View处于不可见状态,然后通过相关动画进行展示操作。与此同时需要按需调整额外相关联View(这里是message_pool)的translationY防止遮挡。

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/message_pool"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#220000ff"
        android:layout_marginBottom="48dp" />

    <LinearLayout
        android:id="@+id/input_bottom"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:orientation="vertical">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="48dp"
            android:layout_gravity="bottom"
            android:background="#ebebeb"
            android:gravity="center_vertical"
            android:paddingHorizontal="8dp">

            <EditText
                android:id="@+id/input_edit"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="@drawable/shape_input_2"
                android:imeOptions="actionSend"
                android:inputType="text"
                android:paddingHorizontal="10dp" />

            <ImageView
                android:id="@+id/input_emoji"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:padding="8dp"
                android:src="@drawable/ic_baseline_emoji_emotions_24" />

            <ImageView
                android:id="@+id/input_extend"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:padding="8dp"
                android:src="@drawable/ic_round_add_circle_outline_24" />
        </LinearLayout>

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/input_extend_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </LinearLayout>

</FrameLayout>

这里直接使用了一个类来辅助动画行为,关于动画的addListener可以通过扩展方法来减少代码量,不过这里还没有这么做。

class TransAnimate(private val referValue: ReferValue) {

    private var lastAnimatorSet: AnimatorSet? = null

    companion object {
        private const val DURATION = 100L
    }

    fun transHeight(targetHeight: Int, companionAnimator: Animator? = null) {
        lastAnimatorSet?.cancel()

        var mainAnimator: ObjectAnimator? = null

        val currentHeight = referValue.currentDisplayHeight

        when {
            targetHeight > currentHeight -> {
                //展示高度增高(展示)
                //先调整目标View的高度以及translationY,然后通过动画进行展示
                referValue.updateTargetHeight(targetHeight)
                referValue.updateTargetTranslationY(targetHeight - currentHeight)
                mainAnimator = ObjectAnimator.ofFloat(
                    referValue.translationYTarget(),
                    "translationY",
                    referValue.referTranslationY(),
                    0f
                ).apply {
                    addListener(object : Animator.AnimatorListener {
                        override fun onAnimationStart(animation: Animator?) { }
                        override fun onAnimationRepeat(animation: Animator?) { }
                        override fun onAnimationEnd(animation: Animator?) {
                            mainAnimator?.removeAllListeners()
                        }
                        override fun onAnimationCancel(animation: Animator?) {
                            mainAnimator?.removeAllListeners()
                        }
                    })
                }

            }
            targetHeight < currentHeight -> {
                //展示高度减少(隐藏)
                //直接进行相关动画展示
                mainAnimator = ObjectAnimator.ofFloat(
                    referValue.translationYTarget(),
                    "translationY",
                    referValue.referTranslationY(),
                    referValue.referHeight() - targetHeight
                ).apply {
                    addListener(object : Animator.AnimatorListener {
                        override fun onAnimationStart(animation: Animator?) { }
                        override fun onAnimationRepeat(animation: Animator?) { }
                        override fun onAnimationEnd(animation: Animator?) {
                            mainAnimator?.removeAllListeners()
                            //在动画展示完毕后清除相关状态以及设定最终高度
                            referValue.updateTargetHeight(targetHeight)
                            referValue.updateTargetTranslationY(0f)
                        }
                        override fun onAnimationCancel(animation: Animator?) {
                            mainAnimator?.removeAllListeners()
                        }
                    })
                }
            }
        }

        if (mainAnimator == null) return

        lastAnimatorSet = AnimatorSet()
            .apply {
                duration = DURATION
                addListener(object : Animator.AnimatorListener {
                    override fun onAnimationStart(animation: Animator?) {
                    }

                    override fun onAnimationEnd(animation: Animator?) {
                        lastAnimatorSet?.removeAllListeners()
                    }

                    override fun onAnimationCancel(animation: Animator?) {
                        lastAnimatorSet?.removeAllListeners()
                    }

                    override fun onAnimationRepeat(animation: Animator?) {
                    }
                })
                //这里主要是处理相关伴随的联动动画
                play(mainAnimator).apply { if (companionAnimator != null) with(companionAnimator) }
                start()
            }
    }
}

最后还是通过一个变量来控制相关动画的触发(关于其中的一些固定高度因为是demo图省力,实际请按需并使用dp2px来计算)

private var mMenu: Menu? = null
    set(value) {
        if (value != field) {
            field = value
            when (value) {
                Menu.Normal -> {
                    //展示normal
                    mInputView?.hideSoftKeyboard()
                    supportFragmentManager.commit {
                        setReorderingAllowed(true)
                        replace(
                            R.id.input_extend_container,
                            fragmentHelper.obtainFragment(0),
                            null
                        )
                        val targetHeight = if (mKeyboardHeight == 0) 500 else mKeyboardHeight
                        transHelper.transHeight(
                            targetHeight,
                            mRecyclerViewTranslationY.transAnimator(targetHeight)
                        )
                    }
                }
                Menu.Emoji -> {
                    //展示emoji
                    mInputView?.hideSoftKeyboard()
                    supportFragmentManager.commit {
                        setReorderingAllowed(true)
                        replace(
                            R.id.input_extend_container,
                            fragmentHelper.obtainFragment(1),
                            null
                        )
                        transHelper.transHeight(
                            1000,
                            mRecyclerViewTranslationY.transAnimator(1000)
                        )
                    }
                }
                Menu.KeyBoard -> {
                    val targetHeight = if (mKeyboardHeight == 0) 500 else mKeyboardHeight
                    transHelper.transHeight(
                        targetHeight,
                        mRecyclerViewTranslationY.transAnimator(targetHeight)
                    )
                }
                null -> {
                    //隐藏菜单
                    transHelper.transHeight(0, mRecyclerViewTranslationY.transAnimator(0))
                    mInputView?.hideSoftKeyboard()
                }
                else -> {
                }
            }
        }
    }

这里需要回顾处理的一个点就是FreddyChen/KulaKeyboard issue 3提到的,原库是在任何时候都会向上移动RecyclerView,所以在数据量较少的时候会造成原有数据遮挡的问题,这个中issue也有其他开发者提出了相关的解决方案。

通过RecyclerView最后一个可见item的底部高度来计算RecyclerView需要上移的距离,同时在插入和删除数据的时候通过adapter的registerAdapterDataObserver来监听调用同样的方法来校准最后高度。这个地方其实存在一个坑,在AdapterDataObserver中onItemRangeRemovedonItemRangeInserted被调用的时候,RecyclerView会执行插入&删除相关动画,这个时候直接去调用findLastVisibleItemPosition是无法得到正确的结果,我个人的解决方法是通过postDelayed来延迟执行相关代码,如果有更好解决方法欢迎告知。

注意init中的代码块,该对象的实例化要放在RecyclerView设置适配器之后,当然这个只是一个图省力直接写的处理方式。

class RecyclerViewTransHelper(
    lifecycleOwner: LifecycleOwner,
    private val recyclerView: RecyclerView
) :
    RecyclerView.AdapterDataObserver(), LifecycleObserver {

    private var lastReferHeight = 0

    init {
        recyclerView.adapter?.registerAdapterDataObserver(this)
        lifecycleOwner.lifecycle.addObserver(this)
    }

    private fun lastBottom(): Int {
        return recyclerView.adapter?.let { adapter ->
            if (adapter.itemCount <= 0) {
                recyclerView.height
            } else {
                (recyclerView.layoutManager as? LinearLayoutManager)?.let { layoutManager ->
                    val view =
                        layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition())
                    recyclerView.height - (view?.bottom ?: 0)
                } ?: throw  IllegalStateException()
            }
        } ?: throw  IllegalStateException()
    }

    fun transAnimator(referHeight: Int): Animator {
        lastReferHeight = referHeight
        return ObjectAnimator.ofFloat(
            recyclerView,
            "translationY",
            recyclerView.translationY,
            (lastBottom() - referHeight).coerceAtMost(0).toFloat()
        )
    }

    override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
        recyclerView.postDelayed({ transAnimator(lastReferHeight).start() }, 200)
    }

    override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
        recyclerView.postDelayed({ transAnimator(lastReferHeight).start() }, 200)
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onLifeDestroy() {
        recyclerView.adapter?.unregisterAdapterDataObserver(this)
    }
}

sample_03

最后

本文相关代码请浏览MitsukiNIBAN/BottomInput