查看原文
其他

一起来打造一个仿微信聊天的键盘

麦客奥德彪 郭霖 2023-06-29


/   今日科技快讯   /

近日,谷歌向该公司全体员工发电子邮件,宣布在全球办公区取消进入办公楼需要接种新冠肺炎疫苗的要求,并称“世界已经变得不同”。 在这封电子邮件中,谷歌全球安全副总裁克里斯·拉科夫表示:“接种疫苗将不再是进入我们任何一栋办公大楼的前提条件。”

/   作者简介   /

本篇文章来自麦客奥德彪的投稿,文章主要分享了对仿微信聊天键盘开发中的相关总结,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

麦客奥德彪的博客地址:
https://juejin.cn/user/2365804752418232

/   前言   /

仿苹果微信聊天页面,聊天中的布局不是,主要是键盘部分,键盘部分在做的过程中遇到了几个坑,记录一下,看看大家有没有遇到过。



/   分析ios微信聊天页面   /

UI组成看起来比较简单,但是包含的内容可真不少,首先语音、输入框、表情、更多四个简单元素,元素间存在互斥的一些状态操作,比如语音时,显示按住说话,键盘关闭,表情面板时面板关闭,面板关闭则联动表情和EditText图标的切换。

各状态分析

语音状态

语音状态时,语音与edit图标切换,EditText 与按住说话UI切换,此时如果键盘处于编辑状态,则收回键盘,此时键盘处于表情面板或者更多面板需要收回面板,若表情面板时,表情与edit图位置恢复表情icon。

键盘状态

点击语音与edit图标 位置时,icon 为语音标,键盘弹出,当前再表情面板时,点击表情与edit图标, 键盘弹出,icon 变换。

表情状态

注意语音与edit图标位置恢复即可。

更多面板

注意语音与edit图标,表情与edit图标位置恢复。

对于这四种状态直接使用LiveData,然后与点击事件做出绑定,事件发生时处理对应状态即可。


键盘UI组成


所以可以将结构设置为:

<LinearLayout
    android:id="@+id/cl_voiceRoom_inputRoot"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/white"
    android:focusable="true"
    android:focusableInTouchMode="true"
    android:orientation="vertical"
    tools:visibility="visible">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/imEditBgCL"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/im_chat_bottom_bg"
        android:minHeight="60dp">

        // 键盘顶部,表情输入框等

    </androidx.constraintlayout.widget.ConstraintLayout>

    // 指定面板占位
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/imMiddlewareVP"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:background="#F0EFEF"
        android:visibility="gone"
        app:layout_constraintTop_toBottomOf="@id/imEditBgCL"
        tools:visibility="visible"
        />

</LinearLayout>

然后对应上述的状态进行UI和键盘的操作。

/   键盘逻辑处理   /

EditText自动换行输入并将action设置为send按钮

这一步很简单,但是有一个坑,按照正常逻辑,再xml中的EditText 设置以下属性,即可完成这个需求。

android:imeOptions="actionSend"
android:inputType="textMultiLine"

按照属性的原义,这样将显示正常的发送按钮以及可自动多行输入,但是就是不显示发送,查资料发现imeOptions需要使inputType为text 时才显示,但是又实现不了我们的需求,最后处理方式。

android:imeOptions="actionSend"
android:inputType="text"

//然后在代码中进行如下设置:
binding.imMiddlewareET.run {
    imeOptions = EditorInfo.IME_ACTION_SEND
    setHorizontallyScrolling(false)
    maxLines = Int.MAX_VALUE
}

按照上面的状态互斥,我们需要动态监听软键盘的打开和关闭

系统没有提供对应的实现,所以我们才采取的办法是,监听软键盘的高度变化。

View rootView = getWindow().getDecorView().getRootView();
rootView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
        Rect rect = new Rect();
        rootView.getWindowVisibleDisplayFrame(rect);
        int heightDiff = rootView.getHeight() - rect.bottom;
        boolean isSoftKeyboardOpened = heightDiff > 0;
        // 处理软键盘打开或关闭的逻辑
    }
});

通过判断高度来推算键盘的打开或者关闭。

/   解决切换键盘问题   /

切换键盘时,比如表情和Edit切换。当面板是键盘时,点击图标区域。

  • 取消Edit焦点
  • 关闭键盘
  • 打开emoji面板

当面板是emoji时。

  • 隐藏面板
  • 设置获取焦点
  • 打开键盘其他场景下切换没什么问题,但是当键盘和自定义面板切换时有可能出现这样的问题


因为键盘的关闭和View的显示,或者View的隐藏和键盘的显示那个先执行完毕逻辑不能串行,导致会出现这种闪烁的画面。

解决方案

分析上述问题后会发现,导致的出现这种情况的原因就是逻辑不能串行,那我们保证二者的逻辑串行就不会出现这问题了,怎么保证呢?

首先要知道的是肯定不能让View先行,View先行一样会出现这个问题,所以要保证让键盘先行,我们看一下,键盘的打开和关闭:

// 显示键盘
private fun showSoftKeyBoard(view: View) {
    val imm = mContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
    imm?.showSoftInput(view, InputMethodManager.SHOW_FORCED)
}

// 隐藏键盘
private fun hideSoftKeyBoard(view: View) {
    val imm = mContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
    if (imm != null && imm.isActive) {
        imm.hideSoftInputFromWindow(view.windowToken, 0)
    }
}

这个代码对于键盘的显示隐藏是没有任何问题的,但是我们怎么判断它执行这个动作完毕了呢?

方法一

上面我们有这样的操作,监听了键盘高度的监听,我们可以在执行切换操作时启动一个线程的死循环,然后再循环中判断高度,满足高度时执行上述逻辑。

方法二

看下InputMethodManager的源码,发现:

/**
 * Synonym for {@link #hideSoftInputFromWindow(IBinder, int, **ResultReceiver**)}
 * without a result: request to hide the soft input window from the
 * context of the window that is currently accepting input.
 *
 * @param windowToken The token of the window that is making the request,
 * as returned by {@link View#getWindowToken() View.getWindowToken()}.
 * @param flags Provides additional operating flags.  Currently may be
 * 0 or have the {@link #HIDE_IMPLICIT_ONLY} bit set.
 */
public boolean hideSoftInputFromWindow(IBinder windowToken, int flags) {
    return hideSoftInputFromWindow(windowToken, flags, null);
}

是不是很神奇,这个隐藏方法有一个ResultReceiver 的回调,卧槽,是不是看这个名字就感觉有戏,具体看一下:

public boolean hideSoftInputFromWindow(IBinder windowToken, int flags,
        ResultReceiver resultReceiver) {
    return hideSoftInputFromWindow(windowToken, flags, resultReceiver,
            SoftInputShowHideReason.HIDE_SOFT_INPUT);
}

ResultReceiver是一个用于在异步操作完成时接收结果的类,它可以让你在不同的线程之间进行通信。在hideSoftInputFromWindow()方法中,ResultReceiver作为一个可选参数,用于指定当软键盘隐藏完成时的回调。该回调会在后台线程上执行,因此不会阻塞主线程,从而提高应用程序的响应性能。ResultReceiver类有一个onReceiveResult(int resultCode, Bundle resultData)方法,当异步操作完成时,该方法会被调用。通过实现该方法,你可以自定义处理异步操作完成后的行为。例如,在软键盘隐藏完成后,你可能需要执行一些操作,例如更新 UI 或者执行其他任务。

在hideSoftInputFromWindow()方法中,你可以通过传递一个ResultReceiver对象来指定异步操作完成后的回调。当软键盘隐藏完成时,系统会调用ResultReceiver对象的send()方法,并将结果代码和数据包装在Bundle对象中传递给ResultReceiver对象。然后,ResultReceiver对象的onReceiveResult()方法会在后台线程上执行,以便你可以在该方法中处理结果。然后看了showSoftInput也同样有这个参数。

public boolean showSoftInput(View view, int flags, ResultReceiver resultReceiver) {
    return showSoftInput(view, flags, resultReceiver, SoftInputShowHideReason.SHOW_SOFT_INPUT);
}

那我们可以这样解决。隐藏为例,当我执行切换时,首先调用hideSoftInputFromWindow,并创建ResultReceiver监听,当返回结果后,执行View的操作,保证他们的串行,以此解决切换键盘闪烁问题。

private fun hideSoftKeyBoard(view: View, callback: () -> Unit) {
    val imm = mActivity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
    if (imm != null && imm.isActive) {
        val resultReceiver = object : ResultReceiver(Handler(Looper.getMainLooper())) {
            override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
                super.onReceiveResult(resultCode, resultData)
                // 在这里处理软键盘隐藏完成后的逻辑
                callback.invoke()
                //...
            }
        }
        imm.hideSoftInputFromWindow(view.windowToken, 0, resultReceiver)
    }
}

/   Emoji   /

Emoji显示

在 Android 中,Emoji表情可以通过以下方式在字符串中表示:

  1. Unicode编码:Emoji表情的Unicode编码可以直接嵌入到字符串中,例如 "\u2764\ufe0f"表示一个红色的心形Emoji。其中,\u 是Unicode转义字符,后面跟着 4 个十六进制数表示该字符的 Unicode 编码。
  2. Unicode代码点:Unicode代码点是Unicode编码的十进制表示,可以使用 &# 后跟代码点数字和分号 ; 来表示Emoji,例如 &#128512; 表示一个笑脸 Emoji。在XML中,可以使用&#x后跟代码点的十六进制表示来表示Emoji,例如 &#x1f600; 表示一个笑脸 Emoji。
  3. Emoji表情符号:在Android 4.4及以上版本中,可以直接使用Emoji表情符号来表示 Emoji,例如😊表示一个微笑的Emoji。在 Android 4.3及以下版本中,需要使用第一种或第二种方式来表示Emoji。

我在此demo中使用第一种实现的,具体使用步骤:

  • UI布局
  • 数据

下载表情内容,地址如下:
https://unicode.org/Public/emoji/14.0/emoji-test.txt

解析表情数据, 多个十六进制的我没写。

flow {

val pattern = Regex("^(\S+)\s+;\s+fully-qualified\s+#\s+((?:\S+\s+)+)(.+)$")
val filterNotNull = readAssetsFile("emoji.txt", IMApplication.context)
                        .trim()
                        .lines()
                        .map { line ->
                            val matchResult = pattern.find(line)
                            if (matchResult != null) {
                                val (emoji, codePointHex, comment) = matchResult.destructured
                                val codePoint = emoji.drop(2).toInt(16)
                                EmojiEntry(emoji, codePoint, "E${emoji.take(2)}", comment,codePointHex)
                            } else {
                                null
                            }
                        }.filterNotNull()
                    emit(filterNotNull)
                }

使用

使用google提供的emoji库。

implementation 'androidx.emoji:emoji:1.1.0'

在Application中初始化。

val fontRequest = FontRequest(
    "com.google.android.gms.fonts",
    "com.google.android.gms",
    "Montserrat Subrayada",
    R.array.com_google_android_gms_fonts_certs
)
val config = FontRequestEmojiCompatConfig(this, fontRequest)
EmojiCompat.init(config)

对于FontRequest是使用的Goolge提供的可下载字体配置进行初始化的,当然可以不用,但是系统的字体对于表情不是高亮的,看起来是灰色的(也可以给TextView设置字体解决)。

通过 Android Studio 和 Google Play 服务使用可下载字体

在 Layout Editor 中,选择一个 TextView,然后在 Properties 下,选择 fontFamily > More Fonts。


在Source下拉列表中,选择Google Fonts。在Fonts框中,选择一种字体。选择Create downloadable font,然后点击 OK。


然后会在项目的res下生成文字。

<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto"
        app:fontProviderAuthority="com.google.android.gms.fonts"
        app:fontProviderPackage="com.google.android.gms"
        app:fontProviderQuery="Montserrat Subrayada"
        app:fontProviderCerts="@array/com_google_android_gms_fonts_certs">
</font-family>

Emoji 面板中的删除操作

再IOS微信中,点击Emoji面板后输入框是没有焦点的,然后点击删除时Emoji会有一个问题,因为它的大小是2个byte,所以常规删除是不行的。

expressionDeleteFL.setOnClickListener {
    val inputConnection =
        editText.onCreateInputConnection(
            EditorInfo()
        )
    // 找到要删除的字符的边界
    val text = editText.text.toString()
    val index = editText.selectionStart
    var deleteLength = 1
    if (index > 0 && index <= text.length) {
        val codePoint = text.codePointBefore(index)
        deleteLength = if (Character.isSupplementaryCodePoint(codePoint)) 2 else 1
    }
    inputConnection.deleteSurroundingText(deleteLength, 0)
}

  1. 首先,通过editText.onCreateInputConnection(EditorInfo())方法获取输入连接器(InputConnection),它可以用于向EditText发送文本和控制命令。在这里,我们使用它来删除文本。
  2. 接着,获取EditText中当前的文本,并找到要删除的字符的边界。通过editText.selectionStart方法获取当前文本的光标位置,然后使用text.codePointBefore(index)方法获取光标位置前面一个字符的Unicode编码点。如果该字符是一个Unicode表情符号,它可能由多个Unicode编码点组成,因此需要使用Character.isSupplementaryCodePoint(codePoint)方法来判断该字符是否需要删除多个编码点。
  3. 最后,使用inputConnection.deleteSurroundingText(deleteLength, 0)方法删除要删除的字符。其中,deleteLength是要删除的字符数,0 表示没有要插入的新文本。

主要的技术点在于“text.codePointBefore(index)方法获取光标位置前面一个字符的 Unicode 编码点,然后向前探测,找到字符边界”以此完成删除操作。

/   打开面板时 RV布局的处理   /

这个就比较简单了。

  1. 首先,通过root.viewTreeObserver.addOnGlobalLayoutListener方法添加一个全局布局监听器,该监听器可以监听整个布局树的变化,包括软键盘的弹出和隐藏。
  2. 在监听器的回调函数中,通过root.getWindowVisibleDisplayFrame(r)方法获取当前窗口的可见区域(不包括软键盘),并通过root.rootView.height方法获取整个布局树的高度,从而计算出软键盘的高度keypadHeight。
  3. 接着,通过计算屏幕高度的15%来判断软键盘是否弹出。如果软键盘高度超过了屏幕高度的15%,则认为软键盘已经弹出。
  4. 如果软键盘已经弹出,则通过imMiddlewareRV.scrollToPosition(mAdapter.getItemCount() - 1)方法将RecyclerView滚动到最后一条消息的位置,以确保用户始终能看到最新的消息。

root.viewTreeObserver.addOnGlobalLayoutListener {
    val r = Rect()
    root.getWindowVisibleDisplayFrame(r)
    val screenHeight = root.rootView.height
    val keypadHeight = screenHeight - r.bottom
    //键盘是否弹出
    val diff = screenHeight * 0.15
    if (keypadHeight > diff) { // 15% of the screen height
        imMiddlewareRV.scrollToPosition(mAdapter.getItemCount() - 1);
    }
}

/   总结   /

仿照微信聊天键盘的方法,实现了一个包含表情等功能的键盘区域,并解决了一些常见的问题。通过实践和调查,解决了切换键盘的问题,并实现了Emoji的Unicode显示和自定义删除时向前探索字符边界完成表情删除等操作。

在过程中,以为很简单的一个东西花了大量的时间调查原因,发现键盘这一块水很深,当我看到ResultReceiver时,看到了AIDL通信,所以再Android这个体系中,Binder的机制需要了然于胸的,刚好我最近在学习Binder得各种知识,不久后会发布对应的博客,关注我,哈哈。

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
微软的人工智能 Copilot 到底有多强大?带你来体验一下
一文吃透Hilt自定义与跨壁垒

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存