EditText是如何实现长按弹出复制粘贴等ContextMenu的源码解析

最近在做一些关于EditText编辑功能的需求,遇到了很多的问题,比如EditText在RecyclerView中会出现内容错乱、RecyclerView复用EditText后长按无法弹出复制、粘贴、全选ContextMenu等一些问题,在网上也没有搜到比较好的解决方法,于是就想研究一下这方面的源码,希望能帮到有需要的同学,少走一些弯路。 
网上看到的关于EditText的ContextMenu的问题,大部分是如何屏蔽长按后不弹,如何自定义ContextMenu的需求,本篇文章介绍Android系统是如何实现长按EditText弹出ContextMenu的,如果原理都明白了,那问题还不迎刃而解嘛,废话不多说,先看一个效果图: 

非常常见的功能,要研究这个功能的实现该从哪入手呢,我说一下我的思路:从EditText的长按事件开始,翻看EditText的源码,发现内容很少,并没有事件处理方法,于是找到了父View(TextView), TextView中有一个方法叫performLongClick,没错,就是它:

@Override
    public boolean performLongClick() {
        boolean handled = false;

        if (mEditor != null) {
            mEditor.mIsBeingLongClicked = true;
        }
        //执行父view的performLongClick
        if (super.performLongClick()) {
            handled = true;
        }

        //执行mEditor的performLongClick
        if (mEditor != null) {
            handled |= mEditor.performLongClick(handled);
            mEditor.mIsBeingLongClicked = false;
        }

        if (handled) {
            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
            if (mEditor != null) mEditor.mDiscardNextActionUp = true;
        }

        return handled;
    }



我们发现调用了super.performLongClick(),然后再到View中去看:

private boolean performLongClickInternal(float x, float y) {
        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);

        boolean handled = false;
        final ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnLongClickListener != null) {
            handled = li.mOnLongClickListener.onLongClick(View.this);
        }
        if (!handled) {
            final boolean isAnchored = !Float.isNaN(x) && !Float.isNaN(y);
            handled = isAnchored ? showContextMenu(x, y) : showContextMenu();
        }
        if (handled) {
            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
        }
        return handled;
    }


大家看这一句handled = isAnchored ? showContextMenu(x, y) : showContextMenu(); 感觉就要接近真相了,赶紧点进去:

public boolean showContextMenu(float x, float y) {
    return getParent().showContextMenuForChild(this, x, y);
}


这里面调的是父View的showContextMenuForChild方法,不同的页面父View都不同,一般都是LinearLayout、RelativeLayout,但他们没有重写这个方法,都用的ViewGroup的showContextMenuForChild:

@Override
    public boolean showContextMenuForChild(View originalView, float x, float y) {
        try {
            mGroupFlags |= FLAG_SHOW_CONTEXT_MENU_WITH_COORDS;
            if (showContextMenuForChild(originalView)) {
                return true;
            }
        } finally {
            mGroupFlags &= ~FLAG_SHOW_CONTEXT_MENU_WITH_COORDS;
        }
        return mParent != null && mParent.showContextMenuForChild(originalView, x, y);
    }

debug会发现这个方法会一直向上找父View,直到DecorView。DecorView中实际调用了showContextMenuForChildInternal方法:

private boolean showContextMenuForChildInternal(View originalView,
            float x, float y) {
        //.....

        final MenuHelper helper;
        final boolean isPopup = !Float.isNaN(x) && !Float.isNaN(y);
        //弹出ContextMenu
        if (isPopup) {
            helper = mWindow.mContextMenu.showPopup(getContext(), originalView, x, y);
        } else {
            helper = mWindow.mContextMenu.showDialog(originalView, originalView.getWindowToken());
        }

        if (helper != null) {
            // If it's a dialog, the callback needs to handle showing
            // sub-menus. Either way, the callback is required for propagating
            // selection to Context.onContextMenuItemSelected().
            callback.setShowDialogForSubmenu(!isPopup);
            helper.setPresenterCallback(callback);
        }

        mWindow.mContextMenuHelper = helper;
        return helper != null;
    }

最关键的一句话helper = mWindow.mContextMenu.showPopup(getContext(), originalView, x, y); 
看到这里本以为真相大白了,遗憾的是debug看这句话返回的helper为null,也就是并没有执行预期的复制、粘贴menu的显示,从showPopup这个方法里面可以看出,这个方法要做的事情就是我们View里面或者是activity里面弹出的ContextMenu,比如,微信的聊天列表,长按弹出的popupWindow就是通过这个方法实现的,这方面的问题百度有很多文章。 
现在线索突然断了,好烦躁,需要静下心来好好思考一下,既然这条路走不通,那肯定有别的途径,还记得前边performLongClick()方法吗?handled |= mEditor.performLongClick(handled); 看到没有,想必就是它了,瞬间精神了许多:

public boolean performLongClick(boolean handled) {
        // .....省略了无关代码

        // Start a new selection
        if (!handled) {
            handled = selectCurrentWordAndStartDrag();
        }

        return handled;
    }

看到selectCurrentWordAndStartDrag()这个方法心里就放心了,从字面上能看出是选中当前文字然后开始拖拽。

    /**
     * If the TextView allows text selection, selects the current word when no existing selection
     * was available and starts a drag.
     *
     * @return true if the drag was started.
     */
    private boolean selectCurrentWordAndStartDrag() {
        //......

        if (!checkField()) {
            return false;
        }
        //如果mTextView没有选中,那么选中当前文字
        if (!mTextView.hasSelection() && !selectCurrentWord()) {
            // No selection and cannot select a word.
            return false;
        }
        stopTextActionModeWithPreservingSelection();
        getSelectionController().enterDrag(
                SelectionModifierCursorController.DRAG_ACCELERATOR_MODE_WORD);
        return true;
    }

这个方法的注释意思是如果当前TextView允许选中,那么选中当前文字然后开启拖拽效果,selectCurrentWord()方法中关键的一句是Selection.setSelection((Spannable) mTextView.getText(), selectionStart, selectionEnd); 可以看出这个地方就是选中当前文字的实现。enterDrag呢?getSelectionController()得到的是SelectionModifierCursorController,这个类从字面上看是选中、修改光标的控制器,

public void enterDrag(int dragAcceleratorMode) {
            // Just need to init the handles / hide insertion cursor.
            show();
            mDragAcceleratorMode = dragAcceleratorMode;
            // Start location of selection.
            mStartOffset = mTextView.getOffsetForPosition(mLastDownPositionX,
                    mLastDownPositionY);
            mLineSelectionIsOn = mTextView.getLineAtCoordinate(mLastDownPositionY);
            // Don't show the handles until user has lifted finger.
            hide();

            // ......
        }

我们看到show()这个方法,很有可能就是显示menu的实现:

 public void show() {
            if (mTextView.isInBatchEditMode()) {
                return;
            }
            initDrawables();
            initHandles();
        }

里面有两个方法,分别看一下:

        private void initDrawables() {
            //获取选中效果左边的Drawable
            if (mSelectHandleLeft == null) {
                mSelectHandleLeft = mTextView.getContext().getDrawable(
                        mTextView.mTextSelectHandleLeftRes);
            }
            //获取选中效果右边的Drawable
            if (mSelectHandleRight == null) {
                mSelectHandleRight = mTextView.getContext().getDrawable(
                        mTextView.mTextSelectHandleRightRes);
            }
        }

        private void initHandles() {
            // Lazy object creation has to be done before updatePosition() is called.
            //将选中效果左右两边的Drawable以SelectionHandleView的形式创建出来
            if (mStartHandle == null) {
                mStartHandle = new SelectionHandleView(mSelectHandleLeft, mSelectHandleRight,
                        com.android.internal.R.id.selection_start_handle,
                        HANDLE_TYPE_SELECTION_START);
            }
            if (mEndHandle == null) {
                mEndHandle = new SelectionHandleView(mSelectHandleRight, mSelectHandleLeft,
                        com.android.internal.R.id.selection_end_handle,
                        HANDLE_TYPE_SELECTION_END);
            }

            //显示两边的Drawable
            mStartHandle.show();
            mEndHandle.show();

            hideInsertionPointCursorController();
        }

原来这个方法是显示选中文字的两边光标效果的,并没有看到我们预期的结果,烦躁啊,但通过这个方法我们可以看到,如果我们想改变这个光标的话,只需要修改mTextView.mTextSelectHandleLeftRes和mTextView.mTextSelectHandleRightRes就行了,这两个属性想必在TextView的xml里面可以直接设置,也算是有一点点收获吧,至少现在文字已经选中了。 
又经过了很长时间的debug,发现这个menu并没有在performLongClick()方法里实现,而是在onTouchEvent()方法中,当事件为ACTION_UP的时候。

@Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();
        if (mEditor != null) {
            mEditor.onTouchEvent(event);

            if (mEditor.mSelectionModifierCursorController != null &&
                    mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) {
                return true;
            }
        }

        //........
    }

又是mEditor ,看来这个类真的很重要啊,进入onTouchEvent方法

void onTouchEvent(MotionEvent event) {
        //......

        if (hasSelectionController()) {
            getSelectionController().onTouchEvent(event);
        }
        //......
    }

关键的只有这一句,getSelectionController()我们上边已经看到过了,返回的是SelectionModifierCursorController,然后我们看看里面的onTouchEvent方法:

public void onTouchEvent(MotionEvent event) {
            //......
            switch (event.getActionMasked()) {
                //......

                case MotionEvent.ACTION_UP:
                    if (!isDragAcceleratorActive()) {
                        break;
                    }
                    updateSelection(event);

                    // No longer dragging to select text, let the parent intercept events.
                    mTextView.getParent().requestDisallowInterceptTouchEvent(false);

                    // No longer the first dragging motion, reset.
                    resetDragAcceleratorState();
                    //如果mTextView有选中,那么启动选中actionMode
                    if (mTextView.hasSelection()) {
                        startSelectionActionMode();
                    }
                    break;
            }
        }

直接进入MotionEvent.ACTION_UP事件,mTextView.hasSelection()想必肯定是true,以为前面的performLongClick()分析,已经处于选中状态了,赶紧看看这个方法吧:

 boolean startSelectionActionMode() {
        boolean selectionStarted = startSelectionActionModeInternal();
        if (selectionStarted) {
            getSelectionController().show();
        }
        mRestartActionModeOnNextRefresh = false;
        return selectionStarted;
    }

实际上调用的是startSelectionActionModeInternal()方法,真相已经渐渐浮出水面了,

private boolean startSelectionActionModeInternal() {
        //......

        ActionMode.Callback actionModeCallback =
                new TextActionModeCallback(true /* hasSelection */);
        mTextActionMode = mTextView.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING);

        //......
        return selectionStarted;
    }

关键的地方到了,这里实例化了一个TextActionModeCallback对象,我们看看这个类的实现:

    /**
     * An ActionMode Callback class that is used to provide actions while in text insertion or
     * selection mode.
     *
     * The default callback provides a subset of Select All, Cut, Copy, Paste, Share and Replace
     * actions, depending on which of these this TextView supports and the current selection.
     */
    private class TextActionModeCallback extends ActionMode.Callback2 {
        //......

        @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;
                }
            }

            if (mTextView.canProcessText()) {
                mProcessTextIntentActionsHandler.onInitializeMenu(menu);
            }

            if (menu.hasVisibleItems() || mode.getCustomView() != null) {
                if (mHasSelection && !mTextView.hasTransientState()) {
                    mTextView.setHasTransientState(true);
                }
                return true;
            } else {
                return false;
            }
        }
    }


看一下这个类的注释,这是一个用来提供文本的插入、选中等操作的回调,默认的回调提供了全选、剪切、复制、粘贴、分享和替换,真的就是它了, 
这里需要提一嘴的是Callback customCallback = getCustomCallback();这一句表示开发者可以自己实现menu的创建,通过TextView的setCustomSelectionActionModeCallback()方法设置的,如果大家有这个需求的话可以在这个地方尝试(我没试过)。 
默认的menu是通过populateMenuWithItems(menu);这句话实现的:

        private void populateMenuWithItems(Menu menu) {
            if (mTextView.canCut()) {
                menu.add(Menu.NONE, TextView.ID_CUT, MENU_ITEM_ORDER_CUT,
                        com.android.internal.R.string.cut).
                    setAlphabeticShortcut('x').
                    setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
            }

            if (mTextView.canCopy()) {
                menu.add(Menu.NONE, TextView.ID_COPY, MENU_ITEM_ORDER_COPY,
                        com.android.internal.R.string.copy).
                    setAlphabeticShortcut('c').
                    setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
            }

            if (mTextView.canPaste()) {
                menu.add(Menu.NONE, TextView.ID_PASTE, MENU_ITEM_ORDER_PASTE,
                        com.android.internal.R.string.paste).
                    setAlphabeticShortcut('v').
                    setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
            }

            if (mTextView.canShare()) {
                menu.add(Menu.NONE, TextView.ID_SHARE, MENU_ITEM_ORDER_SHARE,
                        com.android.internal.R.string.share).
                    setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
            }

            updateSelectAllItem(menu);
            updateReplaceItem(menu);
        }

现在实现menu的地方已经找到了,那么在什么时候会调用onCreateActionMode呢?什么时候回显示呢?我们继续看看前边的startSelectionActionModeInternal()方法,下边一句是mTextView.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING);这个方法有两个参数,第一个参数就是我们刚刚分析的TextActionModeCallback 回调,是menu的实现,第二个参数是ActionMode.TYPE_FLOATING,应该是在显示menu的时候用到的,floating嘛,对不对,继续跟进:

public ActionMode startActionMode(ActionMode.Callback callback, int type) {
        ViewParent parent = getParent();
        if (parent == null) return null;
        try {
            return parent.startActionModeForChild(this, callback, type);
        } catch (AbstractMethodError ame) {
            // Older implementations of custom views might not implement this.
            return parent.startActionModeForChild(this, callback);
        }
    }

这里面会一直调用parent.startActionModeForChild,一直到DecorView,最终会调用到DecorView的startActionMode()方法:

private ActionMode startActionMode(
            View originatingView, ActionMode.Callback callback, int type) {
        //将callback包装到wrappedCallback 
        ActionMode.Callback2 wrappedCallback = new ActionModeCallback2Wrapper(callback);
        ActionMode mode = null;
        //......

        if (mode != null) {
            if (mode.getType() == ActionMode.TYPE_PRIMARY) {
            //......
        } else {
            //这里会创建一个FloatingActionMode
            mode = createActionMode(type, wrappedCallback, originatingView);
            //调用TextActionModeCallback 类的onCreateActionMode方法创建menu
            if (mode != null && wrappedCallback.onCreateActionMode(mode, mode.getMenu())) {
                //处理ActionMode
                setHandledActionMode(mode);
            } else {
                mode = null;
            }
        }
//......
        return mode;
    }

这里面我们看到了创建menu的调用代码,想必setHandledActionMode(mode);这个方法会将它show出来:

private void setHandledActionMode(ActionMode mode) {
        if (mode.getType() == ActionMode.TYPE_PRIMARY) {
            setHandledPrimaryActionMode(mode);
        } else if (mode.getType() == ActionMode.TYPE_FLOATING) {
            setHandledFloatingActionMode(mode);
        }
    }

这里看到了前面提到的ActionMode.TYPE_FLOATING的作用了,继续看:

private void setHandledFloatingActionMode(ActionMode mode) {
        mFloatingActionMode = mode;
        //创建FloatingToolbar
        mFloatingToolbar = new FloatingToolbar(mContext, mWindow);
        ((FloatingActionMode) mFloatingActionMode).setFloatingToolbar(mFloatingToolbar);
        //显示FloatingToolbar
        mFloatingActionMode.invalidate();  // Will show the floating toolbar if necessary.
        mFloatingActionModeOriginatingView.getViewTreeObserver()
            .addOnPreDrawListener(mFloatingToolbarPreDrawListener);
    }

到这里就终于结束了,menu最终以FloatingToolbar的形式显示出来

总结一下
1、EditText(或者说TextView)长按选中的效果是在Editor.performLongClick(handled)中实现的,这个方法会让当前文本处于选中状态,并显示选中的左右Drawable

2、长按弹出的复制、全选、粘贴等menu的显示过程:首先是Editor内部类SelectionModifierCursorController在onTouchEvent处理MotionEvent.ACTION_UP事件,然后调用startSelectionActionModeInternal()方法,并创建了TextActionModeCallback用来初始化menuItem,然后通过TextView的startActionMode一直往上找,最终由DecorView以FloatingToolbar的形式展现出来。

3、如果我们想自定义menu有哪些item,可以通过TextView的setCustomSelectionActionModeCallback实现,可以参照TextActionModeCallback。

实现 EditText按复制功能,可以通过实现 OnLongClickListener 接口来监听按事件,然后使用 ClipboardManager 将文本复制到剪贴板中。 具体实现步骤如下: 1. 在布局文件中添加 EditText 控件: ```xml <EditText android:id="@+id/editText" android:layout_width="match_parent" android:layout_height="wrap_content"/> ``` 2. 在代码中获取 EditText 控件,并设置按监听器: ```java EditText editText = findViewById(R.id.editText); editText.setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View v) { // 处理按事件 return true; } }); ``` 3. 在按监听器中获取 EditText 中的文本,并将其复制到剪贴板中: ```java ClipboardManager clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); ClipData clipData = ClipData.newPlainText("text", editText.getText().toString()); clipboardManager.setPrimaryClip(clipData); ``` 完整代码示例: ```java EditText editText = findViewById(R.id.editText); editText.setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View v) { ClipboardManager clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); ClipData clipData = ClipData.newPlainText("text", editText.getText().toString()); clipboardManager.setPrimaryClip(clipData); Toast.makeText(MainActivity.this, "已复制到剪贴板", Toast.LENGTH_SHORT).show(); return true; } }); ``` 这样,当用户EditText 控件时,就会将其中的文本复制到剪贴板中。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值