閒聊Android懸浮的“系統文字選擇選單”和“ActionMode解析”——附上原理分析

語言: CN / TW / HK

theme: arknights highlight: androidstudio


1.前言

國慶節釣魚的時候,也沒閒著,就想起之前做的一款筆記類app的時候,給系統文字選擇選單增加過一個新的選項入口,點選此入口,可以獲取選擇的文字內容,傳遞到我們的app裡面(有些小夥伴已經知道這其實能做很多事情),就想著給大家分享一下,具體使用起來其實很簡單,且聽我們慢慢道來,不要著急划走,有實現原理分析

下面我們從如何使用原始碼這兩個角度去展開介紹:ActionMode系統文字選擇選單,至於為什麼把這兩個放到一起,我相信你們看完本篇文章,會有自己的見解

2.ActionMode

點選檢視“官方ActionMode使用指南”

此處的官方使用指南有了,我為什麼還要寫?
1.想寫就寫咯,🙃太無聊了
2.寫例子玩玩🤣😅

ActionMode是一個抽象類,它將使用者互動的重點放在執行關聯操作上,ActionMode有兩種模式,一種是:TYPE_PRIMARY(預設模式)、另一種是:TYPE_FLOATING(浮動工具欄)

A.如何使用

ActionMode內部有個Callback,註釋中寫道可以使用

View.startActionMode(ActionMode.Callback) 或者View.startActionMode(ActionMode.Callback,int type) 來啟動

Activity裡面的startActionMode最終也是呼叫的View.startActionMode

使用起來也非常簡單,示例如下:
- 1.提供一個上下文選單的資源xml

```xml

`` - 2.實現ActionMode.Callback` 介面

kotlin var actionMode:ActionMode? = null val actionModeCallback = object: ActionMode.Callback{ override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { //提供上下文選單項的選單資源,官方文件使用指南里面有 mode?.menuInflater?.inflate(R.menu.context_menu,menu) return true } override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { return false } override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { return when (item?.itemId) { R.id.item_action_favorite -> { Toast.makeText(applicationContext,"❤️收藏成功",Toast.LENGTH_SHORT).show() mode?.finish() true } ..... else -> false } } override fun onDestroyActionMode(mode: ActionMode?) { actionMode = null } } - 3.啟動關聯操作模式

```kotlin //type = TYPE_PRIMARY actionMode = it.startActionMode(actionModeCallback)

//type = TYPE_FLOATING if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { actionMode = it.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING) } ```


示例-演示效果

B.原始碼分析

當呼叫了View.startActionMode之後會執行到下面這裡

```java //android.view.View

public ActionMode startActionMode(ActionMode.Callback callback, int type) { ViewParent parent = getParent(); if (parent == null) return null; try { //開始遞迴呼叫ViewGroup的startActionModeForChild return parent.startActionModeForChild(this, callback, type); } catch (AbstractMethodError ame) { // 使用預設型別ActionMode.TYPE_PRIMARY為指定檢視啟動操作模式 return parent.startActionModeForChild(this, callback); } } ``` 遞迴呼叫ViewGroup的startActionModeForChild,最終會執行到DecorView的startActionMode方法裡面

```java //com.android.internal.policy.DecorView

private ActionMode startActionMode( View originatingView, ActionMode.Callback callback, int type) { ActionMode.Callback2 wrappedCallback = new ActionModeCallback2Wrapper(callback); ActionMode mode = null; //mWindow是PhoneWindow if (mWindow.getCallback() != null && !mWindow.isDestroyed()) { try { //此處將會觸發 AppCompatWindowCallback#onWindowStartingActionMode mode = mWindow.getCallback().onWindowStartingActionMode(wrappedCallback, type); } catch (AbstractMethodError ame) { ...... } } if (mode != null) { ...... } else { //本篇文章示例中 //當呼叫startMode傳入的type=ActionMode.TYPE_FLOATING會執行到這裡 //內部執行到createFloatingActionMode方法 //然後內部初始化一個FloatingToolbar並返回FloatingActionMode //FloatingToolbar:是一個顯示上下文選單項的浮動工具欄,內部是通過popWindow實現的,感興趣的同學可以研究一下 mode = createActionMode(type, wrappedCallback, originatingView); if (mode != null && wrappedCallback.onCreateActionMode(mode, mode.getMenu())) { setHandledActionMode(mode); } else { mode = null; } } if (mode != null && mWindow.getCallback() != null && !mWindow.isDestroyed()) { try { //回撥Activity裡面的onActionModeStarted方法空實現 //通知Activity,ActionMode已經啟動了 mWindow.getCallback().onActionModeStarted(mode); } catch (AbstractMethodError ame) { } } return mode; } `` 我們看一下AppCompatWindowCallback#onWindowStartingActionMode`內部實現

```java //androidx.appcompat.app.AppCompatDelegateImpl.AppCompatWindowCallback

public android.view.ActionMode onWindowStartingActionMode( android.view.ActionMode.Callback callback, int type) { if (isHandleNativeActionModesEnabled()) { switch (type) { case android.view.ActionMode.TYPE_PRIMARY: // TYPE_PRIMARY型別觸發此方法呼叫 return startAsSupportActionMode(callback); } } // 不滿足上面的條件,最終會執行到Activity的onWindowStartingActionMode return super.onWindowStartingActionMode(callback, type); } `` 繼續看一下startAsSupportActionMode`

```java //androidx.appcompat.app.AppCompatDelegateImpl.AppCompatWindowCallback

final android.view.ActionMode startAsSupportActionMode( android.view.ActionMode.Callback callback) { // ActionMode.Callback包裝器 final SupportActionModeWrapper.CallbackWrapper callbackWrapper = new SupportActionModeWrapper.CallbackWrapper(mContext, callback);

// 往下翻,有分析
final androidx.appcompat.view.ActionMode supportActionMode =
        startSupportActionMode(callbackWrapper);

if (supportActionMode != null) {
    //返回包裝後的ActionMode
    return callbackWrapper.getActionModeWrapper(supportActionMode);
}
return null;

} 我們看一下上面的`startSupportActionMode`java //androidx.appcompat.app.AppCompatDelegateImpl

public ActionMode startSupportActionMode(@NonNull final ActionMode.Callback callback) { ...... //包裝Callback,當action mode被銷燬時,清除內部引用 final ActionMode.Callback wrappedCallback = new ActionModeCallbackWrapperV9(callback); ActionBar ab = getSupportActionBar(); if (ab != null) { //此處的supportActionBar是WindowDecorActionBar mActionMode = ab.startActionMode(wrappedCallback); ...... } ...... return mActionMode; } `ab.startActionMode(wrappedCallback)`實現如下java //androidx.appcompat.app.WindowDecorActionBar

public ActionMode startActionMode(ActionMode.Callback callback) { ...... //內部會初始化MenuBuilder,並繫結MenuBuilder.Callback ActionModeImpl mode = new ActionModeImpl(mContextView.getContext(), callback); //會觸發:WindowDecorActionBar#dispatchOnCreate //最終會回撥到我們上面示例demo中的ActionMode.Callback //實現的onCreateActionMode方法中,然後解析menu.xml,將xml填充到menu中 if (mode.dispatchOnCreate()) { mActionMode = mode; //狀態變化或者檢視更新 mode.invalidate(); //mContextView是ActionBarContextView //初始化一個返回按鈕的mClose的View,並將mClose新增到ActionBarContextView裡面 //根據mMenuLayoutRes獲取mMenuView //接著初始化多個MenuView.ItemView並填充到mMenuView並更新MenuView在容器中的位置 //執行mMenuView.requestLayout重新整理檢視 mContextView.initForMode(mode); //執行DecorToolBar的INVISIBLE和AppBarContextView的VISIBLE動畫 animateToMode(true); //傳送視窗狀態變更的事件(只有使用了AccessbilityService服務的才可以感知) mContextView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); return mode; } return null; } ```

C.小結

(1). View.startActionMode呼叫之後,通過遞迴呼叫最終會執行到DecorView的startActionMode方法
(2). DecorView內部會呼叫AppCompatWindowCallback#onWindowStartingActionMode
當type = ActionMode.TYPE_PRIMARY時
內部觸發WindowDecorActionBar#startActionMode,然後初始化ActionModeImpl,
並執行dispatchOnCreate方法,dispatchOnCreate方法最終會回撥ActionMode.Callback介面內部的onCreateActionMode方法中,
由開發者呼叫menuInflate.inflate將xml填充到menu中(即MenuBuilder)
然後將mClose(ImageView)新增到ActionBarContextView中,初始化mMenuView,取出ActionMode內部的MenuBuilder資料填充多個MenuView.ItemView然後更新檢視位置順序,呼叫requestLayout重新整理檢視,執行DecorToolBar的INVISIBLE和AppBarContextView的VISIBLE動畫來控制隱藏和顯示;
當type = ActionMode.TYPE_FLOATING時: mWindow.getCallback().onWindowStartingActionMode方法即(AppCompatWindowCallback#onWindowStartingActionMode)返回的是null,接下來會執行createActionMode方法,並在內部執行createFloatingActionMode方法:初始化一個FloatingToolbar並返回FloatingActionMode;
FloatingToolbar:是一個顯示上下文選單項的浮動工具欄,內部是通過popWindow實現的;

3.系統文字選擇選單

我們看一下下面兩個問題: - A、如何彈出系統文字選擇選單?系統文字選擇選單內部是怎麼實現的? - B、如何給系統文字選擇選單增加屬於自己app的選項?

A.如何彈出系統文字選擇選單?及內部實現

舉個例子,我們在使用TextView顯示文字的時候,如果想內容可以被選中,可以顯示覆制、全選按鈕,這個時候使用TextView的方法setTextIsSelectable(boolean selectable)就可以了,裡面做了什麼,是什麼原理?
我們開啟TextView原始碼檢視setTextIsSelectable方法

```java //android.widget.TextView

public void setTextIsSelectable(boolean selectable) { if (!selectable && mEditor == null) return; //初始化Editor createEditorIfNeeded(); //防止重複設定 if (mEditor.mTextIsSelectable == selectable) return; //更新mTextIsSelectable mEditor.mTextIsSelectable = selectable; ...... } ``` 我們進Editor裡面看mTextIsSelectable,發現在Editor的內部類TextActionModeCallback初始化的時候使用此變數,原來這裡使用的也是ActionMode

```java //android.widget.Editor

void startInsertionActionMode() { ...... ActionMode.Callback actionModeCallback = new TextActionModeCallback(TextActionMode.INSERTION); //這裡啟動的是TYPE_FLOATING型別的ActionMode,內部是popwindow實現,彈出系統文字選擇選單 mTextActionMode = mTextView.startActionMode( actionModeCallback, ActionMode.TYPE_FLOATING); if (mTextActionMode != null && getInsertionController() != null) { //如果游標插入控制器存在的話,會重新彈出一個選單,用於給使用者貼上內容用的 getInsertionController().show(); } } ```

B.如何給系統文字選擇選單增加屬於自己app的選項?

我們要找到系統在哪新增這些選項的?仍然以TextView為例
剛剛上面我們提到TextActionModeCallback,我們從上面ActionMode分析知道,在onCreateActionMode裡面會把menu.xml中的內容填充到Menu中,那麼系統自帶的如何做的呢?往下看分析:

```java //android.widget.Editor.TextActionModeCallback

private class TextActionModeCallback extends ActionMode.Callback2 { ...... @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { ...... //這裡面都是系統動態新增的menu選項,如:剪下、貼上、複製、分享等等 populateMenuWithItems(menu); Callback customCallback = getCustomCallback(); if (customCallback != null) { if (!customCallback.onCreateActionMode(mode, menu)) { //上面條件成立,取消選擇的文字 Selection.setSelection((Spannable) mTextView.getText(), mTextView.getSelectionEnd()); return false; } } if (mTextView.canProcessText()) { //如果當前文字支援分享、複製、長度大於0 && 選擇的長度大於0等條件成立 //從原始碼的註釋中可以看到:查詢出 “Intent.ACTION_PROCESS_TEXT” 符合條件Activity列表新增到menu中 mProcessTextIntentActionsHandler.onInitializeMenu(menu); } ...... return true; } ...... private void populateMenuWithItems(Menu menu) { if (mTextView.canCut()) {//剪下 menu.add(Menu.NONE, TextView.ID_CUT,......); } if (mTextView.canCopy()) {//複製 menu.add(Menu.NONE, TextView.ID_COPY, ......); } ...... if (mTextView.canRequestAutofill()) {//自動填充 menu.add(Menu.NONE, TextView.ID_AUTOFILL,......); } if (mTextView.canPasteAsPlainText()) {//貼上 menu.add(Menu.NONE,TextView.ID_PASTE_AS_PLAIN_TEXT,......); } ...... } ...... } `` 我們看一下mProcessTextIntentActionsHandler.onInitializeMenu(menu)`這個方法

java //android.widget.Editor.ProcessTextIntentActionsHandler public void onInitializeMenu(Menu menu) { //加載出所有action含PROCESS_TEXT的Activity列表 loadSupportedActivities(); final int size = mSupportedActivities.size(); for (int i = 0; i < size; i++) { final ResolveInfo resolveInfo = mSupportedActivities.get(i); //獲取到支援的列表,動態新增到menu中 menu.add(Menu.NONE, Menu.NONE, Editor.MENU_ITEM_ORDER_PROCESS_TEXT_INTENT_ACTIONS_START + i, getLabel(resolveInfo)) .setIntent(createProcessTextIntentForResolveInfo(resolveInfo)) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); } } //加載出所有支援處理PROCESS_TEXT的Intent private void loadSupportedActivities() { mSupportedActivities.clear(); if (!mContext.canStartActivityForResult()) { return; } PackageManager packageManager = mTextView.getContext().getPackageManager(); //查詢符合條件的意圖 List<ResolveInfo> unfiltered = packageManager.queryIntentActivities(createProcessTextIntent(), 0); for (ResolveInfo info : unfiltered) { if (isSupportedActivity(info)) { mSupportedActivities.add(info); } } } //處理PROCESS_TEXT的Intent private Intent createProcessTextIntent() { return new Intent() .setAction(Intent.ACTION_PROCESS_TEXT) .setType("text/plain"); } 結合我們上面分析的ActionMode內容,這一下就全部明白了,那麼我們給系統的文字選擇選單增加一個選項入口還不是很簡單嗎?
- 1. 將意圖過濾器新增到AndroidManifest.xml xml <activity android:name=".CustomTextActivity" android:excludeFromRecents="false" android:configChanges="locale|orientation|keyboardHidden|screenSize" android:label="點贊❤️+收藏❤️=學會❤️"> <intent-filter> <action android:name="android.intent.action.PROCESS_TEXT" /> <category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="text/plain"/> </intent-filter> </activity> - 2. 處理意圖

kotlin class CustomTextActivity : AppCompatActivity(){ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_custom_text) val selectedText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) intent?.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) else null textShowContent.text = "$selectedText \n\n 文字內容長度: ${selectedText?.length?:0}" } }


系統文字選擇選單-增加選項(演示示例)

往期文章推薦:
1.Android跨程序傳大圖思考及實現——附上原理分析
2.Jetpack Compose實現bringToFront功能——附上原理分析
3.Jetpack Compose UI建立佈局繪製流程+原理 —— 內含概念詳解(滿滿乾貨)
4.Jetpack App Startup如何使用及原理分析
5.Jetpack Compose - Accompanist 元件庫
6.原始碼分析 | ThreadedRenderer空指標問題,順便把Choreographer認識一下
7.原始碼分析 | 事件是怎麼傳遞到Activity的?
8.聊聊CountDownLatch 原始碼
9.Android正確的保活方案,不要掉進保活需求死迴圈陷進