1. 程式人生 > >自定義 ViewGroup ,實現卡片堆疊效果

自定義 ViewGroup ,實現卡片堆疊效果

需求

想做一個卡片堆疊效果的滑動,兩個檢視,滑動過程將第二個view疊加在第一個view上邊,形成疊加的效果,有點像 NestedScrollView + CoordinatorLayout + Toolbar 的效果。預覽圖如下:

效果圖

看起來有點像 NestedScrollView+Toolbar 的效果,只是裡面的變換不一樣,往上推過程第二個控制元件往上移動,第一個 view 不發生變換。我這個是自定義viewgroup方式實現,其實用 NestedScroll 也能實現,難度應該會更低,下次再使用 NestedScroll 實現。

分析

使用自定義 ViewGroup

來做這個效果,有兩個需要解決的點,一是關於第二個 view 的大小測量,有用過 NestedScrollView 巢狀過 ListView 的同學肯定知道巢狀後 ListView 只顯示一行,必須去重寫 ListViewonMeasure 才能解決顯示不完全的問題,如果不指定 ListView 的具體大小,需自行計算第二個 view 的大小;二是觸控事件的分發處理,在滑動過程如果第二 view 沒有推到頂,父佈局要消費這個事件,如果到頂了,需要把事件繼續下發給第二個 view 。

  1. 測量大小
    測量 view 大小這裡不展開,ListView 的話在 adapterNotifyDataSetChange
    後根據父佈局已確定的大小重新測量,其他也一樣,需要注意的一點是,疊加的檢視(即最下面的那個 view ,下面用 target 代替)上移上去,也就是說改變檢視的 top 大小,所以在測繪 target 的時候,建議把 target的高度再加上需位移大小,這樣移動到最上面的時候,檢視最下面不會出現空白區域。
  2. 使用 MarginLayoutParams
    無論是測量大小還是擺放 view ,能用 margin 肯定是最好的,所以需要重寫 ViewGrouppublic LayoutParams generateLayoutParams(AttributeSet attrs) 方法,返回 MarginLayoutParams
    ,否則子 view 獲取 LayoutParams 強轉為 MarginLayoutParams 會出類轉換異常,需注意
  3. 觸控事件
    • 觸控事件分發複習一下大致流程
      最上層下發觸控事件,由 dispatchTouchEvent 分發,中途由 onInterceptTouchEvent 決定是否攔截,攔截的話不再下發,進入攔截view的 onTouchEvent ,不攔截的繼續下發到子 view ,重複上一步驟分發 dispatch,如果 onTouchEvent 消耗了該事件(return true)則不再往上回調 onTouchEvent,否則繼續上傳回最頂層view,當然,子 view 也可以請求父佈局不準消耗觸控事件,強制要求下發,如可滑動的檢視,請求父佈局 public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) 是否禁止攔截觸控事件。
    • 關於攔截 ACTION_MOVE 不觸發問題
      這個從邏輯層面來解釋比較容易,我們可以理解移動的形成首先由手指按下,再到手指擡起,中間產生的位移,即為移動,那麼也就說需要先攔截到 ACTION_DOWN ,向系統通知這是一個有效的按壓,才會觸發下一個 move 事件,沒有 down 作為前提,是沒有 move 存在的可能,所以需要在攔截 ACTION_DOWN 時候返回 true。
    • 事件繼續下發
      當我們滑動到最頂部的時候,這時候的移動對我們來說已經沒有用了,需要把這個事件傳遞給子 view,但是 move 的前提是什麼?是 ACTION_DOWN,所以需要在繼續下發之前,先主動下發一個 ACTION_DOWN ,再去分發 ACTION_MOVE

實現

Talk is cheap,show me the code.

  1. 測量佈局的就不寫了,不明白可以看看 android 的 LinearLayout/RelativeLayout 等佈局寫法

  2. onLayout 可以參考 LinearLayout ,只是我們需要在擺放 target 檢視時把位移高度加進去,如下:

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        int count = getChildCount();
    
        int layoutTop = top;
        int limitFirstChild = 0;
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() != View.VISIBLE) {
                continue;
            }
            int width = child.getMeasuredWidth();
            int height = child.getMeasuredHeight();
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
    
            left += lp.leftMargin;
            layoutTop += lp.topMargin;
            if (i == 0 && limitOffset == 0) {
                limitFirstChild = layoutTop + height / 2;//留存第一個view的top+高度/2,遮蓋一半的檢視
            }
    
            if (i == count - 1 && height != 0) {
    			//這裡判讀高度不為 0 是因為如 ListView 在未填充資料時,
    			//高度為0,這時再設定我們的位移進去會出現一個空佔用大小,不合適
    			//這裡的高度測量有點問題,如果target之上的檢視大小發生變化,target的大小也需要重新計算
                limitOffset = targetCurrentOffset = layoutTop - limitFirstChild;
                height += limitOffset;
            }
            child.layout(left + lp.leftMargin, layoutTop, left + width - lp.rightMargin, layoutTop + height);
            layoutTop += height;
        }
        if (count > 1) {
            target = getChildAt(count - 1);//最後一個檢視作為我們的移動目標
            iScrollView = ScrollableViewCompat.getScrollView(target);
        }
    }
    
    //如果要使用 Margin 引數的話,必須重寫
    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }
    
    
  3. 攔截事件
    我們一般不會重寫 dispatchOnTouchEvent ,因為涉及的方面分發邏輯太複雜,我們處理 onInterceptTouchEventonTouchEvent

    首先是攔截事件:

    private boolean isDragging = false;//判斷能否被拖拽
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (!isEnabled() || viewCanScrollUp()) {
            return false;
        }
        int action = event.getAction();
        int pointIndex;
        switch (action) {
            case MotionEvent.ACTION_DOWN://按壓
                pointerId = event.getPointerId(0);
                pointIndex = event.findPointerIndex(pointerId);
                if (pointIndex < 0) {
                    return false;
                }
                isDragging = false;
                downY = event.getY(pointIndex);
                break;
            case MotionEvent.ACTION_MOVE://移動
                pointIndex = event.findPointerIndex(pointerId);
                if (pointIndex < 0) {
                    return false;
                }
                float y = event.getY(pointIndex);
                Log.d(TAG, "y = " + y);
                checkScrollBound(y);//檢查邊界是否攔截該事件
                break;
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(event);
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                pointerId = -1;
                isDragging = false;
                break;
        }
        return isDragging;
    }
    
    

    接著是事件處理:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    
        if (!isEnabled() || viewCanScrollUp()) {
            return false;
        }
    
        int action = event.getAction();
        int pointIndex;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                Log.d(TAG, "touch action down");
                pointerId = event.getPointerId(0);
                pointIndex = event.findPointerIndex(pointerId);
                if (pointIndex < 0) {
                    return false;
                }
                isDragging = false;
                downY = event.getY(pointIndex);
                return true;//消耗掉才會觸發下面的 move
            case MotionEvent.ACTION_MOVE:
                pointIndex = event.findPointerIndex(pointerId);
                if (pointIndex < 0) {
                    return false;
                }
                float y = event.getY(pointIndex);
                checkScrollBound(y);
                if (isDragging) {
                    //處理
                    float dy = y - lastMotionY;
    //                    moveAllView(dy);
                    if (dy < 0 && targetCurrentOffset + dy <= targetEndOffset) {//父佈局到頂了,事件重寫下發
                        moveAllView(dy);
                        //重新下發,必須先觸發down才能使move被子view攔截到
                        Log.d(TAG, "dispatch action down");
                        int tmp = event.getAction();
                        event.setAction(MotionEvent.ACTION_DOWN);
                        dispatchTouchEvent(event);
                        event.setAction(tmp);
                    } else if (dy > 0 && targetCurrentOffset + dy >= limitOffset) {//target 還原,如果已經還原就不讓再下滑
                        Log.d(TAG, "到達限制區域");
                        if (targetCurrentOffset != limitOffset) {
                            moveAllView(limitOffset - targetCurrentOffset);
                            targetCurrentOffset = limitOffset;
                        }
                        isDragging = false;
                    } else {
                        moveAllView(dy);
                    }
                    lastMotionY = y;
                }
                break;
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(event);
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                pointerId = -1;
                isDragging = false;
                break;
        }
        return isDragging;
    }
    
    //判斷是否符合滑動條件
    //y>downY,說明手指向下滑動,檢視復原,檢視上面內容操作
    //當前的偏移量比 0 大,說明還可以繼續往上滑,沒有到最頂位置
    //touchSlop 是用來去除一些抖動,因為一點輕微位移也會觸發 move,如手指按下會產生抖動觸發move
    private void checkScrollBound(float y) {
        if (y > downY || targetCurrentOffset > targetEndOffset) {
            float dy = Math.abs(y - downY);
            if (dy > touchSlop && !isDragging) {//滑動判定
                lastMotionY = downY + touchSlop;
                isDragging = true;
            }
        }
    }
    
    //因為不確定子view會不會攔截,所以一定要重寫這個方法,由我們再去下發事件
    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    	//super.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
    
    

    上面兩個程式碼邏輯很類似,只用其中一個行不行,當然不行呀,各司其職才硬道理,攔截只做攔截判斷,onTouchEvent 才是對事件的處理,當然上面的處理不是最好,為滑動效果更好,加上 Scroller 才是硬道理。

    子檢視的移動:

    private void moveAllView(float dy) {//偏移量
        int _target = (int) (targetCurrentOffset + dy);
        _target = Math.max(_target, targetEndOffset);
        int offset = _target - targetCurrentOffset;
        ViewCompat.offsetTopAndBottom(target, offset);//最後一個檢視偏移
    	//如果多於兩個,其餘也做偏移
    	// 但這個暫不能用,各個view偏移量需按百分比算,也需要給各個view新增上偏移位移限制
        for (int i = 1; i < getChildCount() - 1; i++) {
            View child = getChildAt(i);
            ViewCompat.offsetTopAndBottom(child, offset / 2);
        }
        targetCurrentOffset = _target;
    }
    

此處挖坑注意

這裡補充一下 ViewCompat.offsetTopAndBottom 這個方法偏移是一個累加過程,實際是 top+dy 做偏移,負值上移,正值下移,有興趣請檢視原始碼,所以才需要我們去記錄當前偏移了多少 targetCurrentOffset。

超過兩個子 view 要按百分比計算偏移,記錄各自的偏移限制,簡單說一下原因,因為我們滑動的終點是按照最後一個view來判斷,有可能 target 還沒有到位置,但是其他子 view 已經到位置了,此時繼續滑動其他子 view 會偏移到整個佈局頂部外面去,下滑的話,子 view 也會滑出更多的距離導致和初始化檢視不一致,待填坑~~

關於 viewCanScrollUp() 解釋

target 能不能滑動,到頂後事件要不要再攔截的快速判斷,我這邊做個視訊判斷,如果是普通不可滑動的view,如TextView ,這裡直接返回false,如果是 ListView/RecyclerView/ScrollView 就判斷他們是不是第一個子view處於頂位置,也就是判斷,第一個可見 item 是不是列表的第一個,或者第一個 item(getPosition(0))距離父佈局高度是不是比父佈局 getPaddingTop 小

當然 ScrollView 的話,判斷 canScroll() ,注意這裡挖坑了,還沒測試…RecyclerView 也是一樣判斷,同樣挖坑沒測試…

如果是自定義可滑動 view 又想嘗試用這個佈局怎麼辦,我很貼心給了一個 interface IScrollView 介面,自行重寫 viewCanScrollUp() 就好

private boolean viewCanScrollUp() {
    boolean flag = iScrollView != null && iScrollView.viewCanScrollUp();
    Log.i(TAG, "viewCanScrollUp = " + flag);
    return flag;
}

public interface IScrollView {
        boolean viewCanScrollUp();
}

結尾

到這裡,本文也差不多結束,注意這不是一個完整可用的示例,還需要繼續完善,僅作為參考,後續將處理巢狀 RecyclerView/ScrollView ,以及用 NestedNestedScrollingParentHelper 來實現這個效果。

完整程式碼可移步 github AndroidDemo

只查閱自定義ViewGroup 點選我跳轉

只查閱 IScrollView 及 ScrollableViewCompat 點選我跳轉

參考文章:玩轉Android巢狀滾動

已開通微信公眾號碼農茅草屋,有興趣可以關注,一起學習

碼農茅草屋