需求
想做一个卡片堆叠效果的滑动,两个视图,滑动过程将第二个view叠加在第一个view上边,形成叠加的效果,有点像 NestedScrollView + CoordinatorLayout + Toolbar 的效果。预览图如下:
看起来有点像 NestedScrollView+Toolbar
的效果,只是里面的变换不一样,往上推过程第二个控件往上移动,第一个 view 不发生变换。我这个是自定义viewgroup方式实现,其实用 NestedScroll
也能实现,难度应该会更低,下次再使用 NestedScroll
实现。
分析
使用自定义 ViewGroup
来做这个效果,有两个需要解决的点,一是关于第二个 view 的大小测量,有用过 NestedScrollView
嵌套过 ListView
的同学肯定知道嵌套后 ListView
只显示一行,必须去重写 ListView
的 onMeasure
才能解决显示不完全的问题,如果不指定 ListView
的具体大小,需自行计算第二个 view 的大小;二是触摸事件的分发处理,在滑动过程如果第二 view 没有推到顶,父布局要消费这个事件,如果到顶了,需要把事件继续下发给第二个 view 。
- 测量大小
测量 view 大小这里不展开,ListView
的话在adapterNotifyDataSetChange
后根据父布局已确定的大小重新测量,其他也一样,需要注意的一点是,叠加的视图(即最下面的那个 view ,下面用 target 代替)上移上去,也就是说改变视图的 top 大小,所以在测绘 target 的时候,建议把 target的高度再加上需位移大小,这样移动到最上面的时候,视图最下面不会出现空白区域。 - 使用
MarginLayoutParams
无论是测量大小还是摆放 view ,能用 margin 肯定是最好的,所以需要重写ViewGroup
的public LayoutParams generateLayoutParams(AttributeSet attrs)
方法,返回MarginLayoutParams
,否则子 view 获取 LayoutParams 强转为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嵌套滚动
已开通微信公众号码农茅草屋,有兴趣可以关注,一起学习