背景介绍
使用RecyclerView
实现富文本编辑器,包含三种ItemType
,分别是Text
、Todo
和Image
。由于输入需要,每种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()
会给决定能否呼出上下文操作菜单的变量mInsertionControllerEnabled
、mSelectionControllerEnabled
赋值。具体如下:
1 | void prepareCursorControllers() { |
解决方案
在Item添加到RecyclerView中之后,再次调用Editor#prepareCursorControllers()
方法。
因为该方法是包私有(package-private
)方法,因此需要使用反射来调用。为了避免多次查找Field
、Method
的消耗,可以使用成员变量保存反射过程中找到的Field
和Method
。
2. 输入框请求焦点导致滚动自动停止?
原因分析
当一个拥有焦点的position
离开并再次进入屏幕时,首先会为该position
对应的ItemView
请求焦点,接着会进行Layout,发生以下方法堆栈:
1 | /** |
看一下TextView#bringPointIntoView()
方法:
1 | public boolean bringPointIntoView(int offset) { |
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
29private 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));
}
将滚动的请求发送给LayoutManager
,LayoutManager
先计算出滚动的距离,然后执行滚动。来看一下具体过程:
1 | public boolean requestChildRectangleOnScreen(RecyclerView parent, View child, Rect rect, |
以上就是在进入编辑模式时,长item先往上滚动的原因。而往下滚动则与第二个问题一致。当layout
执行完后,TextView#onPreDraw()
会计算出光标所在的矩形,并请求父View显示此矩形,因此发生了向下滚动的过程。
解决方案
当我们需要点击一个编辑项,进入编辑模式时,实际上光标位置一定不会是在屏幕在外。唯一存在的滚动需求是,当编辑项获取焦点后,输入法会弹出来,如果点击的位置在屏幕下方,就会被输入法遮挡,因此需要在重新layout
之后滚动到光标位置,不存在将child顶部显示出来的需求。因此只需覆写RecyclerView.LayoutManager#requestChildRectangleOnScreen(RecyclerView, View, Rect ,boolean)
方法,执行判断,如果y轴方向上滚动的距离为负数,则不滚动。