RecyclerView嵌套或者ScrollView嵌套,包含EditText,EditText獲取焦點時滑動異常問題解決記錄

最近在做公司項目的Android適配工作,將support依賴都升級到了28.0.0,很多問題撲面而來,最讓我苦惱的就是RecyclerView嵌套RecyclerView時,item中的EditText獲取焦點時,橫向滑動的RecyclerView會自動滾動到最前面,我依稀記得在原來遇到過,同樣是升級了RecyclerView的依賴版本後出現,上一次的解決方式是把版本又降回去,但是這樣治標不治本,趁着這次把病根給它解決掉。

首先用 Gif 圖展示下升級RecyclerView版本之後產生的問題(RecyclerView版本爲28.0.0):
28.0.0版本
正常的情況應該如下(此RecyclerView版本爲25.2.0):
25.2.0版本
因爲RecyclerView版本不同產生的問題,當然先從RecyclerView開始查起,項目中使用這種嵌套時做了封裝,並且爲了更好的處理滑動衝突,進行了繼承,我就從這個類中找到了重寫的一個方法:

@Override
public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) {
    boolean result = false;
    if (child instanceof LinearLayout) {
        for (int i = 0; i < ((LinearLayout) child).getChildCount(); i++) {
            View childView = ((LinearLayout) child).getChildAt(i);
            if (childView instanceof EditText) {
                result = true;
                break;
            }
        }
    }

    rectangle.top = 0;
    rectangle.bottom = 0;
    rectangle.left = 0;
    rectangle.right = 0;

    return result;
}

我將這個方法進行度娘,找到了一個文章中對官方文檔的翻譯:
翻譯
通過翻譯,我基本確定了問題就是由這個方法引起的,於是我將這個方法註釋掉,再運行起來後滑動到最左邊的問題是解決了,但是並沒有像正常情況那樣將EditText頂起,而是讓EditText定位在屏幕的最右邊。
沒關係,至少咱成功了一半,EditText還能看得見。
我通過這個方法找到super的實現,也就是RecyclerView的源碼實現:

public boolean requestChildRectangleOnScreen(View child, Rect rect, boolean immediate) {
    return this.mLayout.requestChildRectangleOnScreen(this, child, rect, immediate);
}

RecyclerView本身是調用了mLayout的 requestChildRectangleOnScreen ,找到這個mLayout的定義爲 RecyclerView.LayoutManager mLayout;,緊接着看這個mLayout賦值的地方:(以下省略部分源碼)

public void setLayoutManager(@Nullable RecyclerView.LayoutManager layout) {
    if (layout != this.mLayout) {
       //省略部分代碼
        this.mChildHelper.removeAllViewsUnfiltered();
        //這裏進行賦值操作
        this.mLayout = layout;
        if (layout != null) {
            if (layout.mRecyclerView != null) {
                throw new IllegalArgumentException("LayoutManager " + layout + " is already attached to a RecyclerView:" + layout.mRecyclerView.exceptionLabel());
            }

            this.mLayout.setRecyclerView(this);
            if (this.mIsAttached) {
                this.mLayout.dispatchAttachedToWindow(this);
            }
        }

        this.mRecycler.updateViewCacheSize();
        this.requestLayout();
    }
}

RecyclerView的源碼跟到這可以知道問題的解決應該要看 LayoutManage.requestChildRectangleOnScreen 了,但是這裏我就有一個疑問,我自己的類重寫了RecyclerView的 requestChildRectangleOnScreen 方法,而且重寫這個方法並沒有調用super,那麼就不應該會產生這個問題?難道RecyclerView中還有別的地方調用了LayoutManage.requestChildRectangleOnScreen 方法麼?搜索一下,發現確實如此,在RecyclerView的源碼中,有一個私有方法調用了(注意,這裏調用的是5個參數的 requestChildRectangleOnScreen 方法):

private void requestChildOnScreen(@NonNull View child, @Nullable View focused) {
    //省略部分源碼
    this.mLayout.requestChildRectangleOnScreen(this, child, this.mTempRect, !this.mFirstLayoutComplete, focused == null);
}

而這個方法又由其中的另一個方法調用:

public void requestChildFocus(View child, View focused) {
    if (!this.mLayout.onRequestChildFocus(this, this.mState, child, focused) && focused != null) {
        this.requestChildOnScreen(child, focused);
    }

    super.requestChildFocus(child, focused);
}

從方法名可以知道,這個方法的調用時機是在請求Child獲取焦點的時候,這也驗證了我們問題的產生是由我們點擊EditText時引起的,我感覺我離真相很近了,後面的源碼我們就看LayoutManage。
直接看LayoutManage中的 requestChildRectangleOnScreen 這個方法:

public boolean requestChildRectangleOnScreen(@NonNull RecyclerView parent, @NonNull View child, @NonNull Rect rect, boolean immediate) {
    return this.requestChildRectangleOnScreen(parent, child, rect, immediate, false);
}

public boolean requestChildRectangleOnScreen(@NonNull RecyclerView parent, @NonNull View child, @NonNull Rect rect, boolean immediate, boolean focusedChildVisible) {
    int[] scrollAmount = this.getChildRectangleOnScreenScrollAmount(parent, child, rect, immediate);
    int dx = scrollAmount[0];
    int dy = scrollAmount[1];
    if ((!focusedChildVisible || this.isFocusedChildVisibleAfterScrolling(parent, dx, dy)) && (dx != 0 || dy != 0)) {
        if (immediate) {
            parent.scrollBy(dx, dy);
        } else {
            parent.smoothScrollBy(dx, dy);
        }

        return true;
    } else {
        return false;
    }
}

可以看到有兩個重載的方法,而第一個重載的方法調用了5個參數的重載方法,而5個參數的方法中有一段判斷邏輯:

if ((!focusedChildVisible || this.isFocusedChildVisibleAfterScrolling(parent, dx, dy)) && (dx != 0 || dy != 0)) {
   if (immediate) {
       parent.scrollBy(dx, dy);
   } else {
       parent.smoothScrollBy(dx, dy);
   }

   return true;
} else {
   return false;
}

明顯看到了其中有兩個滑動的操作,那麼到底是不是這個引起的呢?用斷點debug看一下:
Debug
命中了5個參數的方法,並且進入了判斷邏輯,我們梳理一下幾個重要的參數:

immediate = false;
focusedChildVisible = false;
dx = -522852;
dy = 0;

第一個if判斷由於 !focusedChildVisible 成立並且 (dx != 0 || dy != 0) 成立,所以命中if代碼塊,緊接着第二個if判斷中 immediate 爲false,所以命中第二個判斷的else代碼塊,也就是 parent.smoothScrollBy(dx, dy);dx = -522852;dy = 0; ,所以會x方向滑動到最左側,而y方向沒有滑動,這正好印證了第一張Gif圖的情況。
因此,得出的解決方案爲繼承LayoutManage,複寫 requestChildRectangleOnScreen 5個參數的方法,因爲4個參數的方法源碼中也是調用的5個參數的,代碼如下:


解決方案:重寫LayoutManage的 requestChildRectangleOnScreen() 方法,如果是ScrollerView,則重寫ScrollerView的該方法

class FixChildScrollBugLinearLayoutManager(context: Context, orientation: Int, reverseLayout: Boolean) : LinearLayoutManager(context, orientation, reverseLayout) {
    override fun requestChildRectangleOnScreen(parent: RecyclerView, child: View, rect: Rect, immediate: Boolean, focusedChildVisible: Boolean): Boolean {
        return false
    }
}

事情的發展到了這裏並沒有結束,還記得是因爲RecyclerView版本升級導致的問題麼?爲什麼呢?肯定是兩個版本的代碼不一致,有疑問就要去驗證,於是我查看了25.2.0Recycler.LayoutManage關於 requestChildRectangleOnScreen 這個方法的代碼,發現這個方法在這個版本只有4個參數的,並且其中的邏輯也大不相同:

public boolean requestChildRectangleOnScreen(RecyclerView parent, View child, Rect rect,
                boolean immediate) {
    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);

    // 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);
    }

    // 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);

    if (dx != 0 || dy != 0) {
        if (immediate) {
            parent.scrollBy(dx, dy);
        } else {
            parent.smoothScrollBy(dx, dy);
        }
        return true;
    }
    return false;
}

一切都明白了!

致謝:
農民伯伯 - Android 中文 API (100) —— ScrollView

由於本人水平有限,本篇文章並沒有對源碼中涉及到的細節進行逐一解讀,主要是分享自己發現問題、定位問題、解決問題的思路,僅此而已

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章