Android隐藏EditText长按菜单中分享功能探索

4,371 阅读5分钟

常见的EditText长按菜单如下

oppo长按菜单
oppo
小米长按菜单
小米

需求是隐藏掉其中的分享/搜索功能,禁止将内容分享到其他应用。

最终解决方案

这里先说下最终解决方案
像华为/oppo等手机,该菜单实际是谷歌系统的即没有改过源代码,像小米的菜单则是自定义,该部分的源代码改动过。
两方面修改:
1.谷歌系统自带的 通过 EditText.setCustomSelectionActionModeCallback()方法设置自定义的选中后动作模式接口,只保留需要的菜单项
代码如下

  editText.customSelectionActionModeCallback = object : ActionMode.Callback {
      override fun onCreateActionMode(
        mode: ActionMode?,
        menu: Menu?
      ): Boolean {
        menu?.let {
          val size = menu.size()
          for (i in size - 1 downTo 0) {
            val item = menu.getItem(i)
            val itemId = item.itemId
            //只保留需要的菜单项  
            if (itemId != android.R.id.cut
                && itemId != android.R.id.copy
                && itemId != android.R.id.selectAll
                && itemId != android.R.id.paste
            ) {
              menu.removeItem(itemId)
            }
          }
        }
        return true
      }

      override fun onActionItemClicked(
        mode: ActionMode?,
        item: MenuItem?
      ): Boolean {
        return false
      }

      override fun onPrepareActionMode(
        mode: ActionMode?,
        menu: Menu?
      ): Boolean {
        return false
      }

      override fun onDestroyActionMode(mode: ActionMode?) {
      }
    }

2.小米等手机自定义菜单无法进行隐藏,可以是分享、搜索等功能失效,即在BaseActivity的startActivityForResult中进行跳转拦截,如果是调用系统的分享/搜索功能,则不允许跳转

 override fun startActivityForResult(
    intent: Intent?,
    requestCode: Int
  ) {
    if (!canStart(intent)) return
    super.startActivityForResult(intent, requestCode)
  }

  @SuppressLint("RestrictedApi")
  @RequiresApi(Build.VERSION_CODES.JELLY_BEAN)
  override fun startActivityForResult(
    intent: Intent?,
    requestCode: Int,
    options: Bundle?
  ) {
    if (!canStart(intent)) return
    super.startActivityForResult(intent, requestCode, options)
  }

  private fun canStart(intent: Intent?): Boolean {
    return intent?.let {
      val action = it.action
      action != Intent.ACTION_CHOOSER//分享
          && action != Intent.ACTION_VIEW//跳转到浏览器
          && action != Intent.ACTION_SEARCH//搜索
    } ?: false
  }

如果以上不满足要求,只能通过自定义长按菜单来实现自定义的菜单栏。

解决思路(RTFSC)

分析源码菜单的创建和点击事件

既然是长按松手后弹出的,应该在onTouchEvent中的ACTION_UP事件或者在performLongClick中,从两方面着手
先看perfomLongEvent EditText没有实现 去它的父类TextView中查找

TextView.java
    public boolean performLongClick() {
       ···省略部分代码
        if (mEditor != null) {
            handled |= mEditor.performLongClick(handled);
            mEditor.mIsBeingLongClicked = false;
        }

       ···省略部分代码
        return handled;
    }

可看到调用了 mEditor.performLongClick(handled)方法

Editor.java

 public boolean performLongClick(boolean handled) {
        if (!handled && !isPositionOnText(mLastDownPositionX, mLastDownPositionY)
                && mInsertionControllerEnabled) {
            final int offset = mTextView.getOffsetForPosition(mLastDownPositionX,
                    mLastDownPositionY);//获取当前松手时的偏移量
            Selection.setSelection((Spannable) mTextView.getText(), offset);//设置选中的内容
            getInsertionController().show();//插入控制器展示
            mIsInsertionActionModeStartPending = true;
            handled = true;
         ···
        }
        if (!handled && mTextActionMode != null) {
            if (touchPositionIsInSelection()) {
                startDragAndDrop();//开始拖动
               ···
            } else {
                stopTextActionMode();
                selectCurrentWordAndStartDrag();//选中当前单词并且开始拖动
               ···
            }
            handled = true;
        }
        if (!handled) {
            handled = selectCurrentWordAndStartDrag();//选中当前单词并且开始拖动
            ···
            }
        }

        return handled;
    }

从上面代码分析
1.长按时会先选中内容 Selection.setSelection((Spannable) mTextView.getText(), offset)
2.显示插入控制器 getInsertionController().show()
3.开始拖动/选中单词后拖动 startDragAndDrop()/ selectCurrentWordAndStartDrag()
看着很像了
看下第二步中展示的内容

Editor.java  -> InsertionPointCursorController

   public void show() {
            getHandle().show();
            if (mSelectionModifierCursorController != null) {
                mSelectionModifierCursorController.hide();
            }
        }

    ···
   private InsertionHandleView getHandle() {
            if (mSelectHandleCenter == null) {
                mSelectHandleCenter = mTextView.getContext().getDrawable(
                        mTextView.mTextSelectHandleRes);
            }
            if (mHandle == null) {
                mHandle = new InsertionHandleView(mSelectHandleCenter);
            }
            return mHandle;
        }

实际是InsertionHandleView 执行了show方法。 查看其父类HandlerView的构造方法

          private HandleView(Drawable drawableLtr, Drawable drawableRtl, final int id) {
            super(mTextView.getContext());
            ···
            mContainer = new PopupWindow(mTextView.getContext(), null,
                    com.android.internal.R.attr.textSelectHandleWindowStyle);
           ···
            mContainer.setContentView(this);
            ···
        }

由源码可看出 HandlerView实际上是PopWindow的View。 即选中的图标实际上是popwidow
看源码可看出HandleView有两个实现类 InsertionHandleView 和SelectionHandleView 由名字可看出一个是插入的,一个选择的 看下HandleView的show方法

Editor.java  ->HandleView

 public void show() {
            if (isShowing()) return;
            getPositionListener().addSubscriber(this, true );
            // Make sure the offset is always considered new, even when focusing at same position
            mPreviousOffset = -1;
            positionAtCursorOffset(getCurrentCursorOffset(), false, false);
        }

看下positionAtCursorOffset方法

Editor.java  ->HandleView  

          protected void positionAtCursorOffset(int offset, boolean forceUpdatePosition,
                boolean fromTouchScreen) {
          ···
            if (offsetChanged || forceUpdatePosition) {
                if (offsetChanged) {
                    updateSelection(offset);
                   ···
                }
              ···
            }
        }

里面有一个updateSelection更新选中的位置,该方法会导致EditText重绘,再看show方法的getPositionListener().addSubscriber(this, true )
getPositionListener()返回的实际上是ViewTreeObserver.OnPreDrawListener的实现类PositionListener 重绘会调用onPreDraw的方法

Editor.java-> PositionListener 

        @Override
        public boolean onPreDraw() {
            ···
            for (int i = 0; i < MAXIMUM_NUMBER_OF_LISTENERS; i++) {
              ···
                        positionListener.updatePosition(mPositionX, mPositionY,
                                mPositionHasChanged, mScrollHasChanged);
               ···
            }
               ···
            return true;
        }

调用了positionListener.updatePosition方法, positionListener这个实现类对应的是HandlerView
重点在HandleView的updatePosition方法,该方法进行popWindow的显示和更新位置
看一下该方法的实现

Editor.java  ->HandleView

         @Override
        public void updatePosition(int parentPositionX, int parentPositionY,
                boolean parentPositionChanged, boolean parentScrolled) {
                     ···
                    if (isShowing()) {
                        mContainer.update(pts[0], pts[1], -1, -1);
                    } else {
                        mContainer.showAtLocation(mTextView, Gravity.NO_GRAVITY, pts[0], pts[1]);
                    }
                } 
                ···
            }
        }

到此我们知道选中的图标即下面红框内的实际上popWindow展示

点击选中的图标可以展示菜单,看下HandleView的onTouchEvent方法

Editor.java  ->HandleView
        @Override
        public boolean onTouchEvent(MotionEvent ev) {
            updateFloatingToolbarVisibility(ev);
            ···
        }

updateFloatingToolbarVisibility(ev)真相在这里,该方法进行悬浮菜单栏的展示 经过进一步查找,可以看到会调用下面SelectionActionModeHelper的这个方法

SelectionActionModeHelper.java

     public void invalidateActionModeAsync() {
        cancelAsyncTask();
        if (skipTextClassification()) {
            invalidateActionMode(null);
        } else {
            resetTextClassificationHelper();
            mTextClassificationAsyncTask = new TextClassificationAsyncTask(
                    mTextView,
                    mTextClassificationHelper.getTimeoutDuration(),
                    mTextClassificationHelper::classifyText,
                    this::invalidateActionMode)
                    .execute();
        }
    }

会启动一个叫TextClassificationAsyncTask的异步任务,该异步任务最后会执行mEditor.getTextActionMode().invalidate()

 private void invalidateActionMode(@Nullable SelectionResult result) {
        ···
        final ActionMode actionMode = mEditor.getTextActionMode();
        if (actionMode != null) {
            actionMode.invalidate();
        }
        ···
    }

最后看下mTextActionMode 如何在Editor中赋值

Editor.java

      void startInsertionActionMode() {
       ···
        ActionMode.Callback actionModeCallback =
                new TextActionModeCallback(false /* hasSelection */);
        mTextActionMode = mTextView.startActionMode(
                actionModeCallback, ActionMode.TYPE_FLOATING);
        ···
    }

看下mTextView.startActionMode的注释,在View类中,Start an action mode with the given type. 根据给的类型,开启一个动作模式,该模式是一个TYPE_FLOATING模式,菜单的生成就在TextActionModeCallback类中
在TextActionModeCallback的onCreateActionMode方法中

Editor.java  ->TextActionModeCallback

        @Override
        public boolean onCreateActionMode(ActionMode mode, Menu menu) {
            mode.setTitle(null);
            mode.setSubtitle(null);
            mode.setTitleOptionalHint(true);
            //生成菜单
            populateMenuWithItems(menu);

            Callback customCallback = getCustomCallback();
            if (customCallback != null) {
                if (!customCallback.onCreateActionMode(mode, menu)) {
                    // The custom mode can choose to cancel the action mode, dismiss selection.
                    Selection.setSelection((Spannable) mTextView.getText(),
                            mTextView.getSelectionEnd());
                    return false;
                }
            }
            ···
        }

生成的菜单的方法populateMenuWithItems(menu)中,生成完菜单会执行自定义的回调getCustomCallback(), 看下该回调如何赋值。
在TextView中

TextView.java
    public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
        createEditorIfNeeded();
        mEditor.mCustomSelectionActionModeCallback = actionModeCallback;
    }

因此我们可以在自定义回调的onCreateActionMode方法中,删除不需要的菜单项。
但该方法对小米手机无效,小米手机的菜单展示,不是通过startActionMode来展示的。不过可以对菜单中的分享等功能进行禁止跳转,解决方法看最上面