一種非巢狀滑動衝突的解決方案

SCNUzy發表於2019-02-21

非巢狀滑動 | 巢狀滑動

Android 系統的觸控事件分發總是從父佈局開始分發,從最頂層的子 View 開始處理,這種特性有時候會限制了我們一些很複雜的互動設計。

TouchEventBus 致力於解決非巢狀的滑動衝突,比如多個 在同一層級Fragment 對觸控事件的處理:觸控事件會先到達頂層 FragmentonTouch 方法,然後逐層判斷是否消費,在都不消費的情況下才到達底層的 Fragment 。而且這些層級互不巢狀,沒有形成 parent 和 child 的關係,意味著想通過 onInterceptTouchEvent() 或者 requestDisallowInterceptTouchEvent() 方法來調整事件分發都是不可能的。

同級檢視的觸控事件

下面是手機YY的開播預覽頁:

YY預覽頁

在這個頁面上有很多對觸控事件的處理,包括且不限於:

  • 在螢幕上點選,會觸發攝像頭的聚焦(黃色框出現的地方)
  • 雙指縮放,會觸發攝像頭的縮放
  • 左右滑動,可以切換 ViewPager ,從“直播”和“玩遊戲”兩個選項卡之間切換
  • “玩遊戲”選項卡上的列表可以滑動
  • “直播”選項卡上的控制元件可以點選(開播按鈕,新增圖片…)
  • 由於預覽頁和開播頁是同一個 Activity ,所以這個 Activity 上還有很多開播後的 Fragment,比如公屏等等也有觸控事件

從視覺上可以判斷出View Tree的層級以及對觸控處理的層級:

處理順序

圖左側是 UI 的層級,上層是一些按鈕控制元件和 ViewPager ,下層是視訊流展示的 Fragment。右邊是觸控事件處理的層級,雙指縮放/View點選/聚焦點選需要在 ViewPager上面,否則都會被 ViewPager 消費掉,但是 ViewPager 的 UI 層級又比視訊的 Fragment 要高。這就是非巢狀的滑動衝突的核心矛盾:

業務邏輯的層級使用者看到的UI層級 不一致

對觸控事件的重新分發

手機YY直播間中的 Fragment 非常多,而且因為外掛化的原因,各個業務外掛可以動態地往直播間新增/移除自己業務的 Fragment ,這些 Fragment 層級相同互不巢狀,有自己比較獨立的業務邏輯,也會有點選/滑動等事件處理的需求。但由於業務場景複雜,Fragment 的上下層級順序也會動態改變,這就很容易導致一些 Fragment 一直收不到觸控事件或者在切換業務模板的時候觸控事件被其他業務消費。

TouchEventBus 用於這種場景下對觸控事件進行重新分發,我們可以隨心所欲地決定業務邏輯的層級順序。

TouchEventBus重新分發觸控事件

每個手勢的處理就是一個 TouchEventHandler,比如鏡頭的縮放是 CameraZoomHandler ,鏡頭的聚焦點選是 CameraClickHandlerViewPager 滑動是 PreviewSlideHandler ,然後為這些 Handler 重新排序,按照業務的需要來傳遞 MotionEvent 。然後是 TouchEventHandler 和ui的對應關係:通過Handler的 attach / dettach 方法來繫結/解綁對應的 ui 。而 ui 可以是一個具體的 Fragment,也可以是一個抽象的介面,一個對觸控事件作出響應的業務。

比如開播預覽頁的聚焦點選處理,先是定義ui的介面:

public interface CameraClickView {
    /**
     * 在指定位置為中心顯示一個黃色矩形的聚焦框
     *
     * @param x 手指觸控座標x
     * @param y 手指觸控座標y
     */
    void showVideoClickFocus(float x, float y);

    /**
     * 給VideoSdk傳遞觸控事件,讓其在指定座標進行攝像頭聚焦
     *
     * @param e 觸控事件
     */
    void onTouch(MotionEvent e);
}
複製程式碼

然後是 TouchEventHandler 的定義:

public class CameraClickHandler extends TouchEventHandler<CameraClickView> {
    
    private boolean performClick = false;
    //...
    
    @Override
    public boolean onTouch(@NonNull CameraClickView v, MotionEvent e, boolean hasBeenIntercepted) {
        super.onTouch(v, e, hasBeenIntercepted);
        if (!isCameraFocusEnable()) { //一些特殊業務需要禁止攝像頭聚焦
            return false;
        }
        //通過MotionEvent判斷performClick是否為true
        switch (e.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //...
                break;
            case MotionEvent.ACTION_MOVE:
                //...
                break;
            case MotionEvent.ACTION_UP: 
                //...
                break;
            default:
                break;
        }

        if (performClick) { //認為是點選行為,呼叫ui的介面
            v.showVideoClickFocus(e.getRawX(), e.getRawY());
            v.onTouch(e);
        }
        return performClick; //點選的時候消費掉觸控事件
    }
}
複製程式碼

最後是 TouchEventHandler 與 ui 的對應的繫結

public class MobileLiveVideoComponent extends Fragment implements CameraClickView{
    
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        //...
        //CameraClickHandler與當前Fragment繫結
        TouchEventBus.of(CameraClickHandler.class).attach(this);
    }
    
    @Override
    public void onDestroyView() {
        //...
        //CameraClickHandler與當前Fragment解綁
        TouchEventBus.of(CameraClickHandler.class).dettach(this);
    }
    
    @Override
    public void showVideoClickFocus(float x, float y) {
        //todo: 展示一個黃色框ui
    }
    
    @Override
    public void onTouch(MotionEvent e) {
        //todo: 呼叫SDK的攝像頭聚焦
    }
}
複製程式碼

當使用者對ui的進行手勢操作時,MotionEvent 就會沿著 TouchEventBus 裡面的順序進行分發。如果在 CameraClickHandler 之前沒有別的 Handler 把事件消費掉,那麼就能在 onTouch 方法進行處理,然後在 ui 作出響應。

事件的分發順序

多個 TouchEventHandler 之間需要定義一個分發的順序,最先接收到觸控事件的 Handler 可以攔截後面的 Handler。在順序的定義上,很難固定一條絕對的分發路線,因為隨著直播間模版的切換,Fragment 的層級可能會產生變化。
所以 TouchEventBus 使用相對的順序定義。每個 Handler 可以決定要攔截哪些其他的 Handler。比如要把 CameraClickHandler 排在其他幾個Handler前面:

public class CameraClickHandler extends AbstractTouchEventHandler<CameraClickView> {
    //...

    @Override
    public boolean onTouch(@NonNull CameraClickView v, MotionEvent e, boolean hasBeenIntercepted) {
        //...
    }

    /**
     * 定義哪些Handler需要排在我的後面
     **/
    @Override
    protected void defineNextHandlers(@NonNull List<Class<? extends TouchEventHandler<?, ? extends TouchViewHolder<?>>>> handlers) {
        //下面的Handler都會在CameraClickHandler後面,但他們之間的順序還未定義
        handlers.add(CameraZoomHandler.class);
        handlers.add(MediaMultiTouchHandler.class);
        handlers.add(PreviewSlideHandler.class);
        handlers.add(VideoControlTouchEventHandler.class);
    }
}
複製程式碼

每個 Handler 都會指定排在自己後面的 Handler,從而形成一張圖。通過拓撲排序我們就能動態地獲得一條分發路徑。下圖的箭頭指向 “A->B” 表示A需要排在B的前面:

拓撲排序

在直播間模版切換的時候,任何一個 Handler 都可以動態地新增到這個圖當中,也可以從這個圖中隨時移除,不會影響其他業務的正常進行。

巢狀的檢視用 Android 系統的觸控分發

互不巢狀的 Fragment 層級才需要使用 TouchEventBusFragment 內部用 Android 預設的觸控事件分發。如下圖:紅色箭頭部分為 TouchEventBus 的分發,按 Handler 的拓撲順序進行逐層呼叫。藍色箭頭部分為 Fragment 內部 ViewTree 的分發,完全依照 Android 系統的分發順序,即從父佈局向子檢視分發,子檢視向父佈局逐層決定是否消費。

觸控事件分發

使用例子

執行本工程的 TouchSample 模組,是一個使用 TouchEventBus 的簡單 Demo 。

TouchSample
  • 單指左右滑動切換選項卡
  • 雙指縮放中間的”Tab%_subTab%”文字框
  • 雙指左右滑動切換背景圖
  • 滑動螢幕左側拉出側邊皮膚

ui的層級:Activity -> 背景圖 -> 側邊皮膚 -> 選項卡 -> 文字框

觸控處理的順序:側邊皮膚 -> 文字縮放 -> 背景圖滑動 -> 底部導航點選 -> 選項卡滑動

這裡還做了一個操作是:讓底部導航點選不消費觸控事件。所以你可以在底部的導航欄區域上左右滑動,切換的是一級Tab。而在背景圖區域左右滑動,切換的是二級Tab。

配置

  1. 在專案 build.gradle 新增倉庫地址

    allprojects {
        repositories {
            maven { url `https://jitpack.io` }
    	}
    }
    複製程式碼
  2. 對應模組新增依賴

    dependencies {
        compile `com.github.YvesCheung.TouchEventBus:toucheventbus:1.4.3`
    }
    複製程式碼

專案地址

github.com/YvesCheung/…

相關文章