前言
上一篇学习了关于全屏显示相关的 Api,我对“edge to edge”特性有了一定的了解。这种“全面屏”的设计,确实让用户能够最大限度地使用整个屏幕,而且状态栏、导航栏适配以后,页面看着舒服多了。
但这功能也没啥稀奇的,人家 iOS 早就支持这种东西了,而且实现起来更简单;Android 开发者却要等到 2024 年才有官方去推动做兼容,之前用的 Api 难理解又难用,都不敢想象过去十年 Android 开发过的都是什么苦日子。
日子虽然苦,但学习不能停。
这一篇学习一下“edge to edge”中给软键盘添加弹出动画的方法。
给软键盘添加弹出动画
其实本文也没什么特别的内容,就是对官方文档和官方 demo 做一下复刻,加深一下对 Api 的了解,期望下次实现 IM 模块键盘弹出效果时能用上。大家想学习的话,可以先去看下官方文档和官方 demo,然后再来看我的文章。
创建项目
打开 Android Studio,点击 File -> New -> New Project -> Empty Activity -> 选择 Minium SDK 为 API 21 ,新建项目,项目名为 InsetsAnimation,新项目的 targetSdk 默认是 34。新生成的代码是 Compose 写的,将其删掉。然后通过 New-> Activity -> Empty Views Activity 创建新的类 InsetsActivity,并将 InsetsActivity 设置为启动页。因为 InsetsActivity 继承自 AppCompatActivity,我又将主题改为了“Theme.AppCompat.Light.NoActionBar”。
这样,我们的项目就创建好了。
实现动画
因为是复刻官方 demo,那我的布局和代码也基本上是抄了官方,然后做了点修改。
先看下布局 activity_insets.xml。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:elevation="8dp"
android:padding="8dp"
android:text="给软键盘添加动画"
android:textColor="@color/black"
android:textSize="18sp"
tools:ignore="HardcodedText" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/conversation_recyclerview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingVertical="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:reverseLayout="true"
tools:itemCount="20"
tools:listitem="@layout/message_bubble_other" />
<LinearLayout
android:id="@+id/message_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:elevation="8dp"
android:padding="8dp">
<EditText
android:id="@+id/message_edittext"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:hint="Type a message…"
tools:ignore="Autofill,HardcodedText,TextFields" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/send_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="16dp"
app:srcCompat="@drawable/ic_send"
tools:ignore="ContentDescription" />
</LinearLayout>
</LinearLayout>
布局也没什么东西,就是顶部一个 AppCompatTextView 充当标题,中间 RecyclerView 模拟聊天会话内容,底部一个输入框,一个发送按钮,发送按钮的图标从官方 demo 里拿过来的。
接下来看下 InsetsActivity 代码。
<activity
android:name=".InsetsActivity"
android:exported="true"
android:theme="@style/Theme.InsetsAnimation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
class InsetsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_insets)
val conversationRecyclerview = findViewById<RecyclerView>(R.id.conversation_recyclerview)
conversationRecyclerview.adapter = ConversationAdapter()
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
}
在 AndroidManifest 里,InsetsActivity 没有设置任何 windowSoftInputMode;在代码里,InsetsActivity 启用了 enableEdgeToEdge(这是必须的),然后给 RecyclerView 添加了 ConversationAdapter,给根布局添加了 padding。ConversationAdapter 是复制的官方代码,我就不贴了。ConversationAdapter 中涉及到了两个 item 布局:message_bubble_other 和 message_bubble_self,我将代码里的 MaterialCardView 换成了 androidx 的 CardView,下面是修改后的 xml。
res/message_bubble_other.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
app:cardBackgroundColor="@color/purple_200"
app:cardElevation="0dp">
<TextView
android:id="@+id/bubble_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="6dp"
android:layout_marginVertical="4dp"
android:textSize="14sp"
android:text="To me"
tools:ignore="HardcodedText" />
</androidx.cardview.widget.CardView>
</FrameLayout>
res/message_bubble_self.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
app:cardBackgroundColor="@color/teal_200"
app:cardElevation="0dp">
<TextView
android:id="@+id/bubble_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="6dp"
android:layout_marginVertical="4dp"
android:text="To you"
android:textSize="14sp"
tools:ignore="HardcodedText" />
</androidx.cardview.widget.CardView>
</FrameLayout>
好了,我们这时候运行一下项目。
我们看到,输入框获取焦点、软键盘弹出的时候,代表聊天会话内容的 RecyclerView 被软键盘遮住了。不过也确实应该被遮住,因为这时候还没有实现软键盘动画,肯定会遮挡的。
接下来我们就实现一下软键盘动画。
我们把官方 demo 的 RootViewDeferringInsetsCallback 和 TranslateDeferringInsetsAnimationCallback 这两个类复制到项目中,然后修改下 InsetsActivity 的代码,修改后的 InsetsActivity 如下所示 。
class InsetsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_insets)
val conversationRecyclerview = findViewById<RecyclerView>(R.id.conversation_recyclerview)
conversationRecyclerview.adapter = ConversationAdapter()
val rooView = findViewById<LinearLayout>(R.id.main)
val deferringInsetsListener = RootViewDeferringInsetsCallback(
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
deferredInsetTypes = WindowInsetsCompat.Type.ime(),
)
ViewCompat.setWindowInsetsAnimationCallback(rooView, deferringInsetsListener)
ViewCompat.setOnApplyWindowInsetsListener(rooView, deferringInsetsListener)
val messageHolder = findViewById<LinearLayout>(R.id.message_holder)
ViewCompat.setWindowInsetsAnimationCallback(
messageHolder,
TranslateDeferringInsetsAnimationCallback(
view = messageHolder,
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
deferredInsetTypes = WindowInsetsCompat.Type.ime(),
),
)
ViewCompat.setWindowInsetsAnimationCallback(
conversationRecyclerview,
TranslateDeferringInsetsAnimationCallback(
view = conversationRecyclerview,
persistentInsetTypes = WindowInsetsCompat.Type.systemBars(),
deferredInsetTypes = WindowInsetsCompat.Type.ime(),
),
)
}
}
先不管代码的意义,先运行一下项目,看看效果,能够看到键盘弹出时的过渡效果确实平滑了很多。
这是 Android 10 上的效果
还有就是,Google 在文档上建议给 Activity 加上 android:windowSoftInputMode="adjustResize",这样兼容性更好。
到此,给软键盘添加弹出动画的代码就写完了。
接下来,我们看看 Google 为了实现软键盘弹出动画,都写了哪些代码。
代码解读
第一步,InsetsActivity 启用 enableEdgeToEdge,通过 ViewCompat.setOnApplyWindowInsetsListener 监听,给根布局设置初始 padding,初始 padding 设置的就是底部导航栏的高度,是为了防止内容区被导航栏遮住。
看 RootViewDeferringInsetsCallback 这个类,默认 deferredInsets 是 false,所以给根布局 padding 设置的初始值就是底部导航栏的高度。
class RootViewDeferringInsetsCallback(
val persistentInsetTypes: Int,
val deferredInsetTypes: Int
) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE),
OnApplyWindowInsetsListener {
//省略部分代码
......
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
}
//省略部分代码
......
}
第二步,给想要跟着软键盘做动画的 View 设置平移动画。本文输入框父布局和聊天内容 Recyclerview 都要跟着软键盘移动,所以就给他们都设置了平移动画,这个平移动画的距离是跟着软键盘的弹出一点点变化的。具体代码实现可以看TranslateDeferringInsetsAnimationCallback 这个类,这是一个通用方法类, View 的 translationX 和 translationY 都设置了平移。如果只说软键盘弹出的场景的话,其实只有 translationY 起作用。
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
}
}
第三步,软键盘完全弹出后,通过 ViewCompat.setOnApplyWindowInsetsListener,给根布局设置最终 padding。代码实现在 RootViewDeferringInsetsCallback 这个类,我们看到 onEnd 方法里主动调用了ViewCompat.dispatchApplyWindowInsets(view!!, lastWindowInsets!!) ,将最终的边衬传给根布局,之后onApplyWindowInsets 方法回调,根布局就能设置最终的 padding 了。
class RootViewDeferringInsetsCallback(
val persistentInsetTypes: Int,
val deferredInsetTypes: Int
) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE),
OnApplyWindowInsetsListener {
//省略部分代码
......
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 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!!)
}
}
}
}
其它代码
官方 demo 还提供了聊天内容 Recyclerview 上下滑动和软键盘弹出、关闭联动的实现,在 InsetsAnimationLinearLayout 这个类里。
官方还提供了检查键盘软件可见性及其高度的代码 。
val insets = ViewCompat.getRootWindowInsets(view) ?: return
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
如果要实时观察软键盘的变化,可以通过监听实现。
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
insets
}
WindowInsetsAnimationCompat
WindowInsetsAnimationCompat.Callback 的传参有两种:DISPATCH_MODE_CONTINUE_ON_SUBTREE 和 DISPATCH_MODE_STOP。如果你想要将动画继续分发给子视图,就使用 DISPATCH_MODE_CONTINUE_ON_SUBTREE,如果不需要将动画继续分发给子视图或者本身就没有子视图,使用 DISPATCH_MODE_STOP。举个例子,有个布局是 LinearLayout 内嵌套了一个 EditText,LinearLayout 和 EditText 都设置了 setWindowInsetsAnimationCallback。如果你想 LinearLayout 和 EditText 都收到 WindowInsetsAnimation 的回调,那你的 LinearLayout 设置 WindowInsetsAnimation 时就得使用 DISPATCH_MODE_CONTINUE_ON_SUBTREE;如果 LinearLayout 使用的是 DISPATCH_MODE_STOP,那 EditText 就收不到 WindowInsetsAnimation 回调了。
总结
这一次学习了一下给软键盘添加弹出动画的方法,实现效果看着确实比以前僵硬的刷新 UI 舒服多了。虽然这是一点小小的改进,但我感觉这个过渡能给用户带来的正向观感是巨大的。而且,官方在 Android 15 上已经开始强制使用“edge to edge”了,那以后 Android 设备的基础体验也会有很大的提升,希望今后 Android 的使用体验能越来越好。
希望本文对你有所帮助。如果你有任何问题或建议,欢迎随时提出!
参考资料
Google. 控制软件键盘并为其添加动画效果. developer.android.com/develop/ui/…
Google. (n.d.). WindowInsets Samples. github.com/android/pla…