Android嵌套滑動的分析與實踐

【一】傳統事件分發

1.1 傳統事件分發流程

Activity:

public boolean dispatchTouchEvent(MotionEvent event)

public boolean onTouchEvent(MotionEvent event) 

ViewGroup:

public boolean dispatchTouchEvent(MotionEvent event)

public boolean onInterceptTouchEvent(MotionEvent ev)

public boolean onTouchEvent(MotionEvent event) 

View:

public boolean dispatchTouchEvent(MotionEvent event)

public boolean onTouchEvent(MotionEvent event) 

僞代碼表示dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent三者之間的關係:

public boolean dispatchTouchEvent (MotionEvent ev){
    boolean consume = false;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    } else {
        consume = child.dispatchTouchEvent(ev);
    }
    return consume;
}

用圖來表示事件傳遞的過程:

事件傳遞.png

1.2 傳統事件滑動衝突

來看ViewGroup的分發(PS:本文中的源碼是基於Android API 24分析的~):

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

------other code------

// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        intercepted = false;
    }
} else {
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
}
------other code------
handled = super.dispatchTouchEvent(transformedEvent);
}

當ViewGroup不攔截事件並由ViewGroup的子View處理事件時,mFirstTouchTarget會被賦值並指向子View,上面的代碼可以分成下面幾種情況:
1、如果MotionEvent.ACTION_DOWN事件被ViewGroup攔截,那麼mFirstTouchTarget==null,那麼後續的ACTION_MOVE、ACTION_UP事件都不會往子View中傳遞了,而會走ViewGroup的onTouchEvent方法。

2、當mFirstTouchTarget != null時,即子View處理後續ACTION_MOVE、ACTION_UP事件時,子View中可以通過設置getParent().requestDisallowInterceptTouchEvent(true),該設置會影響上述代碼中的disallowIntercept變量,從而使ViewGroup不攔截事件,將事件傳遞到子View中去

下面分析一下當遇到滑動衝突的時候一般的解決方法:

1.2.1外部攔截法

外部攔截法即在父ViewGroup的onInterceptTouchEvent中去做處理,僞代碼如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean isIntercept = false;
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            isIntercept = false;
            break;
        case MotionEvent.ACTION_MOVE:
            if (ViewGroup攔截ACTION_MOVE條件) {
                isIntercept = true;
            } else {
                isIntercept = false;
            }
            break;
        case MotionEvent.ACTION_UP:
            isIntercept = false;
            break;
    }
    return isIntercept;
}

這裏注意除非父ViewGroup要處理所有事件,否則一定不能攔截ACTION_DOWN,因爲一旦攔截了ACTION_DOWN事件,後續的MOVE、UP事件將都不能傳遞到子View了

1.2.2內部攔截法

內部攔截法是指父ViewGroup不攔截任何事件,所有事件都傳遞到子View中,通過getParent().requestDisallowInterceptTouchEvent(boolean)來控制後續事件讓不讓父ViewGroup去攔截。來看requestDisallowInterceptTouchEvent的源碼:

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        // We're already in this state, assume our ancestors are too
        return;
    }

    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}

主要是修改標誌位mGroupFlags,這個標誌位也是在父ViewGroup的dispatchTouchEvent中控制是否走onInterceptTouchEvent()攔截方法的,disallowIntercept爲true時,父ViewGroup不會再走onInterceptTouchEvent()攔截事件;反之會走onInterceptTouchEvent()方法,默認是false。

內部攔截法的使用舉例:

在子View的dispatchTouchEvent中:

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if (父ViewGroup需要攔截並處理事件) {
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
    return super.dispatchTouchEvent(event);
}

在父ViewGroup的onInterceptTouchEvent中:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    return ev.getAction() != MotionEvent.ACTION_DOWN;
}

內部攔截法同樣不能攔截ACTION_DOWN事件,否則事件不能傳遞到子View中。

1.3 傳統嵌套滑動衝突

在開始介紹滑動衝突之前,先介紹一下測量規格MeasureSpecMeasureSpec參與了View的測量過程,子View的MeasureSpec的創建是由父View的MeasureSpec和子View自身的LayoutParams共同決定的,MeasureSpec的組成:

MeasureSpec是一個32位的int值,高2位是specMode,低30位是specSize,如下:

public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size, @MeasureSpecMode int mode) {
    if (sUseBrokenMakeMeasureSpec) {
        return size + mode;
    } else {
        return (size & ~MODE_MASK) | (mode & MODE_MASK);
    }
}

SpecMode是測量模式,SpecSize是在某種測量模式下的測量大小,SpecMode有三個值:

USPECIFIED: 父View對子View沒有任何限制。

EXACTLY: 父View已經檢測出View的精確大小,View的最終大小就是SpecSize指定的值。它對應於LayoutParams中的match_parent和具體數值這兩種模式。

AT_MOST:父View指定了一個可用大小SpecSize,View的大小不能大於這個值,具體是什麼值要看不同View的具體實現。它對應於LayoutParams中的wrap_content。

1.3.1 ScrollView+ ListView嵌套衝突

默認ListView中的onMeasure方法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // Sets up mListPadding
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    -----其他代碼-----
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    if (heightMode == MeasureSpec.UNSPECIFIED) {
        heightSize = mListPadding.top + mListPadding.bottom +    childHeight + getVerticalFadingEdgeLength() * 2;
    }

    if (heightMode == MeasureSpec.AT_MOST) {
        heightSize = measureHeightOfChildren(widthMeasureSpec, 0,    NO_POSITION, heightSize, -1);
    }
 }

通過源碼我們知道當測量模式是UNSPECIFIED時,高度只是一個item的高度(包括上下的padding);當測量模式是AT_MOST時,高度是所有item的高度,即整個listview的高度。通過測試發現ScrollView和ListView嵌套使用時,傳給ListView的測量模式是UNSPECIFIED,所以只能顯示一個Item的高度,那怎麼顯示整個ListView的高度呢?通過上面的分析我們已經知道答案了:重寫ListView的onMeasure方法!

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int heightSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
    super.onMeasure(widthMeasureSpec, heightSpec);
}

放棄通過父View及自身LayoutParams生成的MeasureSpec(specMode爲UNSPECIFIED),重新生成一個specMode爲AT_MOST的MeasureSpec即可。

1.3.2 ScrollView+ ViewPager嵌套問題

來看ViewPager的onMeasure源碼:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // For simple implementation, our internal size is always 0.
    // We depend on the container to specify the layout size of
    // our view.  We can't really know what it is since we will be
    // adding and removing different arbitrary views and do not
    // want the layout to change as this happens.
    setMeasuredDimension(getDefaultSize(0, widthMeasureSpec),
            getDefaultSize(0, heightMeasureSpec));
}

public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);

    switch (specMode) {
    case MeasureSpec.UNSPECIFIED:
        result = size;
        break;
    case MeasureSpec.AT_MOST:
    case MeasureSpec.EXACTLY:
        result = specSize;
        break;
    }
    return result;
}

通過分析源碼我們知道:

測量模式specMode是UNSPECIFIED時,ViewPager的高度是0;
測量模式specMode是AT_MOSTEXACTLY時,ViewPager的高度直接取的父View傳入的值。

通過測試發現當ScrollView和ViewPager嵌套使用時,測量模式specMode是UNSPECIFIED,所以默認高度是0.

【二】 傳統事件分發 VS NestedScrolling

  • 傳統事件分發:子View處理Touch事件時,父View可以進行攔截並在父View中處理,但是一旦父View進行攔截,後續事件都不會再往子View中傳遞了。
  • NestedScrolling:子View在滾動的時候,首先將dx、dy交給NestedScrollingParent進行消耗,剩餘部分還給子View。

【三】NestedScrolling嵌套滑動

從Android5.0開始引入了NestedScrolling機制(5.0之前可以用Support V4包向前兼容),用來處理子View和父View嵌套滑動時的交互機制。子View一般是可以滑動的View並且需要實現 NestedScrollingChild 接口,父View需要實現NestedScrollingParent接口。

2.1 NestedScrollingChild

public interface NestedScrollingChild {
   
    public void setNestedScrollingEnabled(boolean enabled);

    public boolean isNestedScrollingEnabled();

    public boolean startNestedScroll(int axes);

    public void stopNestedScroll();

    public boolean hasNestedScrollingParent();

    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);

    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);

    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    public boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

2.2 NestedScrollingParent

public interface NestedScrollingParent {
   
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);

    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

    public void onStopNestedScroll(View target);

    public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

    public boolean onNestedPreFling(View target, float velocityX, float velocityY);

    public int getNestedScrollAxes();
}

2.3 兩者之間的關係

NestedScroll.png

一、startNestedScroll

首先子view需要開啓整個流程(內部主要是找到合適的能接受nestedScroll的parent),通知父View,我要和你配合處理TouchEvent

二、dispatchNestedPreScroll

在子View的onInterceptTouchEvent或者onTouch中(一般在 MontionEvent.ACTION_MOVE事件裏),調用該方法通知父View滑動的距離。該方法的第三第四個參數返回父view消費掉的 scroll長度和子View的窗體偏移量。如果這個scroll沒有被消費完,則子view進行處理剩下的一些距離,由於窗體進行了移動,如果你記錄了手指最後的位置,需要根據第四個參數offsetInWindow計算偏移量,才能保證下一次的touch事件的計算是正確的。
如果父view接受了它的滾動參數,進行了部分消費,則這個函數返回true,否則爲false。
這個函數一般在子view處理scroll前調用。

三、dispatchNestedScroll

向父view彙報滾動情況,包括子view消費的部分和子view沒有消費的部分。
如果父view接受了它的滾動參數,進行了部分消費,則這個函數返回true,否則爲false。
這個函數一般在子view處理scroll後調用。

四、stopNestedScroll

結束整個流程。

更詳細見:
http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0603/2990.html

2.4 二個NestedScrolling 嵌套滑動例子

直接見github吧:
https://github.com/crazyqiang/AndroidStudy

或者見鴻神的博客:
https://blog.csdn.net/lmj623565791/article/details/52204039

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