現在很多 App 上檢視圖檔時都有手勢下拉關閉圖檔的效果,個人非常非常喜歡這種互動,就想也仿制一個。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICM38CXlZHbvN3cpR2Lc1TPB10QGtWUCpEMJ9CXsxWam9CXwADNvwVZ6l2c052bm9CXUJDT1wkNhVzLcRnbvZ2LcxWMXlVdWJjWxY0MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2LcRHelR3LcJzLctmch1mclRXY39TN4QDNxUDN1ETOxgDM4EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
在 GitHub 上找到一個現成的 repo:MNImageBrowser 已經實作了相應的效果,這個庫設計的挺不錯的,也有很豐富的定制 API,不過仔細看過代碼以後發現一個問題,圖檔切換用的是 ViewPager,ViewPager 在展示大量圖檔時有發生 OOM 的風險,雖然也是有對應解決辦法的,但我還是想改用 RecyclerView 重新實作一下。
先學習一下 MNImageBrowser 實作相關效果的核心原理,他是在 ViewPager 外面又包了一個自定義的布局來攔截處理 MotionEvent,通過計算 Y 軸上的移動距離來移動縮放整個布局:
<RelativeLayout
android:id="@+id/rl_black_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000" />
<com.maning.imagebrowserlibrary.view.MNGestureView
android:id="@+id/mnGestureView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.maning.imagebrowserlibrary.view.MNViewPager
android:id="@+id/viewPagerBrowser"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.maning.imagebrowserlibrary.view.MNGestureView>
自定義布局 MNGestureView 中的核心代碼都在 onInterceptTouchEvent 中:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// 記錄手指觸摸螢幕的初始位置
mDisplacementX = event.getRawX();
mDisplacementY = event.getRawY();
mInitialTy = getTranslationY();
mInitialTx = getTranslationX();
break;
case MotionEvent.ACTION_MOVE:
// get the delta distance in X and Y direction
float deltaX = event.getRawX() - mDisplacementX;
float deltaY = event.getRawY() - mDisplacementY;
//隻有在不縮放的狀态才能下滑
if(onCanSwipeListener!= null){
boolean canSwipe = onCanSwipeListener.canSwipe();
if(!canSwipe){
break;
}
}
// set the touch and cancel event
if (deltaY > && (Math.abs(deltaY) > ViewConfiguration.get(getContext()).getScaledTouchSlop() * && Math.abs(deltaX) < Math.abs(deltaY) / )
|| mTracking) {
onSwipeListener.onSwiping(deltaY);
setBackgroundColor(Color.TRANSPARENT);
mTracking = true;
// 移動縮放整個 Layout
setTranslationY(mInitialTy + deltaY);
setTranslationX(mInitialTx + deltaX);
float mScale = - deltaY / mHeight;
if (mScale < ) {
mScale = f;
}
setScaleX(mScale);
setScaleY(mScale);
}
if (deltaY < ) {
setViewDefault();
}
break;
case MotionEvent.ACTION_UP:
if (mTracking) {
mTracking = false;
float currentTranslateY = getTranslationY();
if (currentTranslateY > mHeight / ) {
onSwipeListener.downSwipe();
break;
}
}
setViewDefault();
onSwipeListener.overSwipe();
break;
}
// 始終傳回 false,不攔截觸摸事件
return false;
}
原理其實非常簡單,于是我就興高采烈地隻把布局中的 ViewPager 換成 RecyclerView了。結果一試試出了問題,手指向下滑動的過程中隻要稍稍在 X 軸方向上移動的距離大一些,RecyclerView 就會開始接管觸摸事件,外面的那層自定義的布局就再也收不到觸摸事件,即 RecyclerView 的 onInterceptTouchEvent 傳回 true 了。這樣外層的布局不僅無法再收到 ACTION_MOVE 事件随手指的滑動而移動,也收不到最後的 ACTION_UP 事件,即無法決定最後是退出界面還是恢複原來位置和大小。效果可以說是相當的失敗。
仔細分析一下,出現這個問題的原因也很簡單,由于自定義布局中的 onInterceptTouchEvent 始終傳回的是 false,他隻是在觸摸事件分發到他的過程中做相應的處理,是以一旦他的子 View 中有任何一個 onInterceptTouchEvent 傳回了 true,那他将再也接受不到任何的觸摸事件。 那為啥 ViewPager 就一直傳回的是 false 呢,它不也是支援 X 軸方向上的滑動的嗎?
Read the fucking source code!!!
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
/*
* This method JUST determines whether we want to intercept the motion.
* If we return true, onMotionEvent will be called and we do the actual
* scrolling there.
*/
final int action = ev.getAction() & MotionEvent.ACTION_MASK;
// Always take care of the touch gesture being complete.
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
// Release the drag.
if (DEBUG) Log.v(TAG, "Intercept done!");
resetTouch();
return false;
}
// Nothing more to do here if we have decided whether or not we
// are dragging.
if (action != MotionEvent.ACTION_DOWN) {
if (mIsBeingDragged) {
if (DEBUG) Log.v(TAG, "Intercept returning true!");
return true;
}
if (mIsUnableToDrag) {
// 注釋 1
if (DEBUG) Log.v(TAG, "Intercept returning false!");
return false;
}
}
switch (action) {
case MotionEvent.ACTION_MOVE: {
/*
* mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
* whether the user has moved far enough from his original down touch.
*/
/*
* Locally do absolute value. mLastMotionY is set to the y value
* of the down event.
*/
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
// If we don't have a valid id, the touch down wasn't on content.
break;
}
final int pointerIndex = ev.findPointerIndex(activePointerId);
final float x = ev.getX(pointerIndex);
final float dx = x - mLastMotionX;
final float xDiff = Math.abs(dx);
final float y = ev.getY(pointerIndex);
final float yDiff = Math.abs(y - mInitialMotionY);
if (DEBUG) Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
if (dx != && !isGutterDrag(mLastMotionX, dx)
&& canScroll(this, false, (int) dx, (int) x, (int) y)) {
// Nested view has scrollable area under this point. Let it be handled there.
mLastMotionX = x;
mLastMotionY = y;
mIsUnableToDrag = true;
return false;
}
if (xDiff > mTouchSlop && xDiff * f > yDiff) {
if (DEBUG) Log.v(TAG, "Starting drag!");
mIsBeingDragged = true;
requestParentDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
mLastMotionX = dx >
? mInitialMotionX + mTouchSlop : mInitialMotionX - mTouchSlop;
mLastMotionY = y;
setScrollingCacheEnabled(true);
} else if (yDiff > mTouchSlop) {
// The finger has moved enough in the vertical
// direction to be counted as a drag... abort
// any attempt to drag horizontally, to work correctly
// with children that have scrolling containers.
if (DEBUG) Log.v(TAG, "Starting unable to drag!");
// 注釋 2
mIsUnableToDrag = true;
}
if (mIsBeingDragged) {
// Scroll to follow the motion event
if (performDrag(x)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
break;
}
case MotionEvent.ACTION_DOWN: {
/*
* Remember location of down touch.
* ACTION_DOWN always refers to pointer index 0.
*/
mLastMotionX = mInitialMotionX = ev.getX();
mLastMotionY = mInitialMotionY = ev.getY();
mActivePointerId = ev.getPointerId();
mIsUnableToDrag = false;
mIsScrollStarted = true;
mScroller.computeScrollOffset();
if (mScrollState == SCROLL_STATE_SETTLING
&& Math.abs(mScroller.getFinalX() - mScroller.getCurrX()) > mCloseEnough) {
// Let the user 'catch' the pager as it animates.
mScroller.abortAnimation();
mPopulatePending = false;
populate();
mIsBeingDragged = true;
requestParentDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
} else {
completeScroll(false);
mIsBeingDragged = false;
}
if (DEBUG) {
Log.v(TAG, "Down at " + mLastMotionX + "," + mLastMotionY
+ " mIsBeingDragged=" + mIsBeingDragged
+ "mIsUnableToDrag=" + mIsUnableToDrag);
}
break;
}
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;
}
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(ev);
/*
* The only time we want to intercept motion events is if we are in the
* drag mode.
*/
return mIsBeingDragged;
我們來想象一下,要觸發手勢下滑的手勢基本條件是什麼?肯定是 Y 軸方向上的移動距離要大于 X 軸方向上的距離。那麼在第一個 ACTION_MOVE 到達 ViewPager 的 onInterceptTouchEvent 的時候就會走到 注釋2 處将 mIsUnableToDrag 設為 true,後面的 ACTION_MOVE 事件再來的時候直接在 注釋1 處就直接傳回false了。
那我們現在要實作的其實也比較簡單了,就看第一下的 ACTION_MOVE,一旦确認了是向下的手勢,就攔下來處理。這樣一分析,其實我們也不必加一個自定義布局包住 RecyclerView 了,因為 RecyclerView 有一個方法:addOnItemTouchListener,看看 API 的注釋,這個監聽可以攔截發到子View的觸摸事件或者本來要由RecyclerView自身來處理的事件。
/**
* Add an {@link OnItemTouchListener} to intercept touch events before they are dispatched
* to child views or this view's standard scrolling behavior.
*
* <p>Client code may use listeners to implement item manipulation behavior. Once a listener
* returns true from
* {@link OnItemTouchListener#onInterceptTouchEvent(RecyclerView, MotionEvent)} its
* {@link OnItemTouchListener#onTouchEvent(RecyclerView, MotionEvent)} method will be called
* for each incoming MotionEvent until the end of the gesture.</p>
*
* @param listener Listener to add
* @see SimpleOnItemTouchListener
*/
public void addOnItemTouchListener(OnItemTouchListener listener) {
mOnItemTouchListeners.add(listener);
}
我的實作如下:
mRecyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
private float mDisplacementX;
private float mDisplacementY;
private float mInitialTy;
private float mInitialTx;
private boolean mTracking;
private boolean canSwipe(RecyclerView rv) {
View view = rv.getChildAt();
PhotoView photoView = view.findViewById(R.id.welfare_img);
if (photoView.getScale() != ) {
return false;
}
return true;
}
@Override
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mDisplacementX = event.getRawX();
mDisplacementY = event.getRawY();
mInitialTy = rv.getTranslationY();
mInitialTx = rv.getTranslationX();
break;
case MotionEvent.ACTION_MOVE:
float deltaX = event.getRawX() - mDisplacementX;
float deltaY = event.getRawY() - mDisplacementY;
//隻有在不縮放的狀态才能下滑
if(!canSwipe(rv)){
break;
}
if (deltaY > &&
(Math.abs(deltaY) > touchSlop * &&
Math.abs(deltaX) < Math.abs(deltaY) / )) {
mTracking = true;
return true;
}
default:
break;
}
return false;
}
@Override
public void onTouchEvent(RecyclerView rv, MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
saveBtn.setVisibility(View.GONE);
textView.setVisibility(View.GONE);
float deltaX = event.getRawX() - mDisplacementX;
float deltaY = event.getRawY() - mDisplacementY;
rv.setBackgroundColor(Color.TRANSPARENT);
rv.setTranslationY(mInitialTy + deltaY);
rv.setTranslationX(mInitialTx + deltaX);
float mScale = - deltaY / ;
if (mScale < ) {
mScale = f;
}
rv.setScaleX(mScale);
rv.setScaleY(mScale);
rl_black_bg.setAlpha(mScale);
break;
case MotionEvent.ACTION_UP:
float currentTranslateY = rv.getTranslationY();
if (currentTranslateY > / ) {
finish();
break;
}
saveBtn.setVisibility(View.VISIBLE);
textView.setVisibility(View.VISIBLE);
rv.setAlpha();
rv.setTranslationX();
rv.setTranslationY();
rv.setScaleX();
rv.setScaleY();
rv.setBackgroundColor(Color.BLACK);
break;
default:
break;
}
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
});
完整的修改連結:https://github.com/Cr321/GankIO/commit/e6e69127f95fb09722ef169c76fd7303fa093330
這次重新造輪子的過程收獲頗多,不僅讓自己鞏固了 Android 觸摸事件分發機制,也順便學習了 ViewPager 和 RecyclerView 這兩種常用布局的觸摸事件分發實作。重新造輪子的意義不正在于此,學習優秀的代碼,鞏固自己的知識嗎?