最近在做一些关于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。