Android自定义ViewGroup实现侧滑菜单

目录
  • 前言
  • 一、常用的几种交互方式
    • 1.1 事件的拦截处理
    • 1.2 自行处理事件的几种方式
    • 1.3 子View的滚动与协调交互
    • 1.4 ViewGroup之间的嵌套与协调效果
  • 二、ViewDragHelper的侧滑菜单实现
  • 三、回调与封装
  • 后记

前言

前文我们理解了ViewGroup的测量与布局,但是并没有涉及到多少的交互逻辑,而 ViewGroup 的交互逻辑说起来范围其实是比较大的。从哪开始说起呢?

我们暂且把 ViewGroup 的交互分为几块知识区,

  • 事件的拦截。
  • 事件的处理(内部又分不同的处理方式)。
  • 子View的移动与协调。
  • 父ViewGroup的协调运动。

然后我们先简单的做一个介绍,需要注意的是下面每一种方式单独拿出来都是一个知识点或知识面,这里我个人理解的话,可以当做一个目录,我们先简单的复习学习一下,心里过一遍,如果遇到哪一个知识点不是那么了解,那我们也可以单独的对这个技术点进行搜索与对应的学习。

而本文介绍完目录之后,我们会针对其中的一种【子View的协调运动】,也就是本文的侧滑菜单效果做讲解,后期也会对一些其他常用的效果再做分析哦。

话不多说,Let's go

一、常用的几种交互方式

一般来说,常见的几种场景通常来说涉及到如下的几种方式。每一种方式又根据不同的效果可以分为不同的方式来实现。

需要注意的是有时候也并非唯一解,也可以通过不同的方式实现同样的效果。也可以通过不同的方式组合起来,实现一些特定的效果。

下面我们先从事件的分发与拦截说起:

1.1 事件的拦截处理

自定义 ViewGroup 的一种分类,还比较常用的就是解决事件的冲突,常用的就是事件的拦截,这一点就需要了解一点 View 的事件分发与拦截的机制了。不过相信大家多多少少都懂一点,毕竟也是面试必出题了,下面简单说一下。

事件分发方面的区别:

事件分发机制主要有三个方法:dispatchTouchEvent()、onInterceptTouchEvent()、onTouchEvent()

ViewGroup包含这三个方法,而View则只包含dispatchTouchEvent()、onTouchEvent()两个方法,不包含onInterceptTouchEvent()。

onTouchEvent() 与 dispatchTouchEvent() 相信大家都有所了解。

onTouchEvent() 是事件的响应与处理,而dispatchTouchEvent() 是事件的分发。

需要注意的是当某个子View的dispatchTouchEvent()返回true时,会中止Down事件的分发,同时在ViewGroup中记录该子View。接下来的Move和Up事件将由该子View直接进行处理。

而 onInterceptTouchEvent() 就是ViewGroup专有的拦截处理,虽然子 View 没有拦截的方法,但是子View可以通过调用方法 getParent().requestDisallowInterceptTouchEvent() 请求父ViewGroup不拦截事件。

通过 重写 onInterceptTouchEvent() 或者 使用 requestDisallowInterceptTouchEvent() 即可达到事件拦截的处理。

关于事件的处理这里可以引用一张图,非常的清晰:

实际的应用,我这里以 ViewPager2 嵌套 RecyclerView 的场景为例。

如图所示的分类列表,我们可以使用ViewPager2 嵌套 RV 来实现。(具体的实现方式有多种,这里不做讨论),那么就会出现一个问题。什么时候滚动子 RV 。什么时候滚动垂直的父 VP2 。如果大家有尝试过类似的场景,相信大家就能理解这其中的坑点,随机出现父布局与子布局的滚动,也就是说有还是有事件冲突的问题。

就算大家使用别的方案解决了这个问题,那么换成一个复杂的分类列表又如何?

再比如这种复杂的分类页面,由于数据量比较大,子 RV 的上拉滑动事件中还需要加入上拉加载的时间。这一个分类滑动完毕之后,还需要切换右上的横向Tab。当横向Tab到最后一个了,并且滑动完毕之后,左侧的滚动Tab才往下走一个。

面对如此复杂的分类列表滚动逻辑,我们就需要使用自定义ViewGroup时间拦截层,自己控制什么时机由子 RV 控制滑动,什么时机由父 VP2 控制滑动。

这里我们以上图的简单示例为主,也是默认的常用效果,当子 RV 滚动完成之后再交由父 VP2 滚动。我们定义的拦截层自定义ViewGroup如下:

class NestedScrollableHost : FrameLayout {

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    private var touchSlop = 0
    private var initialX = 0f
    private var initialY = 0f

    private val parentViewPager: ViewPager2?
        get() {
            var v: View? = parent as? View
            while (v != null && v !is ViewPager2) {
                v = v.parent as? View
            }
            return v as? ViewPager2
        }

    private val child: View? get() = if (childCount > 0) getChildAt(0) else null

    init {
        touchSlop = ViewConfiguration.get(context).scaledTouchSlop
    }

    private fun canChildScroll(orientation: Int, delta: Float): Boolean {
        val direction = -delta.sign.toInt()
        return when (orientation) {
            0 -> child?.canScrollHorizontally(direction) ?: false
            1 -> child?.canScrollVertically(direction) ?: false
            else -> throw IllegalArgumentException()
        }
    }

    override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
        return handleInterceptTouchEvent(e)
    }

    private fun handleInterceptTouchEvent(e: MotionEvent): Boolean {

        val orientation = parentViewPager?.orientation ?: return false

        if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
            return false
        }

        if (e.action == MotionEvent.ACTION_DOWN) {
            initialX = e.x
            initialY = e.y
            parent.requestDisallowInterceptTouchEvent(true)

        } else if (e.action == MotionEvent.ACTION_MOVE) {

            val dx = e.x - initialX
            val dy = e.y - initialY
            val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

            val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
            val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

            if (scaledDx > touchSlop || scaledDy > touchSlop) {
                return if (isVpHorizontal == (scaledDy > scaledDx)) {
                    //垂直的手势拦截
                    parent.requestDisallowInterceptTouchEvent(false)
                    true
                } else {

                    if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
                        //子View能滚动,不拦截事件
                        parent.requestDisallowInterceptTouchEvent(true)
                        false
                    } else {
                        //子View不能滚动,直接就拦截事件
                        parent.requestDisallowInterceptTouchEvent(false)
                        true
                    }
                }
            }
        }

        return false
    }

}

这里主要的逻辑就是对拦截做处理,而如果是下图中复杂的分类页面,也是类似的逻辑,只是需要手动的控制是否拦截了。可以实现同样的效果的。

而除了拦截事件的自定义 ViewGroup 的场景之外,我们用的比较多的就是事件的处理了,事件的处理又分很多,可以自己手撕 onTouchEvent 。也可通过 Scroller 来实现滚动效果。也能通过 GestureDetector 手势识别器来帮我们完成。

下面一起来看看分别如何实现:

1.2 自行处理事件的几种方式

在之前的 View 和 ViewGroup 的学习中,我们一般都是自己来处理事件的响应与拦截,一般都是通过 MotionEvent 对象,拿到它的事件和一些位置信息,做绘制和事件拦截。

其实除了这一种最基本的方式,还有其他的方式也同样可以操作,分为不同的场景,我们可以选择性的使用不同的方式,都可以达到同样的效果。

onTouchEvent

我们比较常见的就是在 dispatchTouchEvent()、onTouchEvent() 两个方法中通过 MotionEvent 对象来操作属性。

比较常用的就是通过手势记录坐标点,然后进行绘制,或者进行事件的拦截。

例如,如果想绘制,我们可以记录变化的X与Y,然后通过指定的公式转换为绘制的变量,然后通过 invalidate 触发重绘,在 onDraw 中取到变化的变量绘制出来,达到动画或滚动或其他的一些效果。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {

            //按下的时候记录当前操作的是左侧限制圆还是右侧的限制圆
            downX = event.getX();
            touchLeftCircle = checkTouchCircleLeftOrRight(downX);

            if (touchLeftCircle) {
                //如果是左侧
                //如果超过右侧最大值则不处理
                if (downX + perSlice > mRightCircleCenterX) {
                    return false;
                }

                mLeftCircleCenterX = downX;
            } else {
                //如果是右侧
                //如果超过左侧最小值则不处理
                if (downX - perSlice < mLeftCircleCenterX) {
                    return false;
                }

                mRightCircleCenterX = downX;
            }

        } 

        //中间的进度矩形是根据两边圆心点动态计算的
        mSelectedCornerLineRect.left = mLeftCircleCenterX;
        mSelectedCornerLineRect.right = mRightCircleCenterX;

        //全部的事件处理完毕,变量赋值完成之后,开始重绘
        invalidate();

        return true;
    }

或者我们可以通过记录X和Y的坐标,判断滑动的方向从而进行事件的拦截:

 @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getRawX();
        int y = (int) ev.getRawY();
        int dealtX = 0;
        int dealtY = 0;

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                dealtX = 0;
                dealtY = 0;
                // 保证子View能够接收到Action_move事件
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                dealtX += Math.abs(x - lastX);
                dealtY += Math.abs(y - lastY);

                // 这里是否拦截的判断依据是左右滑动,读者可根据自己的逻辑进行是否拦截
                if (dealtX >= dealtY) { // 左右滑动请求父 View 不要拦截
                    getParent().requestDisallowInterceptTouchEvent(true);
                } else {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_CANCEL:
                break;
            case MotionEvent.ACTION_UP:
                break;

        }
        return super.dispatchTouchEvent(ev);
    }

这种方式相信也是大家见的最多的,看见代码就知道是什么意思,所以这里就不放图与Demo了,如果想了解,也可以看看我之前的自定义View绘制文章,基本都是这个套路。

接下来我们继续,那么除了原始的 MotionEvent 做移动之外,我们甚至可以使用 Scroller 来专门做滚动的操作。只是相对来说 Scroller 是比较少用的。(毕竟谷歌给我们的太多的滚动的控件了),但是掌握之后可以实现一些特殊的效果,也是值得一学,下面一起看看吧。

Scroller

Scroller 译为滚动器,是 ViewGroup 类中原生支持的一个功能。Scroller 类并不负责滚动这个动作,只是根据要滚动的起始位置和结束位置生成中间的过渡位置,从而形成一个滚动的动画。

Scroller 本身并不神秘与复杂,它只是模拟提供了滚动时相应数值的变化,复写自定义 View 中的 computeScroll() 方法,在这里获取 Scroller 中的 mCurrentX 和 mCurrentY,根据自己的规则调用 scrollTo() 方法,就可以达到平稳滚动的效果。

本质上就是一个持续不断刷新 View 的绘图区域的过程,给定一个起始位置、结束位置、滚动的持续时间,Scroller 自动计算出中间位置和滚动节奏,再调用 invalidate()方法不断刷新。

需要注意的是调用scrollTo()和 scrollBy()的区别。其实也不复杂,我们翻译为中文的意思,scrollTo是滚动到xx,scrollBy是滚动了xx,这样是不是就一下就理解了。

剩下的就是需要重写computeScroll执行滚动的逻辑。

下面举个简单的栗子:

我们使用 Scroller模仿一个 简易的 ViewPager 效果。自定义ViewGroup中加入了9个View。并且占满全屏,然后我们上滑动切换布局,当停手会判断是回到当前View还是去下一个View。

ViewGroup的测量与布局在之前的文章中我们已经反复的复习了,这应该没什么问题:

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View childView = getChildAt(i);
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }

    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        //设置ViewGroup的高度
        MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams();
        mlp.height = mScreenHeight * childCount;
        setLayoutParams(mlp);

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);

            if (child.getVisibility() != View.GONE) {
                child.layout(l, i * mScreenHeight, r, (i + 1) * mScreenHeight);
            }
        }
    }

然后就是对Touch和滚动的操作:

    private int mLastY;
    private int mStart;
    private int mEnd;
    private Scroller mScroller;

    ...

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mScroller.computeScrollOffset()) {
            scrollTo(0, mScroller.getCurrY());
            postInvalidate();
        }
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastY = y;
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE:
                //当停止动画的时候,它会马上滚动到终点,然后向动画设置为结束。
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                int dy = mLastY - y;
                if (getScrollY() < 0) {
                    dy = 0;
                }
                //开始滚动
                scrollBy(0, dy);
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
                mEnd = getScrollY();
                int dScrollY = mEnd - mStart;
                if (dScrollY > 0) {
                    if (dScrollY < mScreenHeight / 3) {
                        mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
                    } else {
                        mScroller.startScroll(0, getScrollY(), 0, mScreenHeight - dScrollY);
                    }
                } else {
                    if (-dScrollY < mScreenHeight / 3) {
                        mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
                    } else {
                        mScroller.startScroll(0, getScrollY(), 0, -mScreenHeight - dScrollY);
                    }
                }
                invalidate();
                break;

        }

        return true;
    }

那么实现的效果就是如下图所示:

是不是相当于一个简配的ViewPager呢。。。

既然我们的一些事件点击和移动可以通过 MotionEvent 来实现,一些特定的滚动效果还能通过 Scroller 来实现。有没有更方便的一种方式全部帮我们实现呢?

接下来就是我们常用的 GestureDetector 类了。可以帮助我们快速实现点击与滚动效果。

GestureDetector

GestureDetector类,这个类指明是手势识别器,它内部封装了一些常用的手势操作的接口,让我们快速的处理手势事件,比如单机、双击、长按、滚动等。

通常来说我们使用 GestureDetector 分为三步:

  • 初始化 GestureDetector 类。
  • 定义自己的监听类OnGestureListener,例如实现 GestureDetector.SimpleOnGestureListener。
  • 在 dispatchTouchEvent 或 onTouchEvent 方法中,通过GestureDetector将 MotionEvent 事件交给监听器 OnGestureListener

例如我们最简单的例子自定义View,控制View跟随手指移动,我们之前的做法是手撕 onTouchEvent,在按下的时候记录坐标,移动的时候计算坐标,然后重绘达到View跟随手指移动的效果。那么此时我们就能使用另一种方式来实现:

  private GestureDetector mGestureDetector;
  private float centerX;
  private float centerY;

  private void init(Context context) {
        mGestureDetector = new GestureDetector(context, new MTouchDetector());
        setClickable(true);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {

        //将Event事件交给监听器 OnGestureListener
        mGestureDetector.onTouchEvent(event);

        return super.dispatchTouchEvent(event);
    }

      private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {

        public boolean onDown(MotionEvent e) {
            YYLogUtils.w("MTouchDetector-onDown");
            return super.onDown(e);
        }

        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {

            centerY -= distanceY;
            centerX -= distanceX;

            //边界处理 ...

             postInvalidate();
        }

    }

上面我们通过 GestureDetector 来实现了 onTouch 中的绘制效果,那么同样的我们也可以通过 GestureDetector 来实现 onTouch 中的时间拦截效果:

  private GestureDetector mGestureDetector;

  private void init(Context context) {
        mGestureDetector = new GestureDetector(context, new MTouchDetector());
        setClickable(true);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {

        // 先告诉父Viewgroup,不要拦截,然后再内部判断是否拦截
        getParent().requestDisallowInterceptTouchEvent(true);
        //将Event事件交给监听器 OnGestureListener
        mGestureDetector.onTouchEvent(event);

        return super.dispatchTouchEvent(event);
    }

      private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {

        public boolean onDown(MotionEvent e) {
            YYLogUtils.w("MTouchDetector-onDown");
            return super.onDown(e);
        }

        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {

         if (1.732 * Math.abs(distanceX) >= Math.abs(distanceY)) {

                YYLogUtils.w("请求不要拦截我");
                getParent().requestDisallowInterceptTouchEvent(true);
                return true;

            } else {
                YYLogUtils.w("拦截我");
                getParent().requestDisallowInterceptTouchEvent(false);
                return false;
            }
        }
        ...
    }

GestureDetector 甚至能实现 Scroller 的效果,实现山寨ViewPager的效果,

  private GestureDetector mGestureDetector;

  private void init(Context context) {
        mGestureDetector = new GestureDetector(context, new MTouchDetector());
        setClickable(true);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {

        //将Event事件交给监听器 OnGestureListener
        mGestureDetector.onTouchEvent(event);

        return super.dispatchTouchEvent(event);
    }

      private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {

        public boolean onDown(MotionEvent e) {
            YYLogUtils.w("MTouchDetector-onDown");
            return super.onDown(e);
        }

        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {

           //直接移动
           scrollBy((int) distanceX, getScrollY());

        }
        ...
    }

可以看到我们直接在 GestureDetector 的 onScroll 回调中直接 scrollBy 有上面那种 Scroller 的效果了,比较跟手但是不能指定跳转到页面,但是如果想要更好的ViewPager效果,我们需要结合 Scroller 配合的使用就可以有更好的效果。

  private GestureDetector mGestureDetector;
  private int currentIndex;
  private int startX;
  private int endX;
  private Scroller mScroller;

  private void init(Context context) {
        mGestureDetector = new GestureDetector(context, new MTouchDetector());
        setClickable(true);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mGestureDetector.onTouchEvent(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                startX = (int) event.getX();
                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_UP:
                endX = (int) event.getX();

                int tempIndex = currentIndex;
                if (startX - endX > getWidth() / 2) {
                    tempIndex++;
                } else if (endX - startX > getWidth() / 2) {
                    tempIndex--;
                }
                scrollIndex(tempIndex);
                break;
        }
        return true;
    }

    private class MTouchDetector extends GestureDetector.SimpleOnGestureListener {

        public boolean onDown(MotionEvent e) {
            YYLogUtils.w("MTouchDetector-onDown");
            return super.onDown(e);
        }

        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {

           //直接移动
           scrollBy((int) distanceX, getScrollY());
           return true;
        }
        ...
    }

      private void scrollIndex(int tempIndex) {
        //第一页不能滑动
        if (tempIndex < 0) {
            tempIndex = 0;
        }
        //最后一页不能滑动
        if (tempIndex > getChildCount() - 1) {
            tempIndex = getChildCount() - 1;
        }
        currentIndex = tempIndex;
        mScroller.startScroll(getScrollX(), 0, currentIndex * getWidth() - getScrollX(), 0);
        postInvalidate();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), 0);
            postInvalidate();
        }
    }

这样通过 GestureDetector 结合 Scroller 就可以达到,按着滚动的效果和放开自动滚动到指定索引的效果了。

GestureDetector 确实是很方便,帮助我们封装了事件的逻辑,我们只需要对相应的时间做出响应即可,我愿称之为万能事件处理器。

除了这些单独的事件的处理,在同一个ViewGroup中如果有多个子View,我们还能通过 ViewDragHelper 来实现子 View 的自由滚动,甚至当其中一个View滚动的同时,我可以做对应的变化,(哟,是不是有behavior那味了)

1.3 子View的滚动与协调交互

一句话来介绍 ViewDragHelper ,它是用于在 ViewGroup 内部拖动视图的。

ViewDragHelper 也是谷歌帮我们封装好的工具类, 其本质就是内部封装了MotionEvent 和 Scroller,记录了移动的X和Y,让 Scroller 去执行滚动逻辑,从而实现让 ViewGroup 内部的子 View 可以实滚动与协调滚动的逻辑。

如何使用?固定的套路:

    private void initView() {
        //通过回调,告知告诉了移动了多少,触摸位置,触摸速度
        viewDragHelper = ViewDragHelper.create(this, callback);
    }

    /**
     * 触摸事件传递给ViewDragHelper
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        viewDragHelper.processTouchEvent(event);
        return true;  //传递给viewDragHelper。返回true,消费此事件
    }

    /**
     * 是否需要传递给viewDragHelper拦截事件
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean result = viewDragHelper.shouldInterceptTouchEvent(ev);
        return result;        //让传递给viewDragHelper判断是否需要拦截
    }

     //回调处理有很多,根据不同的需求来实现
     private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {

        @Override     //是否捕获child的触摸事件,是否能移动
        public boolean tryCaptureView(View child, int pointerId) {
            return child == redView || child == blueView;  //可以移动红色view
        }

        @Override  //chlid的移动后的回调,监听
        public void onViewCaptured(View capturedChild, int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
           // Log.d("tag", "被移动了");
        }

        @Override   //控件水平可拖拽的范围,目前不能限制边界,用于手指抬起,view动画移动到的位置
        public int getViewHorizontalDragRange(View child) {
            return getMeasuredWidth() - child.getMeasuredWidth();
        }

        @Override   //控件垂直可拖拽的范围,目前不能限制边界,用于手指抬起,view动画移动到的位置
        public int getViewVerticalDragRange(View child) {
            return getMeasuredHeight() - child.getMeasuredHeight();
        }

        @Override    //控制水平移动的方向。多少距离,left = child.getleft() + dx;
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            //在这里限制最大的移动距离,不能出边界
            if (left < 0) {
                left = 0;
            } else if (left > getMeasuredWidth() - child.getMeasuredWidth()) {
                left = getMeasuredWidth() - child.getMeasuredWidth();
            }
            return left;
        }

        @Override      //控制垂直移动的方向。多少距离
        public int clampViewPositionVertical(View child, int top, int dy) {
            //在这里限制最大的移动距离,不能出边界
            if (top < 0) {
                top = 0;
            } else if (top > getMeasuredHeight() - child.getMeasuredHeight()) {
                top = getMeasuredHeight() - child.getMeasuredHeight();
            }
            return top;
        }

        @Override      //当前child移动后,别的view跟着做对应的移动。用于做伴随移动
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
            //判断当蓝色的移动的时候,红色跟着移动相同的距离
            if (changedView == blueView) {
                redView.layout(redView.getLeft() + dx, redView.getTop() + dy, redView.getRight()
                        + dx, redView.getBottom() + dy);
            } else if (changedView == redView) {
                blueView.layout(blueView.getLeft() + dx, blueView.getTop() + dy, blueView.getRight()
                        + dx, blueView.getBottom() + dy);
            }
        }

        @Override    //手指抬起后,执行相应的逻辑
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            //以分界线判断在左边还是右边
            int centerLeft = getMeasuredWidth() / 2 - releasedChild.getMeasuredWidth() / 2;
            if (releasedChild.getLeft() < centerLeft) {
                //左边移动。移动到的距离
                viewDragHelper.smoothSlideViewTo(releasedChild, 0, releasedChild.getTop());
                ViewCompat.postInvalidateOnAnimation(DragLayout.this);  //刷新整个view
            } else {
                //右边移动。移动到的距离
                viewDragHelper.smoothSlideViewTo(releasedChild, getMeasuredWidth() -
                        releasedChild.getMeasuredWidth(), releasedChild.getTop());
                ViewCompat.postInvalidateOnAnimation(DragLayout.this);    //刷新整个view
            }
        }

    };

    @Override
    public void computeScroll() {
        //如果正在移动中,继续刷新
        if (viewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(DragLayout.this);
        }
    }

ViewDragHelper (这名字真的取的很好),其实就是滚动(拖拽)的帮助类,可以单独的滚动 ViewGroup 其中的一个子View,也可以用于多个子View的协调滚动。

这也是本期侧滑菜单选用的方案,多个子View的协调滚动的应用。

关于更多 ViewDragHelper 的基础使用,大家如果不了解可以看鸿洋的老文章【传送门】

关于View/ViewGroup的事件,除了这些常用的之外,还有例如多指触控事件,缩放的事件 ScaleGestureDecetor 等,由于比较少用,这里就不过多的介绍,其实逻辑与道理都是差不多的,如果有用到的话,可以再查阅对应的文档哦。

1.4 ViewGroup之间的嵌套与协调效果

前面讲到的都是ViewGroup内部的事件处理,关于ViewGroup之间的嵌套滚动来说的话,其实这是另一个话题了,跟自定义ViewGroup内部的事件处理相比,属实是另一个分支了,演变为多个解决方案,多个知识点了。

我之前的文章有过简单的介绍,目前主要是分几种思路

  • NestedScrolling机制
  • CoordinatorLayout + Behavior
  • CoordinatorLayout + AppBarLayout
  • ConstraintLayout / MotionLayout 机制

NestedScrollingParent 与 NestedScrollingChild,NestedScrolling 机制能够让父view和子view在滚动时进行配合,其基本流程如下:当子view开始滚动之前,可以通知父view,让其先于自己进行滚动,子view滚动之后,还可以通知父view继续滚动。

可以看看我之前的文章【传送门】

由于手撕 NestedScrolling 还是有点难度,对于一些嵌套滚动的需求,谷歌推出了 NestedScrollView 来实现嵌套滚动。而对于一些常见的、场景化的协调效果来说,谷歌推出 CoordinatorLayout 封装类,可以结合 Behavior 实现一些自定义的协调效果。

虽说 Behavior 的定义比 NestedScrolling 算简单一点了,但是也比较复杂啊,有没有更简单的,对于一些更常见的场景,谷歌说可以结合 AppBarLayout 做出一些常见的滚动效果。也确实解决了我们大部分滚动效果。

关于这一点可以看看我之前的文章【传送门】

虽然通过监听 AppBarLayout 的高度变化百分比,可以做出各种各样的其他布局的协调动画效果。但是一个是效率问题,一个是难度问题,总有一些特定的效果无法实现。

所以谷歌推出了 ConstraintLayout / MotionLayout 能更方便的做出各种协调效果。

关于这一点可以看看我之前的文章【传送门】

那么到此基本就解决了外部ViewGroup之前的嵌套与协调问题。

这里就不展开说了,这是另外一个体系,有需求的同学可以自行搜索了解一些。我们还是回归正题。

关于自定义 ViewGroup 的事件相关,我们就先初步的整理出一个目录了,接下来我们还是快看看如何定义一个侧滑菜单吧。

二、ViewDragHelper的侧滑菜单实现

目录列好了之后,我们就可以按需选择或组合就可以实现对应的效果。

比如我们这一期的侧滑菜单,其实就是涉及到了交互与嵌套的问题,而我们通过上述的学习,我们就知道我们可以有多种方式来实现。

  • 比如手撕 onTouchEvent + Scroller(为了自动返回)
  • 再简单点 GestureDetector + Scroller(为了自动返回)
  • 再简单点 ViewDragHelper 即可(就是对Scroller的封装)

我们这里就以最简单的 ViewDragHelper 方案来实现

我们分为内容布局和右侧隐藏的删除布局,默认的布局方式是内容布局占满布局宽度,让删除布局到屏幕外。

首先我们要测量与布局:

private View contentView;
private View deleteView;
private int contentWidth;
private int contentHeight;
private int deleteWidth;
private int deleteHeight;

public class SwipeLayout extends FrameLayout {

    //完成初始化,获取控件
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        contentView = getChildAt(0);
        deleteView = getChildAt(1);
    }

    //完成测量,获取高度,宽度
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        contentWidth = contentView.getMeasuredWidth();
        contentHeight = contentView.getMeasuredHeight();
        deleteWidth = deleteView.getMeasuredWidth();
        deleteHeight = deleteView.getMeasuredHeight();
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        contentView.layout(0, 0, contentWidth, contentHeight);
        deleteView.layout(contentView.getRight(), 0, contentView.getRight() + deleteWidth, deleteHeight);
    }
}

我们直接继承 FrameLayout 也不用自行测量了,布局的时候我们布局到屏幕外的右侧即可。

接下来我们就使用 viewDragHelper 来操作子View了。都是固定的写法

    private void init() {
        //是否处理触摸,是否处理拦截
        viewDragHelper = ViewDragHelper.create(this, callback);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return viewDragHelper.shouldInterceptTouchEvent
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = event.getX();
                downY = event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                float moveX = event.getX();
                float moveY = event.getY();
                float dx = moveX - downX;
                float dy = moveY - downY;
                if (Math.abs(dx) > Math.abs(dy)) {
                    //在水平移动。请求父类不要拦截
                    requestDisallowInterceptTouchEvent(true);
                }
                downX = moveX;
                downY = moveY;
                break;
            case MotionEvent.ACTION_UP:
                break;
        }

        viewDragHelper.processTouchEvent(event);
        return true;
    }

注意的是这里对拦截的事件做了方向上的判断,都是已学的内容。接下来的重点就是 callback 回调的处理。

    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {

        //点击ContentView和右侧的DeleteView都可以触发事件
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return child == contentView || child == deleteView;
        }

        //控件水平可拖拽的范围,最多也就拖出一个右侧DeleteView的宽度
        @Override
        public int getViewHorizontalDragRange(View child) {
            return deleteWidth;
        }

        //控制水平移动的方向距离
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            //做边界的限制
            if (child == contentView) {
                if (left > 0) left = 0;
                if (left < -deleteWidth) left = -deleteWidth;
            } else if (child == deleteView) {
                if (left > contentWidth) left = contentWidth;
                if (left < contentWidth - deleteWidth) left = contentWidth - deleteWidth;
            }
            return left;
        }

        //当前child移动后,别的view跟着做对应的移动。用于做伴随移动
        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
            //做内容布局移动的时候,删除布局跟着同样的移动
            if (changedView == contentView) {
                deleteView.layout(deleteView.getLeft() + dx, deleteView.getTop() + dy,
                        deleteView.getRight() + dx, deleteView.getBottom() + dy);
            } else if (changedView == deleteView) {
                //当删除布局移动的时候,内容布局做同样的移动
                contentView.layout(contentView.getLeft() + dx, contentView.getTop() + dy,
                        contentView.getRight() + dx, contentView.getBottom() + dy);
            }

        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            //松开之后,缓慢滑动,看是到打开状态还是到关闭状态
            if (contentView.getLeft() < -deleteWidth / 2) {
                //打开
                open();
            } else {
                //关闭
                close();
            }
        }
    };
    /**
     * 打开开关的的方法
     */
    public void open() {
        viewDragHelper.smoothSlideViewTo(contentView, -deleteWidth, 0);
        ViewCompat.postInvalidateOnAnimation(SwipeLayout.this);
    }

    /**
     * 关闭开关的方法
     */
    public void close() {
        viewDragHelper.smoothSlideViewTo(contentView, 0, 0);
        ViewCompat.postInvalidateOnAnimation(SwipeLayout.this);
    }

    /**
     * 重写移动的方法
     */
    @Override
    public void computeScroll() {
        if (viewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(SwipeLayout.this);
        }
    }

已经做了详细的注释了,是不是很清楚了呢? 效果图如下:

三、回调与封装

在一些列表上使用的时候我们需要一个Item只能打开一个删除布局,那么我们需要一个管理类来管理,手动的打开和关闭删除布局。

public class SwipeLayoutManager {

    private SwipeLayoutManager() {
    }

    private static SwipeLayoutManager mInstance = new SwipeLayoutManager();

    public static SwipeLayoutManager getInstance() {
        return mInstance;
    }

    //记录当前打开的item
    private SwipeLayout currentSwipeLayout;

    public void setSwipeLayout(SwipeLayout layout) {
        this.currentSwipeLayout = layout;
    }

    //关闭当前打开的item。layout
    public void closeCurrentLayout() {
        if (currentSwipeLayout != null) {
            currentSwipeLayout.close();  //调用的自定义控件的close方法
            currentSwipeLayout=null;
        }
    }

    public boolean isShouldSwipe(SwipeLayout layout) {
        if (currentSwipeLayout == null) {
            //没有打开
            return true;
        } else {
            //有打开的
            return currentSwipeLayout == layout;
        }
    }

    //清空currentLayout
    public void clearCurrentLayout() {
        currentSwipeLayout = null;
    }

}

我们还需要对打开关闭的状态做管理

    enum SwipeState {
        Open, Close;
    }

    private SwipeState currentState = SwipeState.Close; //默认为关闭

如果是打开的状态,我们还需要对事件做拦截的处理

 @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean result = viewDragHelper.shouldInterceptTouchEvent(ev);
        if (!SwipeLayoutManager.getInstance().isShouldSwipe(this)) {
            //在此关闭已经打开的item。
            SwipeLayoutManager.getInstance().closeCurrentLayout();
            result = true;
        }
        return result;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //如果当前的是打开的,下面的逻辑不能执行了
        if (!SwipeLayoutManager.getInstance().isShouldSwipe(this)) {
            requestDisallowInterceptTouchEvent(true);
            return true;
        }

        ...
    }

回调的处理,在 onViewPositionChanged 的移动回调中,我们可以通过内容布局的left是否为0 或者 -deleteWidth 就可以判断当前的布局状态是否是打开状态。

    private OnSwipeStateChangeListener listener;

    public void seOnSwipeStateChangeListener(OnSwipeStateChangeListener listener) {
        this.listener = listener;
    }

    public interface OnSwipeStateChangeListener {
        void Open();

        void Close();
    }

    ...

       //当前child移动后,别的view跟着做对应的移动。用于做伴随移动
        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
            //做内容布局移动的时候,删除布局跟着同样的移动
            if (changedView == contentView) {
                deleteView.layout(deleteView.getLeft() + dx, deleteView.getTop() + dy,
                        deleteView.getRight() + dx, deleteView.getBottom() + dy);
            } else if (changedView == deleteView) {
                //当删除布局移动的时候,内容布局做同样的移动
                contentView.layout(contentView.getLeft() + dx, contentView.getTop() + dy,
                        contentView.getRight() + dx, contentView.getBottom() + dy);
            }

            //判断开,关的逻辑
            if (contentView.getLeft() == 0 && currentState != SwipeState.Close) {
                //关闭删除栏.删除实例
                currentState = SwipeState.Close;
                if (listener != null) {
                    listener.Close();    //在此回调关闭方法
                }
                SwipeLayoutManager.getInstance().clearCurrentLayout();
            } else if (contentView.getLeft() == -deleteWidth && currentState != SwipeState.Open) {
                //开启删除栏。获取实例
                currentState = SwipeState.Open;
                if (listener != null) {
                    listener.Open();     //在此回调打开方法
                }
                SwipeLayoutManager.getInstance().setSwipeLayout(SwipeLayout.this);
            }
        }

这样就完成了全部的逻辑啦,其实理解之后并不复杂。

后记

其实关于侧滑返回的效果,网络上有很多的方案,这也只是其中的一种,为了方便大家理解 viewDragHelper 的使用,其实它还可以用于很多其他的场景,比如底部菜单的展示,Grid网格的动态变换等等。

到此这篇关于Android自定义ViewGroup实现侧滑菜单的文章就介绍到这了,更多相关Android ViewGroup侧滑菜单内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Android实现左侧滑动菜单

    本文实例为大家分享了Android实现左侧滑动菜单的具体代码,供大家参考,具体内容如下 效果图: SlideActivity.java: package com.demo.slide;   import android.app.Activity; import android.os.Bundle; import android.view.View; import android.view.Window;   import com.demo.broadcast.R;   public class

  • Android自定义实现侧滑菜单效果

    本文实例为大家分享了Android自定义实现侧滑菜单的具体代码,供大家参考,具体内容如下 实现原理:继承ViewGroup控件要显示到界面上需要重写OnMeature() OnLayout(),因此在实现OnLayout()的时候,将菜单界面划出到屏幕左侧,动态改变菜单界面距离scrollXto()左边界的距离就能实现滑动效果. 1.继承ViewGroup 2.事件分发机制 3.状态监听 在主界面中添加两个子控件 <com.oblivion.ui.SlideMenu xmlns:android=

  • Android自定义ViewGroup实现九宫格布局

    目录 前言 一.九宫格的测量 二.九宫格的布局 三.单图片与四宫格的单独处理 四.自定义布局的抽取 4.1 先布局再隐藏的思路 4.2 数据适配器的思路 前言 在之前的文章我们复习了 ViewGroup 的测量与布局,那么我们这一篇效果就可以在之前的基础上实现一个灵活的九宫格布局. 那么一个九宫格的 ViewGroup 如何定义,我们分解为如下的几个步骤来实现: 先计算与测量九宫格内部的子View的宽度与高度. 再计算整体九宫格的宽度和高度. 进行子View九宫格的布局. 对单独的图片和四宫格的

  • Android实现简单的自定义ViewGroup流式布局

    目录 前言 一.基本的测量与布局 二.流式的布局的layout 三.流式的布局的Measure 后记 前言 前面几篇我们简单的复习了一下自定义 View 的测量与绘制,并且回顾了常见的一些事件的处理方式. 那么如果我们想自定义 ViewGroup 的话,它和自定义View又有什么区别呢?其实我们把 ViewGroup 当做 View 来用的话也不是不可以.但是既然我们用到了容器 ViewGroup 当时是想用它的一些特殊的特性了. 比如 ViewGroup 的测量,ViewGroup的布局,Vi

  • Android自定义ViewGroup(侧滑菜单)详解及简单实例

    自定义侧滑菜单的简单实现 不少APP中都有这种侧滑菜单,例如QQ这类的,比较有名开源库如slidingmenu. 有兴趣的可以去研究研究这个开源库. 这里我们将一种自己的实现方法,把学习的 东西做个记录,O(∩_∩)O! 首先看效果图: 这里我们实现的侧滑菜单,是将左侧隐藏的菜单和主面板看作一个整体来实现的,而左侧隐藏的菜单和主面板相当于是这个自定义View的子View. 首先来构造该自定义View的布局: 自定义的SlideMenuView包含两个子view,一个是menuView,另一个是m

  • Android自定义ViewGroup多行多列效果

    本文实例为大家分享了Android自定义ViewGroup多行多列的具体代码,供大家参考,具体内容如下 先看下效果图 每行两个子孩子 每行一个子孩子 实现思路 自定义viewGroup,实现测量和布局,使控件适应业务场景. 测量 根据父控件的宽度,平均分给每个子孩子固定的宽度.高度就是行数乘以一个子孩子的高度,再加上空隙的高度. 根据子孩子个数计算行数 val rows = if (childCount % perLineChild == 0) { childCount / perLineChi

  • Android自定义ViewGroup实现侧滑菜单

    目录 前言 一.常用的几种交互方式 1.1 事件的拦截处理 1.2 自行处理事件的几种方式 1.3 子View的滚动与协调交互 1.4 ViewGroup之间的嵌套与协调效果 二.ViewDragHelper的侧滑菜单实现 三.回调与封装 后记 前言 前文我们理解了ViewGroup的测量与布局,但是并没有涉及到多少的交互逻辑,而 ViewGroup 的交互逻辑说起来范围其实是比较大的.从哪开始说起呢? 我们暂且把 ViewGroup 的交互分为几块知识区, 事件的拦截. 事件的处理(内部又分不

  • Android自定义ViewGroup打造各种风格的SlidingMenu

    上篇给大家介绍QQ5.0侧滑菜单的视频课程,对于侧滑的时的动画效果的实现有了新的认识,似乎打通了任督二脉,目前可以实现任意效果的侧滑菜单了,感谢鸿洋大大!! 用的是HorizontalScrollView来实现的侧滑菜单功能,HorizontalScrollView的好处是为我们解决了滑动功能,处理了滑动冲突问题,让我们使用起来非常方便,但是滑动和冲突处理都是android中的难点,是我们应该掌握的知识点,掌握了这些,我们可以不依赖于系统的API,随心所欲打造我们想要的效果,因此这篇文章我将直接

  • Android自定义VIew实现卫星菜单效果浅析

     一 概述: 最近一直致力于Android自定义VIew的学习,主要在看<android群英传>,还有CSDN博客鸿洋大神和wing大神的一些文章,写的很详细,自己心血来潮,学着写了个实现了类似卫星效果的一个自定义的View,分享到博客上,望各位指点一二.写的比较粗糙,见谅.(因为是在Linux系统下写的,效果图我直接用手机拍的,难看,大家讲究下就看个效果,勿喷). 先来看个效果图,有点不忍直视: 自定义VIew准备: (1)创建继承自View的类; (2)重写构造函数; (3)定义属性. (

  • Android自定义view实现侧滑栏详解

    目录 前言 需求 效果图 编写代码 主要问题 前言 上一篇文章学了下自定义View的onDraw函数及自定义属性,做出来的滚动选择控件还算不错,就是逻辑复杂了一些.这篇文章打算利用自定义view的知识,直接手撕一个安卓侧滑栏,涉及到自定义LayoutParams.带padding和margin的measure和layout.利用requestLayout实现动画效果等,有一定难度,但能重新学到很多知识! 需求 这里类似旧版QQ(我特别喜欢之前的侧滑栏),有两层页面,滑动不是最左侧才触发的,而是从

  • Android自定义ViewGroup之FlowLayout(三)

    本篇继续来讲自定义ViewGroup,给大家带来一个实例:FlowLayout.何为FlowLayout,就是控件根据ViewGroup的宽,自动的往右添加,如果当前行剩余空间不足,则自动添加到下一行,所以也叫流式布局.Android并没有提供流式布局,但是某些场合中,流式布局还是非常适合使用的,比如关键字标签,搜索热词列表等,比如下图: 定义FlowLayout LayoutParams,onLayout的写法都和上一篇讲WaterfallLayout一模一样,在此不再赘述了,没看过的可以参照

  • Android开源组件SlidingMenu侧滑菜单使用介绍

    现在很多android应用都有侧滑菜单,效果很不错. GitHub上有SlidingMenu的开源库,使用起来很方便. SlidingMenu GitHub地址:https://github.com/jfeinstein10/SlidingMenu.GitHub上说,Sliding结合ActionBarSherlock使用功能可以更丰富,ActionBarSherlock GitHub地址:https://github.com/JakeWharton/ActionBarSherlock 附csd

  • Android自定义ViewGroup实现标签流容器FlowLayout

    本篇文章讲的是Android 自定义ViewGroup之实现标签流式布局-FlowLayout,开发中我们会经常需要实现类似于热门标签等自动换行的流式布局的功能,网上也有很多这样的FlowLayout,但不影响我对其的学习.和往常一样,主要还是想总结一下自定义ViewGroup的开发过程以及一些需要注意的地方. 按照惯例,我们先来看看效果图 一.写代码之前,有几个是问题是我们先要弄清楚的: 1.什么是ViewGroup:从名字上来看,它可以被翻译为控件组,言外之意是ViewGroup内部包含了许

  • Android自定义ViewGroup实现绚丽的仿支付宝咻一咻雷达脉冲效果

    去年春节的时候支付宝推行的集福娃活动着实火的不能再火了,更给力的是春晚又可以全民参与咻一咻集福娃活动,集齐五福就可平分亿元大红包,只可惜没有敬业福--那时候在家没事写了个咻一咻插件,只要到了咻一咻的时间点插件就可以自动的点击咻一咻来咻红包,当时只是纯粹练习这部分技术代码没有公开,后续计划写篇关于插件这方面的文章,扯远了(*^__^*) --我们知道在支付宝的咻一咻页面有个雷达扩散的动画效果,当时感觉动画效果非常棒,于是私下尝试着实现了类似的效果,后来在github发现有大神也写有类似效果,于是读

  • Android自定义ViewGroup实现弹性滑动效果

    自定义View实现一个弹性滑动的效果,供大家参考,具体内容如下 实现原理 onMeasure()中测量所有子View @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 测量所有子View int count = getChildCount(); for (int i = 0; i < count; i++) { View childView = getChildAt(i); m

  • Android自定义ViewGroup实现竖向引导界面

    一般进入APP都有欢迎界面,基本都是水平滚动的,今天和大家分享一个垂直滚动的例子. 先来看看效果把: 1.首先是布局文件: <com.example.verticallinearlayout.VerticalLinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:i

随机推荐