【坑】RecyclerView与EditText

背景介绍

使用RecyclerView实现富文本编辑器,包含三种ItemType,分别是TextTodoImage。由于输入需要,每种item都有EditText,而RecyclerView在滚动的过程中,会将ItemView移除,并再次attach,这个过程会造成ItemView的焦点丢失,因此需要记录焦点所在的因此需要记录焦点所在的position,当该pisition重新显示在屏幕上时,为ItemView请求焦点。

下面将列出在开发过程中遇到的难点和解决方案。

1. 长按无法呼出上下文操作菜单?

原因分析

由于在onBindViewHolder时,会调用EditText#setText(CharSequence)方法,该方法会产生如下方法堆栈(调用关系为由上及下):

1
2
3
4
5
6
7
8
9
10
/**
* @see TextView#setText(CharSequence)
*
* @see TextView#checkForRelayout()
*
* @see TextView#makeNewLayout(int, int, BoringLayout.Metrics,
* BoringLayout.Metrics, int, boolean)
*
* @see Editor#prepareCursorControllers()
*/

Editor#prepareCursorControllers()会给决定能否呼出上下文操作菜单的变量mInsertionControllerEnabledmSelectionControllerEnabled赋值。具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void prepareCursorControllers() {
boolean windowSupportsHandles = false;

// 在onBindViewHolder时,Item还没有添加到RecyclerView中,其RootView不是Window,
// LayoutParams不是WindowManager.LayoutParams的实例,因此后续的两个变量都为false,
// 导致长按无法呼出上下文操作菜单。
ViewGroup.LayoutParams params = mTextView.getRootView().getLayoutParams();
if (params instanceof WindowManager.LayoutParams) {
WindowManager.LayoutParams windowParams = (WindowManager.LayoutParams) params;
windowSupportsHandles = windowParams.type < WindowManager.LayoutParams.FIRST_SUB_WINDOW
|| windowParams.type > WindowManager.LayoutParams.LAST_SUB_WINDOW;
}

boolean enabled = windowSupportsHandles && mTextView.getLayout() != null;
mInsertionControllerEnabled = enabled && isCursorVisible();
mSelectionControllerEnabled = enabled && mTextView.textCanBeSelected();

if (!mInsertionControllerEnabled) {
hideInsertionPointCursorController();
if (mInsertionPointCursorController != null) {
mInsertionPointCursorController.onDetached();
mInsertionPointCursorController = null;
}
}

if (!mSelectionControllerEnabled) {
stopTextActionMode();
if (mSelectionModifierCursorController != null) {
mSelectionModifierCursorController.onDetached();
mSelectionModifierCursorController = null;
}
}
}

解决方案

在Item添加到RecyclerView中之后,再次调用Editor#prepareCursorControllers()方法。
因为该方法是包私有(package-private)方法,因此需要使用反射来调用。为了避免多次查找FieldMethod的消耗,可以使用成员变量保存反射过程中找到的FieldMethod

2. 输入框请求焦点导致滚动自动停止?

原因分析

当一个拥有焦点的position离开并再次进入屏幕时,首先会为该position对应的ItemView请求焦点,接着会进行Layout,发生以下方法堆栈:

1
2
3
4
5
6
7
8
9
10
/**
*
* @see ViewRootImpl#performTraversals()
*
* @see ViewTreeObserver#dispatchOnPreDraw()
*
* @see TextView#onPreDraw()
*
* @see TextView#bringPointIntoView()
*/

看一下TextView#bringPointIntoView()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public boolean bringPointIntoView(int offset) {
// 此处省略大量无关代码,此部分代码用于计算光标所在矩形的位置。
// ...

if (isFocused()) {
// This offsets because getInterestingRect() is in terms of viewport coordinates, but
// requestRectangleOnScreen() is in terms of content coordinates.

// The offsets here are to ensure the rectangle we are using is
// within our view bounds, in case the cursor is on the far left
// or right. If it isn't withing the bounds, then this request
// will be ignored.
if (mTempRect == null) mTempRect = new Rect();
mTempRect.set(x - 2, top, x + 2, bottom);
getInterestingRect(mTempRect, line);
mTempRect.offset(mScrollX, mScrollY);

// requestRectangleOnScreen()方法会请求此View中的一个矩形显示在屏幕上
if (requestRectangleOnScreen(mTempRect)) {
changed = true;
}
}

return changed;
}

View#requestRectangleOnScreen(Rect)会遍历所有的父View并调用它们的requestChildRectangleOnScreen方法,整个方法会将子View指定的矩形区域显示到屏幕上。在RecyclerView中,该方法会调用RecyclerView#smoothScrollBy(int, int)方法触发滚动,这个方法会先取消之前的滚动,并开始此次滚动。

解决方案

在用户滚动时,禁用请求显示child的功能。通过增加onScrollListener即可监听滚动状态的变化。如果滚动状态是SCROLL_STATE_DRAGGING,或者滚动状态从SCROLL_STATE_DRAGGING变化为SCROLL_STATE_SETTLING则表示用户触发了此次滚动(DRAGGING表示手指还未离开屏幕,DRAGGING之后的SETTLING表示手指离开屏幕但是尚在滚动)。

3. 长item获取焦点后上下滚动?

原因分析

一个十几页长的文本item,点击进入编辑状态时,其首选滚动到文本顶部,再滚动到点击的位置,并显示光标。通过分析代码,发现是子View获取焦点后,RecyclerView会滚动以将其显示在屏幕上。具体方法堆栈如下:

1
2
3
4
5
6
7
8
9
/**
* @see View#requestFocus()
*
* @see View#handleFocusGainInternal(int, Rect)
*
* @see RecyclerView#requestChildFocus(View, View)
*
* @see RecyclerView#requestChildOnScreen(View, View)
*/

看一下RecyclerView#requestChildOnScreen(View, View)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void requestChildOnScreen(@NonNull View child, @Nullable View focused) {
View rectView = (focused != null) ? focused : child;
mTempRect.set(0, 0, rectView.getWidth(), rectView.getHeight());

// get item decor offsets w/o refreshing. If they are invalid, there will be another
// layout pass to fix them, then it is LayoutManager's responsibility to keep focused
// View in viewport.
final ViewGroup.LayoutParams focusedLayoutParams = rectView.getLayoutParams();
if (focusedLayoutParams instanceof LayoutParams) {
// if focused child has item decors, use them. Otherwise, ignore.
final LayoutParams lp = (LayoutParams) focusedLayoutParams;
if (!lp.mInsetsDirty) {
final Rect insets = lp.mDecorInsets;
mTempRect.left -= insets.left;
mTempRect.right += insets.right;
mTempRect.top -= insets.top;
mTempRect.bottom += insets.bottom;
}
}

if (focused != null) {
offsetDescendantRectToMyCoords(focused, mTempRect);
offsetRectIntoDescendantCoords(child, mTempRect);
}

// 可以知道mTempRect实际上是整个child所在的矩形。
mLayout.requestChildRectangleOnScreen(this, child, mTempRect, !mFirstLayoutComplete,
(focused == null));
}

将滚动的请求发送给LayoutManagerLayoutManager先计算出滚动的距离,然后执行滚动。来看一下具体过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public boolean requestChildRectangleOnScreen(RecyclerView parent, View child, Rect rect,
boolean immediate,
boolean focusedChildVisible) {
// 计算需要滚动的距离
int[] scrollAmount = getChildRectangleOnScreenScrollAmount(parent, child, rect,
immediate);
int dx = scrollAmount[0];
int dy = scrollAmount[1];
if (!focusedChildVisible || isFocusedChildVisibleAfterScrolling(parent, dx, dy)) {
if (dx != 0 || dy != 0) {
// 执行滚动
if (immediate) {
parent.scrollBy(dx, dy);
} else {
parent.smoothScrollBy(dx, dy);
}
return true;
}
}
return false;
}

private int[] getChildRectangleOnScreenScrollAmount(RecyclerView parent, View child,
Rect rect, boolean immediate) {
int[] out = new int[2];
final int parentLeft = getPaddingLeft();
final int parentTop = getPaddingTop();
final int parentRight = getWidth() - getPaddingRight();
final int parentBottom = getHeight() - getPaddingBottom();
final int childLeft = child.getLeft() + rect.left - child.getScrollX();
final int childTop = child.getTop() + rect.top - child.getScrollY();
final int childRight = childLeft + rect.width();
final int childBottom = childTop + rect.height();

final int offScreenLeft = Math.min(0, childLeft - parentLeft);
final int offScreenTop = Math.min(0, childTop - parentTop);
final int offScreenRight = Math.max(0, childRight - parentRight);
final int offScreenBottom = Math.max(0, childBottom - parentBottom);

// 计算x轴方向滚动距离,与本次分析的问题关联不大。
// Favor the "start" layout direction over the end when bringing one side or the other
// of a large rect into view. If we decide to bring in end because start is already
// visible, limit the scroll such that start won't go out of bounds.
final int dx;
if (getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL) {
dx = offScreenRight != 0 ? offScreenRight
: Math.max(offScreenLeft, childRight - parentRight);
} else {
dx = offScreenLeft != 0 ? offScreenLeft
: Math.min(childLeft - parentLeft, offScreenRight);
}

// 计算y轴方向滚动距离,“优先于将child的顶部显示出来,而不是底部,如果顶部已经显示了,并且要滚动以显示底部,则需要保证顶部不会滚动到屏幕外”。
// Favor bringing the top into view over the bottom. If top is already visible and
// we should scroll to make bottom visible, make sure top does not go out of bounds.
final int dy = offScreenTop != 0 ? offScreenTop
: Math.min(childTop - parentTop, offScreenBottom);
out[0] = dx;
out[1] = dy;
return out;
}

以上就是在进入编辑模式时,长item先往上滚动的原因。而往下滚动则与第二个问题一致。当layout执行完后,TextView#onPreDraw()会计算出光标所在的矩形,并请求父View显示此矩形,因此发生了向下滚动的过程。

解决方案

当我们需要点击一个编辑项,进入编辑模式时,实际上光标位置一定不会是在屏幕在外。唯一存在的滚动需求是,当编辑项获取焦点后,输入法会弹出来,如果点击的位置在屏幕下方,就会被输入法遮挡,因此需要在重新layout之后滚动到光标位置,不存在将child顶部显示出来的需求。因此只需覆写RecyclerView.LayoutManager#requestChildRectangleOnScreen(RecyclerView, View, Rect ,boolean)方法,执行判断,如果y轴方向上滚动的距离为负数,则不滚动。