闲聊Android悬浮的“系统文本选择菜单”和“ActionMode解析”——附上原理分析

4,817

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 version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:title="@string/menu_favorite"
        android:id="@+id/item_action_favorite"
        android:icon="@drawable/ic_favorite_svg"/>
    <item
        android:title="@string/menu_cjsh"
        android:id="@+id/item_action_cjsh"/>
</menu>
  • 2.实现 ActionMode.Callback 接口
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.启动关联操作模式
//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之后会执行到下面这里

//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方法里面

//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内部实现

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

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

//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)实现如下

//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方法

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

//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中,那么系统自带的如何做的呢?往下看分析:

//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)这个方法

//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
<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. 处理意图
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正确的保活方案,不要掉进保活需求死循环陷进