现在很多 App 上查看图片时都有手势下拉关闭图片的效果,个人非常非常喜欢这种交互,就想也仿制一个。
在 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 > 0 && (Math.abs(deltaY) > ViewConfiguration.get(getContext()).getScaledTouchSlop() * 2 && Math.abs(deltaX) < Math.abs(deltaY) / 2)
|| mTracking) {
onSwipeListener.onSwiping(deltaY);
setBackgroundColor(Color.TRANSPARENT);
mTracking = true;
// 移动缩放整个 Layout
setTranslationY(mInitialTy + deltaY);
setTranslationX(mInitialTx + deltaX);
float mScale = 1 - deltaY / mHeight;
if (mScale < 0.3) {
mScale = 0.3f;
}
setScaleX(mScale);
setScaleY(mScale);
}
if (deltaY < 0) {
setViewDefault();
}
break;
case MotionEvent.ACTION_UP:
if (mTracking) {
mTracking = false;
float currentTranslateY = getTranslationY();
if (currentTranslateY > mHeight / 3) {
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 != 0 && !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 * 0.5f > yDiff) {
if (DEBUG) Log.v(TAG, "Starting drag!");
mIsBeingDragged = true;
requestParentDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
mLastMotionX = dx > 0
? 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(0);
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.
*
* 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.
*
* @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(0);
PhotoView photoView = view.findViewById(R.id.welfare_img);
if (photoView.getScale() != 1.0) {
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 > 0 &&
(Math.abs(deltaY) > touchSlop * 2 &&
Math.abs(deltaX) < Math.abs(deltaY) / 2)) {
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 = 1 - deltaY / 1000;
if (mScale < 0.3) {
mScale = 0.3f;
}
rv.setScaleX(mScale);
rv.setScaleY(mScale);
rl_black_bg.setAlpha(mScale);
break;
case MotionEvent.ACTION_UP:
float currentTranslateY = rv.getTranslationY();
if (currentTranslateY > 1000 / 3) {
finish();
break;
}
saveBtn.setVisibility(View.VISIBLE);
textView.setVisibility(View.VISIBLE);
rv.setAlpha(1);
rv.setTranslationX(0);
rv.setTranslationY(0);
rv.setScaleX(1);
rv.setScaleY(1);
rv.setBackgroundColor(Color.BLACK);
break;
default:
break;
}
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
});
完整的修改链接:https://github.com/Cr321/GankIO/commit/e6e69127f95fb09722ef169c76fd7303fa093330
这次重新造轮子的过程收获颇多,不仅让自己巩固了 Android 触摸事件分发机制,也顺便学习了 ViewPager 和 RecyclerView 这两种常用布局的触摸事件分发实现。重新造轮子的意义不正在于此,学习优秀的代码,巩固自己的知识吗?