自定義 ViewGroup ,實現卡片堆疊效果
需求
想做一個卡片堆疊效果的滑動,兩個檢視,滑動過程將第二個view疊加在第一個view上邊,形成疊加的效果,有點像 NestedScrollView + CoordinatorLayout + Toolbar 的效果。預覽圖如下:
看起來有點像 NestedScrollView+Toolbar
的效果,只是裡面的變換不一樣,往上推過程第二個控制元件往上移動,第一個 view 不發生變換。我這個是自定義viewgroup方式實現,其實用 NestedScroll
也能實現,難度應該會更低,下次再使用 NestedScroll
實現。
分析
使用自定義 ViewGroup
NestedScrollView
巢狀過 ListView
的同學肯定知道巢狀後 ListView
只顯示一行,必須去重寫 ListView
的 onMeasure
才能解決顯示不完全的問題,如果不指定 ListView
的具體大小,需自行計算第二個 view 的大小;二是觸控事件的分發處理,在滑動過程如果第二 view 沒有推到頂,父佈局要消費這個事件,如果到頂了,需要把事件繼續下發給第二個 view 。
- 測量大小
測量 view 大小這裡不展開,ListView
的話在adapterNotifyDataSetChange
- 使用
MarginLayoutParams
無論是測量大小還是擺放 view ,能用 margin 肯定是最好的,所以需要重寫ViewGroup
的public LayoutParams generateLayoutParams(AttributeSet attrs)
方法,返回MarginLayoutParams
MarginLayoutParams
會出類轉換異常,需注意 - 觸控事件
- 觸控事件分發複習一下大致流程
最上層下發觸控事件,由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.
-
測量佈局的就不寫了,不明白可以看看 android 的
LinearLayout/RelativeLayout
等佈局寫法 -
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); }
-
攔截事件
我們一般不會重寫dispatchOnTouchEvent
,因為涉及的方面分發邏輯太複雜,我們處理onInterceptTouchEvent
和onTouchEvent
首先是攔截事件:
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巢狀滾動
已開通微信公眾號碼農茅草屋,有興趣可以關注,一起學習