在前一篇文章从PhotoView看Android手势监听实践中,介绍了PhotoView这一控件的手势控制的分析,其中有三个主要行为的触发,Drag,Fling,Scale,而在PhotoView的实现中除了Scale采取的是一个ScaleGestureDetector这样的一个高级类,前面两种行为都是依赖原生的手势来判断,十分的麻烦,代码量也很大, 那么这两个有没有比较简单实用的类呢?
结论自然是肯定的,这篇文章要介绍的就是这么一个闪亮的存在,ViewDragHelper。先看一下官方对这个类的一个定义。
ViewDragHelper是一个在自定义ViewGroup中十分实用的类,它提供了一系列有用的操作和状态追踪来帮助用户实现在一个ViewGroup内拖动View或者复位 。
总体设计
ViewDragHelper 只有一个类,但是内部还有一个抽象类CallBack。
CallBack中有一系列方法,用来设置许多属性,可拖动的范围,边缘检测,哪个View触发拖动等等。这个CallBack是在初始化一个ViewDragHelper 时的必要参数。
除了CallBack之外,ViewDragHelper 依然是通过 shouldInterceptTouchEvent和 processTouchEvent 以及设置的属性来设置状态判断拖动,不过这些被封装后就不需要我们自己写了,省时省力,ViewDragHelper 内部实际上是一个小型状态机,在IDLE,DRAGGING,SETTLING三种状态之间切换。
流程图
这个图是我们在使用一个ViewDragHelper 所需要做的事情,ViewDragHelper使用一个静态的方式来创建一个对象
public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb) {
final ViewDragHelper helper = create(forParent, cb);
helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));
return helper;
}
第一个参数就是ParentView的引用,第二个参数是一个触发的灵敏程度,默认为1.0,第三个就是图中的自定义的CallBack。
在CallBack中,我们需要根据自己的需要实现对应的方法,总体来说主要是上图中的几个方法:
tryCaptureView: 在这个方法中,我们会去声明我们想要产生Drag的View,这个方法是有返回值的,只有在返回true的情况下,才有权限去真正的产生Drag的行为,我们直接看这个方法在源码中的调用
boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
if (toCapture == mCapturedView && mActivePointerId == pointerId) {
// Already done!
return true;
}
if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
mActivePointerId = pointerId;
captureChildView(toCapture, pointerId);
return true;
}
return false;
}
toCapture
也就是我们现在手指所在的View,mCapturedView
就是ViewDragHelper 中当前已经有Drag状态的View,实际上即使已经产生了拖动,这个方法依然会不断的触发,在手指Id和View都相同的情况下,就直接return true,如果是第一次,这里的mCallback.tryCaptureView(toCapture, pointerId)
的返回值决定了是否会走到条件语句之内,因此需要在实现的时候如果想要触发Drag,这个方法一定要返回true。
onEdgeDragStarted:如果我们设置了可以在边缘触摸滑动,那么可以在这个方法中实现一个侧滑的效果,通过手动调用ViewDragHelper的 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);
}
这个方法可以摆脱前面 tryCaptureView 需要返回true的一个限制,即使返回false,在这里依然能够将传进来的childView的状态置为STATE_DRAGGING。
clampViewPositionVertical: 这个方法还有一个对应方法,这两个方法主要是用来指定DragView的活动范围
clampViewPositionVertical(View child, int top, int dy)
case MotionEvent.ACTION_MOVE: {
if (mDragState == STATE_DRAGGING) {
// If pointer is invalid then skip the ACTION_MOVE.
if (!isValidPointerForActionMove(mActivePointerId)) break;
final int index = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(index);
final float y = ev.getY(index);
final int idx = (int) (x - mLastMotionX[mActivePointerId]);
final int idy = (int) (y - mLastMotionY[mActivePointerId]);
dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);
saveLastMotion(ev);
...
private void dragTo(int left, int top, int dx, int dy) {
int clampedX = left;
int clampedY = top;
final int oldLeft = mCapturedView.getLeft();
final int oldTop = mCapturedView.getTop();
if (dx != 0) {
clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
}
if (dy != 0) {
clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
}
在ACTION_MOVE的时候,根据移动的距离delta,调用了dragTo的方法,在这里由我们实现的clampViewPositionVertical
方法根据一系列参数,返回了一个最后的X,Y坐标,通过ViewCompat的两个方法来实现View的位置变换,从上面的变换可以看出我们需要返回的是View最终能到达的地方。
onViewReleased: 这个就是在手指抬起的时候或者超出边界了会触发,如果想实现一个侧滑菜单,那么在这里可以根据给予的速度的参数来决定是否去打开或者关闭菜单。
除了CallBack之外,还有一个重要的点,那就是ViewDragHelper 怎么与MotionEvent连接起来,我们在创建ViewDragHelper 实例的时候需要传入一个ParentView,这是一个ViewGroup,我们需要drag的view就是这个父控件的子View,所以我们需要在onInterceptTouchEvent的时候采取ViewDragHelper 的shouldInterceptTouchEvent
方法
return mDragState == STATE_DRAGGING;
这个方法的返回是一个判断语句,判断是否是Drag状态,那么肯定有一个设置状态的地方
if (toCapture == mCapturedView && mDragState == STATE_SETTLING) {
tryCaptureViewForDrag(toCapture, pointerId);
}
在down和Pointer_down的时候去判断能不能设置这个状态,不过前面就说了,对于边缘检测型,拦不拦无所谓,直接可以绕过tryCaptureView那一关,对于直接Drag的还是需要的,不过事件可能被子View截获了。
除了这个之外,我们还需要实现一个onTouchEvent,ViewDragHelper 也提供了一个对应的方法 processTouchEvent
,这个主要就是用来drag view用的,这里最关键的就是onTouchEvent这个方法的返回值,具体情况具体分析,如果返回true,后续的所有事件就都由这个父控件接送了,那么自然drag行为也就可以触发了。如果不返回true,那么除了down事件外,没有别的事件可以接收了,除非边缘是一个有点击事件的子view。
侧滑实现
分析了那么多,还是模仿一个侧滑的实现,效果十分的简单
如果不使用ViewDragHelper,那么这个需要多长的代码不清楚,但是使用ViewDragHelper,这个效果不需要100行。先放代码
public class NavigationView extends LinearLayout {
private static final String TAG = "NavigationView";
private static final int RIGHT = 100;
private static final int MIN_VELOCITY = 300;
private static float density;
private ViewDragHelper mDragHelper;
private View mContent;
private View mMenu;
public NavigationView(Context context) {
this(context, null);
}
public NavigationView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public NavigationView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(HORIZONTAL);
mDragHelper = ViewDragHelper.create(this, new CustomCallBack());
mDragHelper.setEdgeTrackingEnabled(EDGE_LEFT);
density = getResources().getDisplayMetrics().density;
}
private class CustomCallBack extends ViewDragHelper.Callback {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return child == mMenu;
}
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
mDragHelper.captureChildView(mMenu,pointerId);
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
int newLeft = Math.max(-child.getWidth(),Math.min(left,0));
return newLeft;
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
invalidate();
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
if (xvel > MIN_VELOCITY || releasedChild.getLeft() >-releasedChild.getWidth() * 0.5) {
mDragHelper.settleCapturedViewAt(0, releasedChild.getTop());
}else {
mDragHelper.settleCapturedViewAt(-releasedChild.getWidth(), releasedChild.getTop());
}
invalidate();
}
}
@Override
public void computeScroll() {
if (mDragHelper.continueSettling(true)){
invalidate();
}
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
int count = getChildCount();
if(count >= 2){
//简单写了 直接写死
mMenu = getChildAt(1);
mContent = getChildAt(0);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//如果menu的宽度是match_parent或者超过限制 那么就需要重新设置
int width = (int) (density * RIGHT);
if (mMenu.getMeasuredWidth() + width > getWidth()){
int menuWidthSpec = MeasureSpec.makeMeasureSpec(getWidth() -width,MeasureSpec.EXACTLY);
mMenu.measure(menuWidthSpec,heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (mMenu != null){
mMenu.layout(-mMenu.getMeasuredWidth(),t,0,mMenu.getMeasuredHeight());
}
if (mContent != null){
mContent.layout(0,0,mContent.getMeasuredWidth(),mContent.getMeasuredHeight());
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean event = mDragHelper.shouldInterceptTouchEvent(ev);
return event;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.e(TAG,"onTouchEvent" + event.toString());
mDragHelper.processTouchEvent(event);
return true;
}
}
这里尽量写的简单,但是核心的东西不会少,两个View,一个是侧滑里面的menu,一个是外面的主content。这里直接继承了LinearLayout ,measure时如果宽度过大,也会做一个限制,然后layout到屏幕外面去。
根据前面的方法的分析,这里的逻辑就一目了然了,设置一个左边边缘检测,在 onEdgeDragStarted
上面去drag我们的menu菜单,除此之外,在 onViewReleased
的时候根据速度和当前menu的位置判断后去设置最终滑动的位置,这里是一个Scroller,所有务必实现一个 computeScroll
。
写的比较的简洁,其中还有很多可以完善的地方,比如添加开闭按钮,判断更准确一点,不过这些都是后续的小细节,这里为的是简单但不失主体。
整个源码在github上: https://github.com/sheepm/ViewDragHelper_Sample