自定义 ViewGroup ,实现卡片堆叠效果

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ganfanzhou/article/details/85925291

需求

想做一个卡片堆叠效果的滑动,两个视图,滑动过程将第二个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嵌套滚动

已开通微信公众号码农茅草屋,有兴趣可以关注,一起学习

码农茅草屋

猜你喜欢

转载自blog.csdn.net/ganfanzhou/article/details/85925291