ListView 5種滑動模式解析全在這裏了


這篇文章已同步到 ListView 5種滑動模式解析全在這裏了,提供更好的閱讀體驗


前段時間在使用 ListView 的過程中,需要對一個子 Item 優化橫向 Bannar 的滑動體驗,於是藉此機會,深入瞭解了一下 ListView 滑動的一些知識,來探究一下,一個 View 滑動,究竟需要做哪些事情。

滑動模式基本介紹

ListView 的滑動模式使用變量 mTouchMode 來表示,分爲以下幾種模式:

mTouchMode 註釋 解析 備註
TOUCH_MODE_REST Indicates that we are not in the middle of a touch gesture 標識當前未處於滑動手勢中 用於重置當前滑動狀態
TOUCH_MODE_DOWN Indicates we just received the touch event and we are waiting to see if the it is a tap or a scroll gesture 標識僅僅是接到了觸摸事件,還需要進一步判斷是點按事件還是滑動手勢
TOUCH_MODE_TAP Indicates the touch has been recognized as a tap and we are now waiting to see if the touch is a longpress 標識觸摸事件已經被識別爲點按事件,還需要進一步判斷是不是長按事件
TOUCH_MODE_DONE_WAITING Indicates we have waited for everything we can wait for, but the user’s finger is still down 標識我們已經等了很久,但是用戶的手勢還是處於down狀態
TOUCH_MODE_SCROLL Indicates the touch gesture is a scroll 標識觸摸手勢是滑動事件
TOUCH_MODE_FLING Indicates the view is in the process of being flung 標識當前View是在“甩”的過程中
TOUCH_MODE_OVERSCROLL Indicates the touch gesture is an overscroll - a scroll beyond the beginning or end. 標識手勢是一個越界滑動越界滑動是指滑動超出了內容區域的首尾 這種狀態下,AbsListView 會在 DOWN 事件時攔掉事件,交給自己的 onTouchEvent 處理,事件不會繼續往子 View 傳遞
TOUCH_MODE_OVERFLING Indicates the view is being flung outside of normal content bounds and will spring back. 標識當前View被“甩”出了正常滑動區域,即將會彈回來 這種狀態下,AbsListView 會在 DOWN 事件時攔掉事件,交給自己的 onTouchEvent 處理,事件不會繼續往子 View 傳遞

ListView 滑動模式有這麼多,其實都是基於控件自身設計和交互上的考慮。我們會發現上表中出現的滑動模式,前四個模式,標識 ListView 當前處於的狀態,並沒有”滑“起來,而後四個模式,卻在實際中有對應的場景:

  • TOUCH_MODE_SCROLL:最簡單的場景,對應於 ListView 跟隨我們的手指上下滑動
  • TOUCH_MODE_FLING:列表太長時,我們想要快速瀏覽,手指快速向下(上)滑動,手機離開屏幕伴有一定的加速度
  • TOUCH_MODE_OVERSCROLL:手機滑動列表已經超出內容的可滑動區域後,還繼續滑動,表示 ListView 沒有更多的內容了
  • TOUCH_MODE_OVERFLING:Fling 之後,滑動還沒停下來的時候超出內容的滑動區域,繼續滑動的狀態,原生默認的效果是波紋動畫,也是標示 ListView 沒有更多可以滑動的內容了
  • FAST_SCROLL:除了以上的模式之外,還有一個模式 ,對應的場景是,我們拖動滑動標識器(scroll thumb)快速定位到 ListView 的某個位置,FAST_SCROLL 模式的事件處理在整個以上模式的最前邊,是獨立於這些模式的,我們後邊單獨分析

以上就是我們這次要講得 5 種滑動模式

這裏有個模式變化表,僅供參考,單次滑動屏幕,(中間不擡手指)

模式變化 說明
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_OVERSCROLL (不可能,無法直接從 DOWN->OVERSCROLL,因爲 mScrollY 必須經過 onOverScrolled 之後纔能有值)
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> 按下,長按,TAP 普通點擊,長按
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_REST 普通的滑動模式
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_OVERSCROLL-> TOUCH_MODE_OVERFLING -> TOUCH_MODE_FLING -> TOUCH_MODE_REST 初始化 -> 觸摸屏幕 -> 接觸滑動 -> 越界 -> 越界FLING -> FLING -> 重置
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_OVERSCROLL -> TOUCH_MODE_REST 初始化 -> 觸摸屏幕 -> 接觸滑動 -> 越界 -> 鬆手 -> 彈回重置
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_OVERSCROLL -> TOUCH_MODE_SCROLL -> TOUCH_MODE_REST 初始化 -> 觸摸屏幕 -> 接觸滑動 -> 越界 -> 往回滑動 -> 鬆手
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_FLING (不可能,無法直接從 DOWN->FLING,因爲 fling 需要加速度,必須要經過 move)
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_FLING -> TOUCH_MODE_OVERFLING -> TOUCH_MODE_REST 初始化 -> 觸摸屏幕 -> 接觸滑動 -> 鬆手FLING -> 越界FLING -> 停止
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_FLING -> TOUCH_MODE_OVERFLING -> TOUCH_MODE_FLING -> TOUCH_MODE_REST 初始化 -> 觸摸屏幕 -> 接觸滑動 -> 鬆手FLING -> 越界FLING -> FLING -> 停止
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_FLING -> TOUCH_MODE_REST 初始化 -> 觸摸屏幕 -> 接觸滑動 -> FLING -> 到達邊緣(當前 overScrollMode 被禁用)

從哪裏開始

滑動是與用戶連接非常密切的交互方式,而感知用戶屏幕行爲最重要的一個途徑就是 MotionEvent 事件,所以要了解 ListView 的滑動模式,我們首先就需要把目光放到 ListView 處理屏幕觸摸事件的兩個方法

android.widget.AbsListView#onInterceptTouchEvent()
android.widget.AbsListView#onTouchEvent()

ListView 繼承自 ViewGroup,所以事件分發的過程會經歷以下三個方法(並不是每次事件流都必走,順序也不是從上到下的)

android.view.ViewGroup#dispatchTouchEvent()
android.widget.AbsListView#onInterceptTouchEvent()
android.widget.AbsListView#onTouchEvent()

dispatchTouchEvent() 是 ViewGroup 專屬,負責把控整個 View 樹的整體事件分發,我們不再細說,具體可以看之前的一篇文章 觸摸事件分析

觸屏事件從底層傳上來之後,會先經過父控件的 onInterceptTouchEvent() 方法,讓父控件判斷是否要攔截事件自用,要攔截的滑交給父控件自身的 onTouchEvent(),不攔截,那就向下傳遞,我們以這個爲基本原則,詳細來看

onInterceptTouchEvent

@Override

public boolean onInterceptTouchEvent(MotionEvent ev) {

    final int actionMasked = ev.getActionMasked();
    View v;

    ......

    if (mFastScroll != null && mFastScroll.onInterceptTouchEvent(ev)) {
        return true;
    }

    switch (actionMasked) {
    case MotionEvent.ACTION_DOWN: {
        int touchMode = mTouchMode;
        if (touchMode == TOUCH_MODE_OVERFLING || touchMode == TOUCH_MODE_OVERSCROLL) { // 如果在 overfling 或者 overScroll 中,那麼直接攔截事件,交給自己的 onTouchEvent 處理
            mMotionCorrection = 0;
            return true;
        }

        ......

        mLastY = Integer.MIN_VALUE;
        initOrResetVelocityTracker();
        mVelocityTracker.addMovement(ev); //加速度追蹤者

        ......

        if (touchMode == TOUCH_MODE_FLING) {
            return true;
        }
        break;

    }

    case MotionEvent.ACTION_MOVE: {
        switch (mTouchMode) {
        case TOUCH_MODE_DOWN:

            ......

            final int y = (int) ev.getY(pointerIndex);
            initVelocityTrackerIfNotExists();
            mVelocityTracker.addMovement(ev);
            if (startScrollIfNeeded((int) ev.getX(pointerIndex), y, null)) { // 是否要開始 overScrollBy 的滑動
                return true;
            }
            break;
        }
        break;
    }

    case MotionEvent.ACTION_CANCEL:
    case MotionEvent.ACTION_UP: {
        mTouchMode = TOUCH_MODE_REST;
        mActivePointerId = INVALID_POINTER;
        recycleVelocityTracker();

        ......

        break;
    }

    case MotionEvent.ACTION_POINTER_UP: {

        ......

        break;
    }
    }
    return false;
}

快速滑動的判定更早於其他模式,在 onInterceptTouchEvent() 一開始就處理過了,具體細節我們後續再說

在 ACTION_DOWN 中,OVERFLING,OVERFLING,OVERFLING 這三種情況下,會將接下來的事件直接轉交給 ListView 自身,而其他情況下,對是否抵達這三種情況的判定其實是在 ACTION_MOVE 中,也就是說 ListView 並沒有在事件一開始就接管,而是通過一段時間的滑動之後才做出了進入滑動模式的判定,從而基於這個判定來接管後續的事件

從 ACTION_DOWN 的代碼中,我們可以總結兩點:

  1. 在已經進入 OVERFLING,OVERFLING,OVERFLING 三個模式後,後續發生的事件流,都會先被 ListView 自身所接管處理,也就是說在 ListView 還在滑動的過程中的時候,輕點一下屏幕,並不會觸發子 View 的點擊事件,而是 ListView 自己去處理,這個並不難理解,觸發滾動之後,後續的第一個事件自然應該是先停止滾動,然後在靜止的情況下讓用戶準確的選中要處理的子 Item

  2. 在將要觸發滑動的第一個事件流來的時候,ListView 並沒有立刻接管這個事件流,而是“觀察”了一段 ACTION_MOVE 事件之後,纔開始接管,所以對於進入滑動的時機,是在 ACTION_MOVE 中判定的。

每組事件的開頭 ACTION_DOWN 被父控件攔截之後,這個事件流的後續事件都會被父控件接管嗎?子控件還有機會獲得使用嗎?
答:有機會,調用 requestDisallowInterceptTouchEvent() 就可以,但是因爲 touch 事件已經被父 View 接管了,所以在觸屏事件流的過程中,是沒有機會的,只能通過其他時機觸發

onTouchEvent

@Override

public boolean onTouchEvent(MotionEvent ev) {

    ......

    if (mFastScroll != null && mFastScroll.onTouchEvent(ev)) {
        return true;
    }

    initVelocityTrackerIfNotExists();
    final MotionEvent vtev = MotionEvent.obtain(ev);
    final int actionMasked = ev.getActionMasked();

    ......

    switch (actionMasked) {
        case MotionEvent.ACTION_DOWN: {
            onTouchDown(ev);
            break;
        }

        case MotionEvent.ACTION_MOVE: {
            onTouchMove(ev, vtev);
            break;
        }

        case MotionEvent.ACTION_UP: {
            onTouchUp(ev);
            break;
        }

        case MotionEvent.ACTION_CANCEL: {
            onTouchCancel();
            break;
        }

        ......

    }

    if (mVelocityTracker != null) {
        mVelocityTracker.addMovement(vtev);
    }

    vtev.recycle();
    return true;

}

好了,定位到滑動的入口,接下來我們就簡單介紹下這幾個滑動模式的整體流程
事件首先會到達 android.widget.AbsListView#onInterceptTouchEvent,我們先看攔截的機制

  1. 手指放到屏幕上,ACTION_DOWN ,mTouchMode 被初始化爲 TOUCH_MODE_REST,標誌着 touchMode 的重新計算,並重置了上次滑動的位置 mLastY

  2. 手指開始移動,ACTION_MOVE 事件到達,這裏會時刻監控 startScrollIfNeeded() 的返回值,來決定要不要攔截事件,轉交給自身的 onTouchEvent() 處理,

    攔截的觸發條件有兩個:

    • 是否超出 View 的邊緣(mScrollY !=0),超過表示 overScroll() 模式
    • 有沒有超過 android 設定的誤觸值 mTouchSlop,超過並且不是 overScoll 模式,那麼就是滑動模式 scroll 了,如果判定了滑動模式,要清除一些子 Item 的按下狀態,還要阻止父控件攔截事件,全部交給自己來處理了(有點霸道。。),最後就根據滑動的值來改變 ListView 自身的狀態了
  3. 我們假定,已經觸發了滑動或者越界滑動的條件,實際滾動的過程需要區分這二者

所謂的超過 View 的邊緣是什麼意思?
答:View 的滾動可以分爲兩個部分,一個是內容的滾動,一個是 View 自身的滾動,ListView 中 item 的滾動就是屬於內容的滾動,而越界的滾動屬於 ListView 自身的滾動。

進入各個模式之前,我們需要明確一點,ListView 在每次滑動的時候,都會記錄手指按下時候的位置 mMotionY,手指從上次按下位置開始到當前點滑動的距離 deltaY,以及手指自上次之後滑動的距離 incrementalDeltaY,以及當前 View 所滑動的距離 mScrollY(整個 View 滑動的距離,而不是 View 的內容)

這四個變量會在下邊的四個滑動模式中被反覆使用。

5種模式

SCROLL

exPXLt.md.png

滑動模式我們需要讓 ListView 的內容滾動起來,這裏 Android 是在 trackMotionScroll() 中處理的,這個處理的過程區分是否到達 ListView 的開頭和末尾,如果滿足條件的話,那麼馬上就要切換到越界滾動模式,反之,就要保證讓 ListView 的內容滾動起來,因爲 ListView 有 View 複用的邏輯,所以在舊的 View 滑出屏幕之後要加入回收池,新的 View 進入屏幕時要立刻綁定數據或者重新創建,
子 item 的滾動是通過對每個 item 使用 offsetChildrenTopAndBottom() 來實現的,這個函數之後會調用 invalidate() 來刷新 ListView 內容

那麼問題又來了,屏幕滾動,一部分子 item 向上(下)滾動,那麼 ListView 空出來的地方怎麼把新的 View 填充上去,答案是 fillGap(),這個函數會根據向上還是向下滾動,來決定 fillUp() 還是 fillDown(),這裏會根據一定的條件決定新建還是複用子 Item,如果是創建的話,還需要使用 setupChild() 初始化這個新的子 Item,來將 item 放到合適的位置上

SCROLL 模式的滑動模型是手指移動的距離就是 ListView 內容滾動的區域,所以在進入滑動模式之後,手指每次移動的距離 incrementalDeltaY 就是本次 listView 需要“消費”的距離,消費完成,ListView 就停止滾動

OVERSCOLL

exPveP.md.jpg

越界滾動是在 ListView 滑動子 Item 到達頂部或者底部的時候觸發,在之前的滾動模式中我們在判定 ListView 到達上下邊緣時就看到了觸發的條件,我們接着上邊的分支看

判定滿足了越界滾動模式的條件,那麼就會通過 overScrollBy() 來梳理計算出越界滾動的一些數據(注意,這個 overScrollBy() 是 View 的能力),將數據通過交給使用者自己處理,我們可以在 onOverScrolled() 中拿到這些數據決定如何處理,ListView 這裏只是設定了 mScrollY 的值,並通過回調通知使用者越界的具體參數,如果想實現彈性效果,就可以在複寫 onOverScrolled() 來處理

這裏插一句閒話,據說在 IOS 出現越界彈性效果之後,Android 也跟進了提供 onOverScrolled() 方式,但是卻沒有提供具體實現,可能是因爲擔心承擔法律責任

最後,Android 默認會根據設定的 overScrollMode 來決定如何提示滾動越界了,android5.0 之後的越界會有波紋效果,就是從這裏顯示出來的,這裏通過一個 EdgeEffect 的類來處理越界滾動的效果

至於 overScroll 模式如何停下來,我們在 fling 模式的後邊細說,因爲 overScroll 模式和 fling 模式的停止都使用了動畫。

什麼是未被消費的滾動距離
答:我們手指離開屏幕之後的加速度所計算得出的距離就是總的滾動距離,後續 View 自動滾動需要消費這些距離,直到消費完成

overScrollMode

先說明一下,overScroll 也有三種模式,用戶可以設置的模式,根據這三種模式,越界滾動的效果也不一樣

scrollMode 註釋 解析 備註
OVER_SCROLL_ALWAYS Always allow a user to over-scroll this view, provided it is a view that can scroll. 總是允許越界滾動
OVER_SCROLL_IF_CONTENT_SCROLLS Allow a user to over-scroll this view only if the content is large enough to meaningfully scroll, provided it is a view that can scroll. 只有在內容足夠大時,來清晰的表示滑動狀態時,才允許滑動
OVER_SCROLL_NEVER Never allow a user to over-scroll this view. 總是禁止

看到這裏不要忘記我們是在 onInterceptTouchEvent() 方法中,事件在這裏攔截後,會轉交給 onTouchEvent() 處理

所以在上文3的觸發條件滿足之後,我們後續的事件要接受 onTouchEvent() 的進一步處理
從我們在上邊的分析中,在 ListView 從靜止到觸發滑動之前,事件並沒有被 ListView 攔截掉,那麼這些事件會被派發給子 Item,這時候子 Item 可以根據這個 Down 事件來處理按下的狀態,

如果子 Item 這個時候在自己的事件處理中返回 true,接管了這個事件流的後續,那麼後邊是什麼樣的流程了?
答:那麼事件將會直接交給子 item 處理,這個事件流週期內,ListView 自身是無法再接受到事件。

如果子 item 沒有攔截這個事件,那麼 listView 會在 onTouchDown() 中簡單處理一下狀態,併發送延遲消息來判定是否要觸發長按事件。
我們接着來看滑動事件觸發之後,事件會交給 ListView 自身處理,這時候 MOVE 事件會在 onTouchMove() 中處理,同樣,這裏也使用 scrollIfNeed() 來處理滾動和越界滾動的效果

這裏爲啥要多加一層,在onInterceptTouchEvent 中處理的不夠嗎?
答:因爲 intercept 只是起到第一次攔截的作用,intercept 攔截之後,後續事件不會再次經過 intercept,只會直接交給 touchEvent,所以這裏需要繼續後邊滑動的處理

FLING & OVER_FLING

exPxdf.md.jpg

在 ACTION_DOWN 和 ACTION_MOVE 方法處理中這兩種模式並沒有什麼不同,不同之處在於鬆手之後,也就是 ACTION_UP 事件傳過來,
ACTION_UP 事件傳過來之後,我們會使用之前在 ACTION_DOWN 和 ACTION_MOVE 中監控的 velocityTracker 事件來分析手指離開屏幕的加速度,如果分析得到加速度大於 mMinimumVelocity ,那麼會觸發進入 TOUCH_MODE_FLING 模式,這個模式下的處理交給了 FlingRunnable,這個 Runnable 內置了一個 OverScroller 會按需處理 FLING,OverFling,SpringBack(回彈)這三種狀態

舉例來說,在列表足夠長時,Fling 一段距離之後會停下來,而列表短時,可能會觸發 OverFling,然後再觸發 SpringBack 效果,這裏不再細談,只需要知道 Fling 和 OverFling 是在這個 FlingRunnable 中處理就 OK,後續需要的時候再回頭細看即可

注:FlingRunnable 的運行模式
FlingRunnable 通過 postOnAnimation 把自身加入到 Choreographer 的回調隊列中,在每次 VSYNC 事件的時候都會回調FlingRunnable 的 run 方法,在 run 方法中決定是否要繼續監聽下一次 Choreographer 的回調,來實現持續滑動的效果

VSYNC 相關內容可以參考以下文章:
爲什麼是VSYNC

Choreographer 可以參考以下文章:
invalidate 三部曲序
invalidate 三部曲之始於 invalidate
invalidate 三部曲之歷經 Choreographer

FAST_SCROLL

說完 scroll,overScrol,fling,overFling,這四種模式之後,我們最後看一下 fastScroll 的一些內容
顧名思義,fastScroll,快速滾動,這個滾動不是通過滑動 listView 的內容區域來觸發的,而是通過 fast scroll thumb 這個控件來觸發的,事件處理也是獨立於 ListView 的,單獨放到了 FastScroller 中處理,我們可以通過設置來開關這個屬性,很多人應該都用過,拖動 scrollbar,然後下拉,就可以達到這種效果

因爲這個事件是在一個單獨的控件中觸發的,所以事件處理也是完全獨立 ListView 的,感興趣的翻看 FastScroller 代碼

至此,我們對 ListView 的5個滑動模式就分析到這裏,理清楚了大部分的流程之後,我們遇到 ListView 相關的問題,就可以迅速從某個入口介入。

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