神奇的 ViewDragHelper,让你轻松定制拥有拖拽能力的 ViewGroup

为了吸引大家的注意力,先给大家看一张动图:
这里写图片描述
相信这种效果大家都见过吧?我第一次见到这样的效果时,心里也痒痒的,急于想实现这种功能,后来因为拖延症的问题,就一直没有去弄这件事。现在这段时间,工作比较轻闲,所以对自己几年 Android 生涯所运用的技术做一些总结与思考。拖拽这种功能正好可以形成一个主题。如题目所示,今天博文的目标就是介绍与分析 ViewDragHelper 这个类。

读者阅读本文后将会有如下收获:
1. 不借助于 ViewDragHelper 实现基本的拖拽效果。
2. 借助于 ViewDragHelper 轻松实现复杂的拖拽效果。
3. 分析 ViewDragHelper 源码说明它能实现拖拽的原因(放心,不会头晕,只涉及一点点源码)。

初识 ViewDragHelper

在我 Android 职业生涯的第一个月,第一个项目和 Launcher 有关,需要直接阅读系统的 Launcher 的代码,当时是 Launcher2 的工程,这个工程是 Android 系统的门面,但是代码量巨大,作为菜鸟而言,工作难度可想而知。很多地方直接看不懂,比如各种 Callback,比如涉及拖拽的 DragController 。

Launcher 中的拖拽主要是针对 APP 在桌面上的 ICON 和 Widget。
这里写图片描述

而 Launcher2 关于拖拽的代码,当时的我自认为是看不懂的,我当时的想法时能看懂的是高手。

但是,正因为如此我对拖拽这一功能才会有深深的恐惧感。

我当时立下志向————有朝一日,我一定会拥有这个能力的。

后来,官方为了便于开发,提供了一个方便的辅助类 ViewDragHelper 放到 Support V4 这个兼容包中,正因为如此,我的目标就更进了一步。
这里写图片描述

不借助于 ViewDragHelper 实现拖拽的功能

主动思考比被动接受的学习效果要好一点,被动接受的弊端在于看书的时候,我们以为自己懂了,产生“已经学会”这种错觉,结果是一段时间再来检验,发现实践效果相去甚远。

所以,我们学习新的知识最好要加入自己的主动思考,因为这样别人的知识才会被自己真正吸收,构建到自己的知识体系当中,成为自己的知识组件。

那么,对于拖拽这个功能,我们可以先抛开 ViewDragHelper 这个类不管。

我们先想一想如果是自己亲自编码,我们将怎么样开始呢?

动作分解

我们先可以将拖拽这个动作分解:
1. 触摸。
2. 移动。

角色分析

首先,我们博文分析的目标是 ViewGroup 中的拖拽,而并非是一个 View 中的拖拽。View 中拖拽实际上就是针对内容拖拽。用 scrollBy() 方法就可以解决,它等同于滑动或者滚动的概念,这个不在于本文讨论范围之内,如果对于这部分感兴趣的同学可以阅读我这篇博文《不再迷惑,也许之前你从未真正懂得 Scroller 及滑动机制》

很容易观察得到,ViewGroup 中拖拽涉及的角色可能包括:
1. ViewGroup。
2. 它的子 View,也就是某些 childView。

交互分析

  1. 手指触摸在 ViewGroup 上。
  2. 如果触摸的坐标正好落在某个 childView 上面。拖拽开始。
  3. 手指开始移动,childView 位置坐标改变。拖拽进行。
  4. 手指释放后,childView 落在新的位置或者回弹到指定的某处,拖拽结束。

这里写图片描述

编码

涉及到触摸的话,ViewGroup 自然要在 onTouchEvent() 和 onInterceptTouchEvent() 两个方法中处理。
onInterceptTouchEvent() 主要是用来决定是否拦截 childView 的触摸操作,这里面为了方便演示,统一处理为 true,也就是拦截。

onTouchEvent() 在这个方法中,ViewGroup 用来处理触摸的具体流程。也就是对应上图的触摸、移动、释放手指。

在 Android 中 MotionEvent 封装了触摸时的各种状态。所以我们主要处理的状态有以下:
1. MotionEvent.ACTION_DOWN: 在这个状态时,标记手指按下屏幕。我们需要判断当前触摸的地方是否落在 childview 的显示区域,如果是则标记拖拽状态开始,我们需要记录手指的触摸位置为原始坐标。
2. MotionEvent.ACTION_MOVE: 这个状态自然代表手指的移动过程,这个时候我们仍然需要记录手指触摸新的坐标,然后如果是在触摸开始的状态,则将 childview 进行位置偏移,偏移量就是新坐标与原始坐标的偏差。
3. MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCLE:这两者都是表明手指离开了屏幕,这个时候如果一个 childview 正在拖拽,那么需要标记拖拽状态结束,至于 View 根据实际需要,通常是停留在新的坐标或者是回弹到原来的地方。

知道了流程,我们就可以开始编码,我们可以新建一个 ViewGroup 命名为 DragViewGroup,为了简便起见,让它继承自 FrameLayout。之后实现它的 onInterceptTouchEvent() 和 onTouchEvent()。

public class DragViewGroup extends FrameLayout {
   
    private static final String TAG = "TestViewGroup";

    // 记录手指上次触摸的坐标
    private float mLastPointX;
    private float mLastPointY;

    //用于识别最小的滑动距离
    private int mSlop;
    // 用于标识正在被拖拽的 child,为 null 时表明没有 child 被拖拽
    private View mDragView;

    // 状态分别空闲、拖拽两种
    enum State {
        IDLE,
        DRAGGING
    }

    State mCurrentState;

    public DragViewGroup(Context context) {
        this(context,null);
    }

    public DragViewGroup(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }


    public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mSlop = ViewConfiguration.getWindowTouchSlop();
    }


    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return true;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();

        switch (action){
            case MotionEvent.ACTION_DOWN:
                if ( isPointOnViews(event)) {
                    //标记状态为拖拽,并记录上次触摸坐标
                    mCurrentState = State.DRAGGING;
                    mLastPointX = event.getX();
                    mLastPointY = event.getY();
                }
                break;

            case MotionEvent.ACTION_MOVE:
                int deltaX = (int) (event.getX() - mLastPointX);
                int deltaY = (int) (event.getY() - mLastPointY);
                if (mCurrentState == State.DRAGGING && mDragView != null
                        && (Math.abs(deltaX) > mSlop || Math.abs(deltaY) > mSlop)) {
                    //如果符合条件则对被拖拽的 child 进行位置移动
                    ViewCompat.offsetLeftAndRight(mDragView,deltaX);
                    ViewCompat.offsetTopAndBottom(mDragView,deltaY);
                    mLastPointX = event.getX();
                    mLastPointY = event.getY();
                }
                break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                if ( mCurrentState == State.DRAGGING ){
                    // 标记状态为空闲,并将 mDragView 变量置为 null
                    mCurrentState = State.IDLE;
                    mDragView = null;
                }
                break;
        }
        return true;
    }

    /**
     * 判断触摸的位置是否落在 child 身上
     *
     * */
    private boolean isPointOnViews(MotionEvent ev) {
        boolean result = false;
        Rect rect = new Rect();
        for (int i = 0;i < getChildCount();i++) {
            View view = getChildAt(i);
            rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
                ,(int)view.getY()+view.getHeight());

            if (rect.contains((int)ev.getX(),(int)ev.getY())){
                //标记被拖拽的child
                mDragView = view;
                result = true;
                break;
            }
        }

        return  result && mCurrentState != State.DRAGGING;
    }
}

注释写得很清楚,流程之前也分析过。现在我们来进行验证,验证的前置条件就是放 3 个 View 到 DragViewGroup 中,然后检测能不能够手指移动它。布局代码比较简单,我就不张贴了。直接看效果。

这里写图片描述

可以看到,基本的拖拽的功能实现了,但是有个细节需要优化,当 3 个 child 显示重叠时,触摸它的公共区域,总是最底层的 child 被响应,这有点反人类,正常的操作应该是最上层的最先被响应。那么怎么优化呢?

在上面代码中 mDragView 用来标记可以被拖拽的 child,我们在 isPointOnViews() 方法中找到最先适配的 child 然后赋值,但是由于 FrameLayout 的特性,最上面的 child 其实在 ViewGroup 的索引位置最靠后。

private boolean isPointOnViews(MotionEvent ev) {
    boolean result = false;
    Rect rect = new Rect();
    for (int i = 0;i < getChildCount();i++) {
        View view = getChildAt(i);
        rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
            ,(int)view.getY()+view.getHeight());

        if (rect.contains((int)ev.getX(),(int)ev.getY())){
            //标记被拖拽的child
            mDragView = view;
            result = true;
            break;
        }
    }

    return  result && mCurrentState != State.DRAGGING;
}

因此,我们可以做一小小改动就能修正这个问题,那就是遍历 children 的时候,逆序进行。这样先从顶层检查找到最适配触摸位置的地方,代码如下:

private boolean isPointOnViews(MotionEvent ev) {
    boolean result = false;
    Rect rect = new Rect();
    for (int i = getChildCount() - 1;i >= 0;i--) {
        View view = getChildAt(i);
        rect.set((int)view.getX(),(int)view.getY(),(int)view.getX()+(int)view.getWidth()
            ,(int)view.getY()+view.getHeight());

        if (rect.contains((int)ev.getX(),(int)ev.getY())){
            //标记被拖拽的child
            mDragView = view;
            result = true;
            break;
        }
    }

    
  • 22
    点赞
  • 83
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 29
    评论
要实现一个可动的ViewGroup,需要以下步骤: 1. 继承自ViewGroup类,实现onLayout方法和onTouchEvent方法。 2. 在onTouchEvent方法中,判断手势的类型,如果是按下事件,则记录下当前触摸点的坐标,如果是移动事件,则计算出移动的距离,并调用layout方法重新布局子View。 3. 在布局子View时,需要考虑到手指移动的距离,因此需要将子View的坐标加上手指移动的距离。 4. 可以通过设置子View的LayoutParams来实现子View的位置移动,例如: ``` MarginLayoutParams layoutParams = (MarginLayoutParams) childView.getLayoutParams(); layoutParams.leftMargin += deltaX; layoutParams.topMargin += deltaY; childView.setLayoutParams(layoutParams); ``` 5. 在onLayout方法中,需要根据子View的LayoutParams来确定子View的位置,例如: ``` for (int i = 0; i < getChildCount(); i++) { View childView = getChildAt(i); MarginLayoutParams layoutParams = (MarginLayoutParams) childView.getLayoutParams(); int childLeft = layoutParams.leftMargin; int childTop = layoutParams.topMargin; int childRight = childLeft + childView.getMeasuredWidth(); int childBottom = childTop + childView.getMeasuredHeight(); childView.layout(childLeft, childTop, childRight, childBottom); } ``` 6. 如果需要支持边界限制,可以在移动子View的过程中,判断子View是否超出了ViewGroup的边界,如果超出了,则将子View的位置限制在边界内。 7. 最后,需要在ViewGroup的构造方法中将其设置为可点击,否则无法接收到手势事件,例如: ``` public MyViewGroup(Context context, AttributeSet attrs) { super(context, attrs); setClickable(true); } ``` 通过以上步骤,就可以实现一个可动的ViewGroup了。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 29
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

frank909

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值