Animate Your Keyboard 安卓键盘动画实践
Google 给的实践效果
1. 基础知识准备
androidx.core
下集成了对屏幕内影响界面布局元素的监听及查看支持,状态栏、键盘、手势区域等,现在统一叫 Insets。
涉及到键盘的主要有
-
WindowCompat#setDecorFitsSystemWindows
用来设置 DecorView 是否响应 Insets。 -
ViewCompat#setOnApplyWindowInsetsListener
用来设置特定 View 是否响应 Insets,并且支持到 API 21,API 21 以下没有效果,Android SDK 内部的View#setOnApplyWindowInsetsListener
需要至少 API 20,很奇怪。 -
ViewCompat.setWindowInsetsAnimationCallback
用来设置特定 View 对类似键盘这种动画的回调。 -
正常回调顺序
onPrepare
onApplyWindowInsets
onProgress
...
onProgress
onEnd
onApplyWindowInsets
1. 谷歌示例源码分析
Google 界面布局最外层为 LinearLayout,内部主要是 RecyclerView + 作为输入框的容器LinearLayout
1. Window 设置
使用 WindowCompat#setDecorFitsSystemWindows(window,false),设置当前 decorView 不响应 Insets。
2. 最外层 View
给布局最外层 View 也就是 RootView 设置WindowInsetsAnimationCallback
重写onPrepare
判断如果是键盘动画,则设置deferredInsets = true
重写onProgress
但是不做任何操作,直接返回参数 insets
重写onEnd
,如果deferredInsets == true
则重写设置deferredInsets = false
,然后调用dispatchApplyWindowInsets
向下传递一次 insets。
同时设置OnApplyWindowInsetsListener
,给自身设置 Padding,然后返回 WindowInsetsCompat.CONSUME不继续向下分发。
这里记录下这次传入的WindowInsetsCompat
赋值给lastWindowInsets
同时设置 Padding 时会判断deferredInsets
,这个逻辑特别重要如果是false,会将键盘与导航栏的高度取最高值作为 padding,true 则会将导航栏高度作为 padding。
3. 容器 LinearLayout
此为输入框的容器,给它设置WindowInsetsAnimationCallback
,重写onProgress
根据当前键盘位置设置自己的translationY
,重写onEnd
在键盘动画结束时重置translationY
为0
4. TextInputLayout
给 TextInputLayout 设置WindowInsetsAnimationCallback
,重写onEnd
,让其能在键盘动画结束时根据当前键盘状态执行获取焦点或者清除焦点的操作
5. RecyclerView
这里与第3点中容器 LinearLayout 的逻辑完全一致,因为都是键盘慢慢弹起,它们慢慢上移。
6. 为什么谷歌的代码会产生预期的效果
首先我们打印下生命周期
弹起
onPrepare // 设置标志位 `deferredInsets == true`,我们要开始动画了
onApplyWindowInsets: Insets{left=0, top=66, right=0, bottom=132} // 因为`deferredInsets`为true,我们只使用导航栏高度作为 padding
onProgress // 啥也没干,分发
RecyclerView ->onProgress // 通过设置`translationY`开始上移
LinearLayout ->onProgress // 通过设置`translationY`开始上移
AppCompatEditText ->onProgress // 啥也没干
...
...
...
...
onProgress // 啥也没干,分发
RecyclerView ->onProgress // 通过设置`translationY`开始上移
LinearLayout ->onProgress // 通过设置`translationY`开始上移
AppCompatEditText ->onProgress // 啥也没干
onEnd // 设置`deferredInsets = false`,再次调用`onApplyWindowInsets()`
onApplyWindowInsets: Insets{left=0, top=66, right=0, bottom=912} // 将键盘高度作为 padding
RecyclerView ->onEnd // 重置`translationY`
LinearLayout ->onEnd // 重置`translationY`
AppCompatEditText ->onEnd // 键盘可见,requestFocus()
收起
onPrepare // 设置标志位 `deferredInsets == true`,我们要开始动画了
onApplyWindowInsets: Insets{left=0, top=66, right=0, bottom=132} // 因为`deferredInsets`为true,我们只使用导航栏高度作为 padding,
onProgress // 啥也没干,分发
RecyclerView ->onProgress // 通过设置`translationY`开始下移
LinearLayout ->onProgress // 通过设置`translationY`开始下移
AppCompatEditText ->onProgress // 啥也没干
...
...
...
...
onProgress // 啥也没干,分发
RecyclerView ->onProgress // 通过设置`translationY`开始下移
LinearLayout ->onProgress // 通过设置`translationY`开始下移
AppCompatEditText ->onProgress // 啥也没干
onEnd // 设置`deferredInsets = false`,再次调用`onApplyWindowInsets()`
onApplyWindowInsets: Insets{left=0, top=66, right=0, bottom=132} // 使用导航栏高度作为 padding
RecyclerView ->onEnd // 重置`translationY`
LinearLayout ->onEnd // 重置`translationY`
AppCompatEditText ->onEnd // 键盘不可见,clearFocus()
这里我们分阶段分析
- 调用显示键盘的代码
观察到有时候
onPrepare()
前也会回调一次onApplyWindowInsets()
但是键盘高度还没变,所以不影响
但肯定的是onPrepare()
后一定会回调一次onApplyWindowInsets()
但是这时候标志位是true
所以键盘弹起的时候,无论如何,布局不会发生任何变化。 - 键盘慢慢弹起
随着键盘慢慢弹起,所有 View 会开始回调受
onProgress()
方法影响,并且是有固定顺序的
界面会发生 RecyclerView 和 容器 LinearLayout 慢慢上移的变化 - 键盘展示完成
WindowInsetsAnimationCallback
会回调onEnd()
方法,然后主动调用 rootView 的onWindowInsetsChanged()
translationY清零,EditText根据当前状态决定是获取焦点还是清楚焦点 - 键盘高度突然改变
这里需要考虑到的一点是,键盘由于语言变化或者本身能调整高度,是会在已经弹起的时候发生改变的
由于onEnd标志位已经重新设置为false,所以这里也是能够处理的
7. 谷歌动画的问题点
在
弹起
结束时,rootView 的onEnd()
会将 Padding 更新为键盘高度了,然后传递给下一个 View 回调 onEnd
在这个时间段内其实其他View并未及时将 translationY 清零,所以需要这里的时间足够短
否则执行位移动画的 View 会有一瞬间在很上面然后再归位。
同理,在
收起
开始时,rootView 的onApplyWindowInsets()
会将 Padding 更新为导航栏高度了,然后才会回调onProgress()
在这个时间段内其实其他View translationY 依然为0,所以这里的时间也需要足够短
否则执行位移动画的 View 会有一瞬间在很下面然后再归位。
2. 处理我们自己动画的需求
需求还是比较简单的,除了 EditText 其他完全不用管,键盘弹起,显示输入框并获取焦点,键盘隐藏,隐藏输入框并清除焦点
由于需要改变其位置,同时需要响应动画,很明显ViewCompat#setOnApplyWindowInsetsListener
和ViewCompat.setWindowInsetsAnimationCallback
是都需要的实现过程中发现如果直接通过 Insets 判断键盘状态来显示或者隐藏输入框,会影响其他焦点的逻辑(比如页面上有第二个输入框)
效果(显示隐藏的画面不小心被裁掉了)
键盘动画
源码
TranslateInsetsAnimationListener.java
优化
以上是最终代码,实现过程中有很多由于机型、安卓版本等问题导致的特殊情况。
现在流程为点击按钮,显示 EditText,弹出键盘
动画中只对 EditText focus 进行操作
然后在UI层对 focus 进行监听,失去焦点则隐藏 EditText
-
主动调起键盘代码存在兼容性问题,多次测试发现不同代码适用不同版本安卓
最后使用了版本判断来解决
public static void showKeyboardCompat(@NonNull View view){ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { InputMethodManager systemService = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); systemService.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); }else{ showKeyboard(view); } } public static void showKeyboard(@NonNull View view) { int type = WindowInsetsCompat.Type.ime(); WindowInsetsControllerCompat controller = ViewCompat.getWindowInsetsController(view); if (controller != null) { controller.show(type); } view.requestFocus(); }
-
requestFocus()
或者setVisibility()
写在onApplyWindowInsets()
中导致奇奇怪怪的BUG测试过程中出现过UI不更新、动画卡顿的问题,具体还是要依靠自己反复调试
-
动画结束的
checkFocus()
应该加上当前是否有焦点的判断见代码注释
-
Insets 拦截、传递在各版本上的异同
在布局中与 EditText 同级的 View 也设置
setOnApplyWindowInsetsListener
测试发现,如果onApplyWindowInsets
返回 WindowInsetsCompat.CONSUME
Android 7.1.1 Smartisan Nut Pro OD105 动画不执行
Android 8.1.0 Smartisan Nut R1 DE106 动画不执行
Android 12 Google Pixel3 动画正常
Android 12 API 31 Pixel 3 模拟器 动画正常直接返回 insets,则各机器都正常响应
-