Android “edge to edge”特性(二)给软键盘添加动画

4,193 阅读9分钟

前言

上一篇学习了关于全屏显示相关的 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>

好了,我们这时候运行一下项目。

软键盘添加动画初始状态.gif

我们看到,输入框获取焦点、软键盘弹出的时候,代表聊天会话内容的 RecyclerView 被软键盘遮住了。不过也确实应该被遮住,因为这时候还没有实现软键盘动画,肯定会遮挡的。

接下来我们就实现一下软键盘动画。

我们把官方 demo 的 RootViewDeferringInsetsCallbackTranslateDeferringInsetsAnimationCallback 这两个类复制到项目中,然后修改下 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 上的效果

Android 10.gif

这是 Android 14 上的效果

Android14效果.gif

还有就是,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…