Android神奇的 ViewDragHelper,让你轻松定制
读者阅读本文后将会有如下收获:
- 不借助于 ViewDragHelper 实现基本的拖拽效果。
- 借助于 ViewDragHelper 轻松实现复杂的拖拽效果。
- 分析 ViewDragHelper 源码说明它能实现拖拽的原因(只涉及一点点源码)。
初识 ViewDragHelper
官方为了便于开发,提供了一个方便的辅助类 ViewDragHelper 放到 Support V4 这个兼容包中,正因为如此,我的目标就更进了一步。
不借助于 ViewDragHelper 实现拖拽的功能
主动思考比被动接受的学习效果要好一点,被动接受的弊端在于看书的时候,我们以为自己懂了,产生“已经学会”这种错觉,结果是一段时间再来检验,发现实践效果相去甚远。
所以,我们学习新的知识最好要加入自己的主动思考,因为这样别人的知识才会被自己真正吸收,构建到自己的知识体系当中,成为自己的知识组件。
那么,对于拖拽这个功能,我们可以先抛开 ViewDragHelper 这个类不管。
我们先想一想如果是自己亲自编码,我们将怎么样开始呢?
动作分解
我们先可以将拖拽这个动作分解:
- 触摸。
- 移动。
角色分析
首先,我们博文分析的目标是 ViewGroup 中的拖拽,而并非是一个 View 中的拖拽。View 中拖拽实际上就是针对内容拖拽。用 scrollBy() 方法就可以解决,它等同于滑动或者滚动的概念,这个不在于本文讨论范围之内,如果对于这部分感兴趣的同学可以阅读我这篇博文Android Scroller你所需知道的一切
很容易观察得到,ViewGroup 中拖拽涉及的角色可能包括:
- ViewGroup。
- 它的子 View,也就是某些 childView。
交互分析
- 手指触摸在 ViewGroup 上。
- 如果触摸的坐标正好落在某个 childView 上面。拖拽开始。
- 手指开始移动,childView 位置坐标改变。拖拽进行。
- 手指释放后,childView 落在新的位置或者回弹到指定的某处,拖拽结束。
编码
涉及到触摸的话,ViewGroup 自然要在 onTouchEvent() 和 onInterceptTouchEvent() 两个方法中处理。
onInterceptTouchEvent() 主要是用来决定是否拦截 childView 的触摸操作。
onTouchEvent() 在这个方法中,ViewGroup 用来处理触摸的具体流程。也就是对应上图的触摸、移动、释放手指。
在 Android 中 MotionEvent 封装了触摸时的各种状态。所以我们主要处理的状态有以下:
- MotionEvent.ACTION_DOWN: 我们需要判断当前触摸的地方是否落在 childview 的显示区域,如果是则标记拖拽状态开始,我们需要记录手指的触摸位置为原始坐标。
- MotionEvent.ACTION_MOVE: 这个时候我们仍然需要记录手指触摸新的坐标,然后如果是在触摸开始的状态,则将 childview 进行位置偏移,偏移量就是新坐标与原始坐标的偏差。
- MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCLE:这个时候如果一个 childview 正在拖拽,那么需要标记拖拽状态结束,至于 View 根据实际需要,通常是停留在新的坐标或者是回弹到原来的地方。
知道了流程,我们就可以开始编码,我们可以新建一个 ViewGroup 命名为 DragViewGroup,为了简便起见,让它继承自 FrameLayout。之后实现它的 onInterceptTouchEvent() 和 onTouchEvent()。
package com.example.scrollertest;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.FrameLayout;
import androidx.core.view.ViewCompat;
import androidx.core.view.ViewConfigurationCompat;
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);
ViewConfiguration configuration = ViewConfiguration.get(context);
mSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastPointX = ev.getRawX();
break;
case MotionEvent.ACTION_MOVE:
float diff = Math.abs(ev.getRawX() - mLastPointX);
// 当手指拖动值大于TouchSlop值时,认为应该进行滚动,拦截子控件的事件
if (diff > mSlop) {
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}
@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) {
//如果符合条件则对被拖拽的 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() + 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 中,然后检测能不能够手指移动它。布局代码比较简单
<?xml version="1.0" encoding="utf-8"?>
<com.example.scrollertest.DragViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="160dp"
android:layout_height="160dp"
android:background="#03A9F4"
android:gravity="center"
android:layout_marginLeft="20dp"
android:text="教育就是解放心灵"
android:textColor="@android:color/white"
android:textSize="16sp" />
<TextView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_marginLeft="160dp"
android:layout_marginTop="240dp"
android:background="#FF9800"
android:gravity="center"
android:text="教育就是解放心灵"
android:textColor="@android:color/white"
android:textSize="16sp" />
<TextView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_marginLeft="40dp"
android:layout_marginTop="480dp"
android:background="#8BC34A"
android:gravity="center"
android:text="教育就是解放心灵"
android:textColor="@android:color/white"
android:textSize="16sp" />
</com.example.scrollertest.DragViewGroup>
可以看到,基本的拖拽的功能实现了,但是有个细节需要优化,当 3 个 child 显示重叠时,触摸它的公共区域,总是最底层的 child 被响应,这有点反人类,正常的操作应该是最上层的最先被响应。那么怎么优化呢?
由于 FrameLayout 的特性,最上面的 child 其实在 ViewGroup 的索引位置最靠后。
因此,我们可以做一小小改动就能修正这个问题,那就是遍历 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;
}
}
return result && mCurrentState != State.DRAGGING;
}
效果如图:
回弹效果
可能还有的同学在想,我想在拖拽中实现回弹效果怎么样?毕竟这样更符合拖拽这一特性。上面的代码,都是假设手指离开屏幕后,child 停留在新的坐标上,如果我们的需求就释放手指后 child 移动回原来的位置,那么怎么做呢?
其实答案很简单,我们需要做如下工作:
- 记录 child 原来位置的坐标。
- 手指释放时借助于属性动画,从新的位置到原始位置做数值变化,变化的过程中移动 child 最终就形成了回弹的动画效果。
package com.example.scrollertest;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.FrameLayout;
import androidx.core.view.ViewCompat;
import androidx.core.view.ViewConfigurationCompat;
public class DragViewGroup extends FrameLayout {
private static final String TAG = "TestViewGroup";
// 记录手指上次触摸的坐标
private float mLastPointX;
private float mLastPointY;
//用于识别最小的滑动距离
private int mSlop;
// 记录被拖拽之前 child 的位置坐标
private float mDragViewOrigX;
private float mDragViewOrigY;
// 用于标识正在被拖拽的 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);
ViewConfiguration configuration = ViewConfiguration.get(context);
mSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastPointX = ev.getRawX();
break;
case MotionEvent.ACTION_MOVE:
float diff = Math.abs(ev.getRawX() - mLastPointX);
// 当手指拖动值大于TouchSlop值时,认为应该进行滚动,拦截子控件的事件
if (diff > mSlop) {
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}
@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) {
//如果符合条件则对被拖拽的 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
if (mDragView != null) {
ValueAnimator animator = ValueAnimator.ofFloat(mDragView.getX(), mDragViewOrigX);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mDragView.setX((Float) animation.getAnimatedValue());
}
});
ValueAnimator animator1 = ValueAnimator.ofFloat(mDragView.getY(), mDragViewOrigY);
animator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mDragView.setY((Float) animation.getAnimatedValue());
}
});
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.play(animator).with(animator1);
animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mDragView = null;
}
});
animatorSet.start();
} else {
mDragView = null;
}
mCurrentState = State.IDLE;
}
break;
}
return true;
}
/**
* 判断触摸的位置是否落在 child 身上
*/
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() + view.getWidth(), (int) view.getY() + view.getHeight());
if (rect.contains((int) ev.getX(), (int) ev.getY())) {
//标记被拖拽的child
mDragView = view;
// 保存拖拽之间 child 的位置坐标
mDragViewOrigX = mDragView.getX();
mDragViewOrigY = mDragView.getY();
result = true;
break;
}
}
return result && mCurrentState != State.DRAGGING;
}
}
到此,我们就用自己的方式实现了比较简单的拖拽功能,下面的部分自然就是学习如何用 ViewDragHelper 实现这一功能了。
ViewDragHelper 基本介绍
ViewDragHelper 它的目的是辅助自定义 ViewGroup。ViewDragHelper 针对 ViewGroup 中的拖拽和重新定位 views 操作时提供了一系列非常有用的方法和状态追踪。
上面就是官网对于 ViewDragHelper,它的本质了只是一个工具类而已,为了更好地运用在拖拽这一动作上。
我们先看看它的使用方法。
ViewDragHelper 的创建
static ViewDragHelper create(ViewGroup forParent, float sensitivity, ViewDragHelper.Callback cb)
static ViewDragHelper create(ViewGroup forParent, ViewDragHelper.Callback cb)
ViewDragHelper 提供了两个工厂方法来创建实例,为了简单起见,我们先分析第二个方法好了。它有两个参数。
forParent 自然是与 ViewDragHelper 相关联的 ViewGroup。
cb 的类型是 ViewDragHelper.Callback 是个回调,我们具体分析。
ViewDragHelper 提供了一系列回调,用来指示拖拽时的各种信号及状态变化,其中的方法全部陈列如下:
int clampViewPositionHorizontal(View child, int left, int dx)
int clampViewPositionVertical(View child, int top, int dy)
int getOrderedChildIndex(int index)
int getViewHorizontalDragRange(View child)
int getViewVerticalDragRange(View child)
void onEdgeDragStarted(int edgeFlags, int pointerId)
boolean onEdgeLock(int edgeFlags)
void onEdgeTouched(int edgeFlags, int pointerId)
void onViewCaptured(View capturedChild, int activePointerId)
void onViewDragStateChanged(int state)
void onViewPositionChanged(View changedView, int left, int top, int dx, int dy)
void onViewReleased(View releasedChild, float xvel, float yvel)
abstract boolean tryCaptureView(View child, int pointerId)
作为初学者,我们先关注能构成完整拖拽事件序列的回调方法,这就是 MVP 法则,但这个 MVP 是 Mininum Viable Product 的意思,意思是最小可行性产品。通俗来讲就是用最少的资源能跑动起来的产品。
那么,ViewDragHelper 用哪几个回调能构成最简单能运行的实例呢?
// 决定了是否需要捕获这个 child,只有捕获了才能进行下面的拖拽行为
abstract boolean tryCaptureView(View child, int pointerId)
// 修整 child 水平方向上的坐标,left 指 child 要移动到的坐标,dx 相对上次的偏移量
int clampViewPositionHorizontal(View child, int left, int dx)
// 修整 child 垂直方向上的坐标,top 指 child 要移动到的坐标,dy 相对上次的偏移量
int clampViewPositionVertical(View child, int top, int dy)
// 手指释放时的回调
void onViewReleased(View releasedChild, float xvel, float yvel)
前文有讲过拖拽功能通过自己实现 onTouchEvent() 方法其实也是可以的,但是我们自己编写的代码肯定没有 Google 开发者稳定性高,毕竟是人家设计的产品嘛。
现在如果要用 ViewDragHelper 来处理这个流程,自然要把触摸相关的动作要委托给它了。这里涉及到了 ViewDrageHelper 两个方法。
/** 是否应该拦截 children 的触摸事件,
*只有拦截了 ViewDragHelper 才能进行后续的动作
*
*将它放在 ViewGroup 中的 onInterceptTouchEvent() 方法中就好了
**/
boolean shouldInterceptTouchEvent(MotionEvent ev)
/** 处理 ViewGroup 中传递过来的触摸事件序列
*在 ViewGroup 中的 onTouchEvent() 方法中处理
*/
void processTouchEvent(MotionEvent ev)
接下来,就可以用 ViewDragHelper 进行编码了。我们用它来改写之前我们自己实现的拖拽动作。
public class TestViewGroup extends FrameLayout {
private static final String TAG = "TestViewGroup";
private ViewDragHelper mDragHelper;
public TestViewGroup(Context context) {
this(context,null);
}
public TestViewGroup(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
}
});
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
}
寥寥几行代码就实现了同样的功能。住得注意的是
tryCaptureView() 方法返回 true 时才会导致下面的回调方法被调用
clampViewPositionHorizontal() 和 clampViewPositionVertical() 中处理 child 拖拽时的位置坐标。
我们看看现象如何:
ViewDragHelper 实现拖拽后的回弹
现在,我要加一些难度了。还是要实现前文一样,手指释放的时候 child 回弹到原来的位置。
这个时候就要借助于另外一个 API 了。
将 child 安置到坐标 (finalLeft,finalTop) 的位置。
settleCapturedViewAt(int finalLeft, int finalTop)
因此,我们同样是要记录 child 刚开始被拖拽时的位置,这个可以在回调方法中设置。
void onViewCaptured(View capturedChild, int activePointerId)
所以呢,我们的思路:
- 在 onViewCaptured() 方法中记录拖拽前的坐标。
- 在 onViewReleased() 方法中调用 settleCapturedViewAt() 方法来重定位 child。
于是,我们修正相应代码:
mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
super.onViewCaptured(capturedChild, activePointerId);
mDragOriLeft = capturedChild.getLeft();
mDragOriTop = capturedChild.getTop();
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
mDragHelper.settleCapturedViewAt((int)mDragOriLeft,(int)mDragOriLeft);
}
});
可是,效果却没有变化。我们查看 settleCapturedViewAt() 源码。
/**
* Settle the captured view at the given (left, top) position.
* The appropriate velocity from prior motion will be taken into account.
* If this method returns true, the caller should invoke {@link #continueSettling(boolean)}
* on each subsequent frame to continue the motion until it returns false. If this method
* returns false there is no further work to do to complete the movement.
*
* @param finalLeft Settled left edge position for the captured view
* @param finalTop Settled top edge position for the captured view
* @return true if animation should continue through {@link #continueSettling(boolean)} calls
*/
public boolean settleCapturedViewAt(int finalLeft, int finalTop) {
if (!mReleaseInProgress) {
throw new IllegalStateException("Cannot settleCapturedViewAt outside of a call to "
+ "Callback#onViewReleased");
}
return forceSettleCapturedViewAt(finalLeft, finalTop,
(int) VelocityTrackerCompat.getXVelocity(mVelocityTracker, mActivePointerId),
(int) VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId));
}
注释说明了一切, settleCapturedViewAt() 方法返回 true 时,开发者需要调用 continueSettleing() 方法在动画过程中的每一帧。
可能有些同学没能理解明白,我分解一下:
- settleCapturedViewAt() 方法调用的目的的将 child 定位到 (left,top) 位置,但它不是瞬间到达,有一个动画的过程。
- 需要动画过程的每一帧调用 continueSettling() 方法,直到它返回 false。
- 如果 continureSettling() 返回 false 表明此次动画结束。
一个 View 能够配合 Scroller 工作实现滑动机制,在于 computeScroll() 方法中调用 Scroller.computeScrollOffsets() 方法,然后再调用 scrollTo() 方法,这样导致不停地重绘,不停地调用 computeScroll() 方法,然后又不停地 Scroller.computeScrollOffset() 方法,最终才使 Scroller 动画运转起来,并将 Scroller 变化的数值与 View 中 mScrollX、mScrollY 关键属性建立映射。
现在 settleCapturedViewAt() 的启动只是一个开始,需要源源不断地调用 continueSettleing() 方法,所以,我有种预感,这其中必定有 Scroller 相关的机制。于是,我查看 settleCapturedViewAt() 最终调用的 forceSettleCapturedViewAt() 方法源码。
private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel) {
final int startLeft = mCapturedView.getLeft();
final int startTop = mCapturedView.getTop();
final int dx = finalLeft - startLeft;
final int dy = finalTop - startTop;
if (dx == 0 && dy == 0) {
// Nothing to do. Send callbacks, be done.
mScroller.abortAnimation();
setDragState(STATE_IDLE);
return false;
}
final int duration = computeSettleDuration(mCapturedView, dx, dy, xvel, yvel);
mScroller.startScroll(startLeft, startTop, dx, dy, duration);
setDragState(STATE_SETTLING);
return true;
}
看到这里的时候,果然 Scroller 才是幕后英雄。好了,线索找到了,鸣金收兵。对于 Scroller 的研究不是本文重点,大家知道它能触发一个平滑的位移效果就好。
再回到原来的问题,continueSettleing() 方法要在每一帧被调用,和编码 Scroller 代码一样,最适合的场合就是 ViewGroup 中的 computeScroll() 方法中。
package com.example.scrollertest;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import androidx.customview.widget.ViewDragHelper;
public class TestViewGroup extends FrameLayout {
private static final String TAG = "TestViewGroup";
private ViewDragHelper mDragHelper;
private int mDragOriLeft;
private int mDragOriTop;
public TestViewGroup(Context context) {
this(context, null);
}
public TestViewGroup(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
super.onViewCaptured(capturedChild, activePointerId);
mDragOriLeft = capturedChild.getLeft();
mDragOriTop = capturedChild.getTop();
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
mDragHelper.settleCapturedViewAt(mDragOriLeft, mDragOriTop);
invalidate();
}
});
}
@Override
public void computeScroll() {
super.computeScroll();
if (mDragHelper != null && mDragHelper.continueSettling(true)) {
invalidate();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
}
我们再看看效果
讲到这里的时候,其实 ViewDragHelper 已经差不多了。不过,还有一个比较重要的特性,那就是边缘触发。
边缘触发最常见的就是侧滑菜单了。
边缘触发时,只要在能够响应拖动的 View 的对应边缘上进行触摸便可以开始拖动了。
那么,用 ViewDragHelper 怎么来实现这样的行为呢?
首先,得声明 ViewDragHelper 能够识别哪些连续触摸行为。
void setEdgeTrackingEnabled (int edgeFlags)
edgeFlags 是一个整形变量,它代表了能够被识别的边缘。它的取值有
// 左边缘
public static final int EDGE_LEFT = 1 << 0;
// 右边缘
public static final int EDGE_RIGHT = 1 << 1;
// 上边缘
public static final int EDGE_TOP = 1 << 2;
// 下边缘
public static final int EDGE_BOTTOM = 1 << 3;
// 所有边缘
public static final int EDGE_ALL = EDGE_LEFT | EDGE_TOP | EDGE_RIGHT | EDGE_BOTTOM;
因为涉及到位操作,所以 edgeFlags 的取值可以通过或操作多重组合。比如
//识别左边缘和上边缘
setEdgeTrackingEnabled(EDGE_LEFT | EDGE_TOP)
然后,ViewDragHelper 就可以识别边缘触摸了。在 ViewDragHelper.Callback() 中它也有对应的回调方法。
/**边缘拖拽开始*/
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
super.onEdgeDragStarted(edgeFlags, pointerId);
}
/**边缘被点击*/
@Override
public void onEdgeTouched(int edgeFlags, int pointerId) {
super.onEdgeTouched(edgeFlags, pointerId);
}
我们一般在 onEdgeDragStarted() 方法中处理。如大家所见,这个方法只是通知了开发者边缘拖拽开始,但是它并没有提供 View 类型的参数,所以,它的目的也很明确,就是只提供边缘拖拽的信息,至于具体哪个 child 将被拖拽,这个权力交给开发者自己。
在正常的项目开发中,一般只有某个或者某些 child 才针对特定的边缘拖拽进行响应。
所以,在 onEdgeDragStarted() 方法中,我们应该手动捕获这些 child,让它们成为拖拽的现象。
之前,在回调方法
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
public void processTouchEvent(MotionEvent ev) {
switch (action) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
final int pointerId = ev.getPointerId(0);
//查找界面上最上面的子视图根据手指按下的x y 坐标
final View toCapture = findTopChildUnder((int) x, (int) y);
tryCaptureViewForDrag(toCapture, pointerId);
break;
}
}
}
boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
//我们重写的尝试捕获视图为true 才会捕获子示图
if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
captureChildView(toCapture, pointerId);
return true;
}
return false;
}
public View findTopChildUnder(int x, int y) {
final int childCount = mParentView.getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View child = mParentView.getChildAt(mCallback.getOrderedChildIndex(i));
if (x >= child.getLeft() && x < child.getRight()
&& y >= child.getTop() && y < child.getBottom()) {
return child;
}
}
return null;
}
Callback.tryCaptureView() 在 captureChildView() 之前调用。
public void captureChildView(View childView, int activePointerId) {
if (childView.getParent() != mParentView) {
throw new IllegalArgumentException("captureChildView: parameter must be a descendant "
+ "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
}
mCapturedView = childView;
mActivePointerId = activePointerId;
mCallback.onViewCaptured(childView, activePointerId);
setDragState(STATE_DRAGGING);
}
如源码所示,这个方法直接将 childView 设置为 mCaptureView,然后调用 mCallback 的回调方法 onViewCaptured()。
我们继续做试验。现在,我们的目标是让最上层的 childview 响应边缘触发的效果。于是,我们可以这样更改代码。
public TestViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return true;
}
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
super.onViewCaptured(capturedChild, activePointerId);
mDragOriLeft = capturedChild.getLeft();
mDragOriTop = capturedChild.getTop();
Log.d(TAG, "onViewCaptured: left:" + mDragOriLeft
+ " top:" + mDragOriTop);
}
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
super.onEdgeDragStarted(edgeFlags, pointerId);
Log.d(TAG, "onEdgeDragStarted: " + edgeFlags);
mDragHelper.captureChildView(getChildAt(getChildCount() - 1), pointerId);
}
@Override
public void onEdgeTouched(int edgeFlags, int pointerId) {
super.onEdgeTouched(edgeFlags, pointerId);
Log.d(TAG, "onEdgeTouched: " + edgeFlags);
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return top;
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
mDragHelper.settleCapturedViewAt((int) mDragOriLeft, (int) mDragOriTop);
invalidate();
}
});
mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_ALL);
}
所谓边缘,其实是 childview 共同的边缘。也是上图红框的部分,大家需要注意。
/**
* Enable edge tracking for the selected edges of the parent view.
* The callback's {@link Callback#onEdgeTouched(int, int)} and
* {@link Callback#onEdgeDragStarted(int, int)} methods will only be invoked
* for edges for which edge tracking has been enabled.
*
* @param edgeFlags Combination of edge flags describing the edges to watch
* @see #EDGE_LEFT
* @see #EDGE_TOP
* @see #EDGE_RIGHT
* @see #EDGE_BOTTOM
*/
public void setEdgeTrackingEnabled(int edgeFlags) {
mTrackingEdges = edgeFlags;
}
代码注释上说明了,边缘触发的边指得是 ViewGroup 本身的 4 条边,而非我之前想像的是某个 childview 的边缘。
边缘滑动的边指 ViewGroup 本身的 4 条边
到这里,边缘拖拽的效果也实现了,那么 ViewDragHelper 还有什么有趣的操作方法呢?可能大家对这两个会感兴趣。
//快速滚动的意思,一般手指离开后 view 还会由于惯性继续滑动。
void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop);
// 让 child 平滑地滑动到某个位置
boolean smoothSlideViewTo(View child, int finalLeft, int finalTop)
我们先来试验 flingCapturedView() 的效果。
之前,我们在 onViewRelease() 回调方法中让被拖拽的 child 回到原来的位置。现在,做少许改变,其它 child 仍然保持这一行为,但最低层的 child 被拖拽后会进行习惯性动作继续滑行一段距离。
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
View child = getChildAt(0);
if ( child != null && child == releasedChild ) {
mDragHelper.flingCapturedView(getPaddingLeft(),getPaddingTop(),
getWidth()-getPaddingRight()-child.getWidth(),
getHeight()-getPaddingBottom()-child.getHeight());
} else {
mDragHelper.settleCapturedViewAt((int)mDragOriLeft,(int)mDragOriTop);
}
invalidate();
}
效果如下:
至于 smoothSlideViewTo() 这个方法,我们可以在 TestViewGroup 中编写这样的代码
public void testSmoothSlide(boolean isReverse) {
if ( mDragHelper != null ) {
View child = getChildAt(1);
if ( child != null ) {
if ( isReverse ) {
mDragHelper.smoothSlideViewTo(child,
getLeft(),getTop());
} else {
mDragHelper.smoothSlideViewTo(child,
getRight()-child.getWidth(),
getBottom()-child.getHeight());
}
invalidate();
}
}
}
然后在外部,用来一个 Button 来控制它。
<?xml version="1.0" encoding="utf-8"?>
<com.example.scrollertest.TestViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:id="@+id/testDragViewGroup"
android:layout_height="match_parent">
<TextView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_marginLeft="20dp"
android:layout_marginTop="10dp"
android:background="#03A9F4"
android:gravity="center"
android:text="教育就是解放心灵"
android:textColor="@android:color/white"
android:textSize="16sp" />
<TextView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_marginLeft="160dp"
android:layout_marginTop="200dp"
android:background="#FF9800"
android:gravity="center"
android:text="教育就是解放心灵"
android:textColor="@android:color/white"
android:textSize="16sp" />
<TextView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_marginLeft="40dp"
android:layout_marginTop="420dp"
android:background="#8BC34A"
android:gravity="center"
android:text="教育就是解放心灵"
android:textColor="@android:color/white"
android:textSize="16sp" />
<Button
android:layout_width="wrap_content"
android:text="test"
android:id="@+id/btnTest"
android:layout_marginLeft="20dp"
android:layout_gravity="bottom"
android:layout_marginBottom="40dp"
android:layout_height="wrap_content"/>
</com.example.scrollertest.TestViewGroup>
效果如下:
文章到了这里,正式结束了。可以看到 ViewDragHelper 也没有想像中的那么难。
总结
现在,对这篇博文进行一点知识点的回顾。
不借助于 ViewDragHelper ,自己也能实现拖拽功能,比如博文的例子,比如 Launcher2 工程中相关的代码。
ViewDragHelper 是一个工具类,为拖拽而生,它提供了一系列的方法和回调方法用来操纵拖拽及跟踪 child 被拖拽时的位置、状态。
回调方法 tryCaptureView() 返回值为 true 时,ViewDragHelper 才能拖动对应的 child。但是可以直接调用 captureChildView() 方法来指定被拖动的 child。
ViewDragHelper 要在 ViewGroup 中的 onInterceptTouchEvent() 方法中调用 shouldInterceptTouchEvent() 方法,然后在 ViewGroup 中的 onTouchEvent() 方法调用 processTouchEvent()。
ViewDragHelper 内部有一个 Scroller 变量,所以涉及到位移动画如 settleCapturedViewAt()、flingCapturedView()、smoothSlideViewTo() 方法时要复写 ViewGroup 的 computeScroll() 方法,在这个方法中调用 ViewDragHelper 的 continueSettling()。