實戰經驗:打造仿微信聊天鍵盤,解決常見問題

語言: CN / TW / HK

防蘋果微信聊天頁面,聊天中的佈局不是,主要是鍵盤部分,鍵盤部分在做的過程中遇到了幾個坑,記錄一下,看看大家有沒有越到過

output_image.gif

分析ios微信聊天頁面

UI組成看起來比較簡單,但是包含的內容可真不少,首先語音、輸入框、表情、更多四個簡單元素,元素間存在互斥的一些狀態操作,比如語音時,顯示按住説話,鍵盤關閉,表情面板時面板關閉,面板關閉則聯動表情和EditText圖標的切換。

各狀態分析

  1. 語音狀態

    語音狀態時,語音與edit圖標切換,EditText 與按住説話UI切換,此時如果鍵盤處於編輯狀態,則收回鍵盤,此時鍵盤處於表情面板或者更多面板需要收回面板,若表情面板時,表情與edit圖位置恢復表情icon。

  2. 鍵盤狀態

點擊語音與edit圖標 位置時,icon 為語音標,鍵盤彈出,當前再表情面板時,點擊表情與edit圖標, 鍵盤彈出,icon 變換

  1. 表情狀態

注意語音與edit圖標 位置恢復即可

  1. 更多面板

注意語音與edit圖標,表情與edit圖標位置恢復

對於這四種狀態直接使用LiveData, 然後與點擊事件做出綁定,事件發生時處理對應狀態即可

image.png

鍵盤UI組成

image.png

所以可以將結構設置為: ```xml

<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"
    />

```

然後對應上述的狀態進行UI和鍵盤的操作

鍵盤邏輯處理

  1. 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 }

```

  1. 按照上面的狀態互斥,我們需要動態監聽軟鍵盤的打開和關閉

系統沒有提供對應的實現,所以我們才採取的辦法是,監聽軟鍵盤的高度變化 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時

    • 隱藏面板
    • 設置獲取焦點
    • 打開鍵盤 其他場景下切換沒什麼問題,但是當鍵盤和自定義面板切換時有可能出現這樣的問題:

image.png

因為鍵盤的關閉和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 顯示

在 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中使用第一種實現的,具體使用步驟:

  1. UI佈局
  2. 數據
  3. https://unicode.org/Public/emoji/14.0/emoji-test.txt 下載表情內容
  4. 解析表情數據, 多個十六進制的我沒寫, ```kotlin 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 服務使用可下載字體

  1. 在 Layout Editor 中,選擇一個 TextView,然後在 Properties 下,選擇 fontFamily > More Fonts。

image.png

  1. 在 Source 下拉列表中,選擇 Google Fonts。
  2. 在 Fonts 框中,選擇一種字體。
  3. 選擇 Create downloadable font,然後點擊 OK

image.png

然後會在項目的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得各種知識,不久後會發佈對應的博客,關注我,哈哈。

此係列屬於我的一個 《Android IM即時通信多進程中間件設計與實現》 系列的一部分,可以看看這個系列