前言
在做IM相关功能的时候,想到过一些奇奇怪怪的底部输入框的处理方式,一开始还挺满意直到特意留意到了微信的这块处理,手中的饼忽然就不香了。接下来会给出之前自己的处理方案以及仿微信交互的实现方案。
方案一:啥都不干版
直接设置Activity
的windowSoftInputMode
,adjustResize
和adjustPan
能够满足最基本的使用。但也只是满足最基本的使用,在交互上体验相当不好。
方案二:双列表
这个是自己琢磨出来的一种,其实就是太懒了不愿去处理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压制原因可能不太流畅,请以实机效果为准)
关联方案:弹窗式输入框
某些场景(例如发表评论)下底部的输入框不再适用,需要点击某些按钮之后再弹出输入框以及输入法,这时候采用弹窗式输入法就比较好。
关于这个弹窗有一些比较注意的点:
- 弹窗的根布局要使用
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
监听输入法的变化,实现对弹窗的自动关闭
方案三:仿微信式交互
微信的输入法交互是真的不错,流畅的弹出以及回落,特别是在扩展菜单的切换。微信具体是怎么实现的研究半天也研究不明白,不过好在有其他大佬做相关研究,后续内容大致参照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我个人认为的一些需要优化的点:
- 横竖屏不同状态下需要考虑的导航栏的高度。
- 在调整
decorView
的systemUiVisibility
导致导航栏高度变动对输入法高度测量的影响,以及对状态栏变动触发的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中onItemRangeRemoved
和onItemRangeInserted
被调用的时候,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)
}
}
最后
本文相关代码请浏览MitsukiNIBAN/BottomInput