ViewDragHelper 解析以及側滑控件實現
在前一篇文章 從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 。
寫的比較的簡潔,其中還有很多可以完善的地方,比如添加開閉按鈕,判斷更準確一點,不過這些都是后續的小細節,這里為的是簡單但不是主體。
來自:http://www.jianshu.com/p/3ef87f7b0a1d