Android自定义ViewGroup神器-ViewDragHelper

一、概述

ViewDragHelper is a utility class for writing custom ViewGroups. It offers a number of useful operations and state tracking for allowing a user to drag and reposition views within their parent ViewGroup.

这是官方的解释:在自定义ViewGroup时,ViewDragHelper可以用来拖拽和设置子View的位置(在ViewGroup范围内)。另外,还提供了一系列的方法和状态跟踪。

可见,在自定义ViewGroup时,ViewDragHelper一般用来处理子View的位置移动。

二、入门示例

demo1.gif

效果很简单,屏幕中间有两个TextView,位置随着我们的手指不断移动。

传统方式实现:一般需要重写onInterceptTouchEventonTouchEvent这两个方法,写好这两个方法不是一件容易的事情,需要自己去处理:事件冲突、加速检测等。

ViewDragHelper简化了很多工作,让我们更加关注“业务”的需求,实现步骤如下:

  1. 创建ViewDragHelper实例
  2. 处理ViewGroup的触摸事件
  3. ViewDragHelper.Callback的编写

(一) 自定义ViewGroup

public class VDHLinearLayout extends LinearLayout {
  ViewDragHelper dragHelper;

  public VDHLinearLayout(Context context, AttributeSet attrs) {
      super(context, attrs);
      dragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
          @Override
          public boolean tryCaptureView(View child, int pointerId) {
              return true;
          }

          @Override
          public int clampViewPositionVertical(View child, int top, int dy) {
              return top;
          }

          @Override
          public int clampViewPositionHorizontal(View child, int left, int dx) {
              return left;
          }
      });
  }

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

  @Override
  public boolean onTouchEvent(MotionEvent event) {
      dragHelper.processTouchEvent(event);
      return true;
  }
}

VDHLinearLayout的代码还是非常简单的,主要是分为以下三个步骤:

  1. 创建ViewDragHelper实例

    dragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {});
    

创建需要三个参数,第一个为当前的ViewGroup,第二个为sensitivity,主要用于设置touchSlop

   helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));

传入越大,touchSlop就越小。第三个参数就是ViewDragHelper.Callback,触摸过程中会回调相关方法。

  1. 实现ViewDragHelper.Callback相关方法

    new ViewDragHelper.Callback() {
       @Override
       public boolean tryCaptureView(View child, int pointerId) {
           return true;
       }
    
       @Override
       public int clampViewPositionVertical(View child, int top, int dy) {
           return top;
       }
    
       @Override
       public int clampViewPositionHorizontal(View child, int left, int dx) {
           return left;
       }
    }
    
  • tryCaptureView:如果返回true表示捕获相关View,你可以根据第一个参数child决定捕获哪个View。
  • clampViewPositionVertical:计算child垂直方向的位置,top表示y轴坐标(相对于ViewGroup),默认返回0(如果不复写该方法)。这里,你可以控制垂直方向可移动的范围。
  • clampViewPositionHorizontal:与clampViewPositionVertical类似,只不过是控制水平方向的位置。

比如效果图中,“拖拽2”明显超过屏幕范围了,你可以这样控制:

     @Override
     public int clampViewPositionHorizontal(View child, int left, int dx) {
        if (left > getWidth() - child.getMeasuredWidth()) // 右侧边界
        {
            left = getWidth() - child.getMeasuredWidth();
        }
        else if (left < 0) // 左侧边界
        {
            left = 0;
        }
        return left;
     }
  1. 处理ViewGroup触摸事件

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
       return dragHelper.shouldInterceptTouchEvent(ev);
    }
    
    @Override
    public boolean onTouchEvent(MotionEvent event) {
       dragHelper.processTouchEvent(event);
       return true;
    }
    

onInterceptTouchEvent直接交给dragHelper.shouldInterceptTouchEvent去处理,onTouchEvent通过dragHelper.processTouchEvent来处理。

如果你希望拖拽的子View是不可点击的,可以不重写onInterceptTouchEvent方法,后面我们会介绍为什么。

(二) 布局文件

<?xml version="1.0" encoding="utf-8"?>
<android.drag.viewdraghelperdemo.VDHLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        android:background="@color/colorPrimaryDark"
        android:textColor="@android:color/white"
        android:text="拖拽1"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        android:layout_marginTop="10dp"
        android:background="@color/colorPrimaryDark"
        android:textColor="@android:color/white"
        android:text="拖拽2"/>
</android.drag.viewdraghelperdemo.VDHLinearLayout>

布局很简单,自定义的ViewGroup包含两个TextView。

三、更多用法

ViewDragHelper不仅仅能够让子View跟随我们的手指移动,还能实现以下功能:

  • 边界触摸检测
  • Drag释放回调
  • 移动到某个指定位置

我么改造下上面的例子,效果图如下:

demo2.gif

第一个View,可以随意被拖动位置
第二个View,只能从ViewGroup左侧拖动
第三个View,拖动释放之后会回到原始位置

修改后的ViewGroup代码如下:

public class VDHLinearLayout extends LinearLayout {
  ViewDragHelper dragHelper;

  public VDHLinearLayout(Context context, AttributeSet attrs) {
      super(context, attrs);
      dragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
          @Override
          public boolean tryCaptureView(View child, int pointerId) {
              return child == dragView || child == autoBackView;
          }

          @Override
          public int clampViewPositionVertical(View child, int top, int dy) {
              return top;
          }

          @Override
          public int clampViewPositionHorizontal(View child, int left, int dx) {
              return left;
          }

          // 当前被捕获的View释放之后回调
          @Override
          public void onViewReleased(View releasedChild, float xvel, float yvel) {
              if (releasedChild == autoBackView)
              {
                  dragHelper.settleCapturedViewAt(autoBackViewOriginLeft, autoBackViewOriginTop);
                  invalidate();
              }
          }

          @Override
          public void onEdgeDragStarted(int edgeFlags, int pointerId) {
              dragHelper.captureChildView(edgeDragView, pointerId);
          }
      });
      // 设置左边缘可以被Drag
      dragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
  }

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

  @Override
  public boolean onTouchEvent(MotionEvent event) {
      dragHelper.processTouchEvent(event);
      return true;
  }

  @Override
  public void computeScroll() {
      if (dragHelper.continueSettling(true))
      {
          invalidate();
      }
  }

  View dragView;
  View edgeDragView;
  View autoBackView;
  @Override
  protected void onFinishInflate() {
      super.onFinishInflate();
      dragView = findViewById(R.id.dragView);
      edgeDragView = findViewById(R.id.edgeDragView);
      autoBackView = findViewById(R.id.autoBackView);
  }

  int autoBackViewOriginLeft;
  int autoBackViewOriginTop;
  @Override
  protected void onLayout(boolean changed, int l, int t, int r, int b) {
      super.onLayout(changed, l, t, r, b);
      autoBackViewOriginLeft = autoBackView.getLeft();
      autoBackViewOriginTop = autoBackView.getTop();
  }
}
  1. tryCaptureView方法,我们只捕获第一个和第三个View,分别是dragViewautoBackView

  2. 使用dragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT)设置ViewGroup左边缘可以被拖拽,同时在ViewDragHelper.Callback的onEdgeDragStarted方法中,使用dragHelper.captureChildView主动去捕获第二个View:edgeDragView

虽然在tryCaptureView方法中我们并未捕获edgeDragView,但dragHelper.captureChildView可以绕过该方法,详见官方解释:

Capture a specific child view for dragging within the parent. The callback will be notified but {@link Callback#tryCaptureView(android.view.View, int)} will not be asked permission to capture this view.

  1. onViewReleased方法会在被捕获的子View释放之后调用,我们判断释放的View:releasedChildautoBackView,使用dragHelper.settleCapturedViewAt方法设置autoBackView的位置为它的初始位置。

注意,此方法内部是通过Scroller实现的,所以我们需要使用invalidate来刷新,同时需要重写computeScroll方法:

   @Override
   public void computeScroll() {
      if (dragHelper.continueSettling(true))
      {
         invalidate();
      }
   }

dragHelper.continueSettling方法是用来判断当前被捕获的子View是否还需要继续移动,类似ScrollercomputeScrollOffset方法一样,我们需要在返回true的时候使用invalidate刷新。


至此,我么已经介绍了ViewDragHelper以及ViewDragHelper.Callback的多数用法。

还记得前面我们留下的一个问题吗?

“如果你希望拖拽的子View是不可点击的,可以不重写onInterceptTouchEvent方法,后面我们会介绍为什么。”

我们尝试将TextView设置成clickable=true,你会发现原本可以被拖拽的View都不动了。我们思考下,这是为什么呢?

原因在于:

由于子View是可被点击的,那么会触发ViewGroup的onInterceptTouchEvent方法。默认情况下,事件会被子View消耗掉,这显然是有问题的,因为这样ViewGroup的onTouch方法就不会被调用,而onTouch方法中正是我们的关键方法:dragHelper.processTouchEvent

既然我们找到原因了,有人说:你不能在onInterceptTouchEvent直接返回true吗?为啥还要用dragHelper.shouldInterceptTouchEvent(ev)的返回值啊???

确实,如果你直接返回true,会发现一切都能正常工作了。

这里我们需要解释下:

打个比方,如果你的ViewGroup中有另外一个Button(或者任何可点击的View),但是它不在ViewDragHelper的处理范围内,你可能需要监听它的onClick事件,如果直接返回true,你会发现onClick事件不会被触发了。

纳尼,为啥呢?因为ViewGroup拦截了它的事件了啊。。。好吧,我们还是老实这样写吧:

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

你迫不及待的运行修改之后的代码。咦?为啥还是不能拖拽。。。
此时,遇到这种情况,我一般是查看下dragHelper.shouldInterceptTouchEvent的源码(此处省略了部分不相关的代码):

public boolean shouldInterceptTouchEvent(MotionEvent ev) {
    final int action = MotionEventCompat.getActionMasked(ev);
    switch (action) {
        case MotionEvent.ACTION_MOVE: {          
            final int pointerCount = ev.getPointerCount();
            for (int i = 0; i < pointerCount; i++) {           
                final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
                            toCapture);
                final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
                // 如果getViewHorizontalDragRange和getViewVerticalDragRange的返回值都为0,则break
                if (horizontalDragRange == 0 && verticalDragRange == 0) {
                    break;
                }
                
                // tryCaptureViewForDrag方法中会设置mDragState=STATE_DRAGGING
                if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                    break;
                }
            }
            break;
        }
    }
    return mDragState == STATE_DRAGGING;
}

shouldInterceptTouchEvent返回true的条件是mDragState == STATE_DRAGGING,然而mDragState是在tryCaptureViewForDrag方法中被设置为STATE_DRAGGING的。

所以,如果horizontalDragRange == 0 && verticalDragRange == 0这个条件一直为true的话,tryCaptureViewForDrag方法就得不到调用了。

horizontalDragRangeverticalDragRange分别是Callback的getViewHorizontalDragRangegetViewVerticalDragRange方法返回的值,这两个方法默认情况下都返回0。

  • getViewHorizontalDragRange,返回子View水平方向可以被拖拽的范围
  • getViewVerticalDragRange,返回子View垂直方向可以被拖拽的范围

我们尝试重写这两个方法:

@Override
public int getViewVerticalDragRange(View child) {
   return getMeasuredHeight() - child.getMeasuredHeight();
}

@Override
public int getViewHorizontalDragRange(View child) {
   return getMeasuredWidth() - child.getMeasuredWidth();
}

再次运行下,你会发现TextView设置clickable=true之后也可以被拖拽了。


至此,ViewDragHelper的基本使用方式我们已经介绍完了。详细的代码可以查看文章最后的源码,另外,源码中还实现了一个比较常用的效果:

demo3.gif

本文源码

如果你喜欢我的文章,动动小手,关注我的个人简书吧~

也可以保存zhuhf.tech这个网址,它会自动跳转到我的简书个人主页哦~

每周给自己定一个小的目标,加油~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容