前言 在如今 app 泛滥的年代里,越来越多的开发者注重用户体验这个方面了。其中,有很多的 app 都有一种功能,那就是滑动返回。比如知乎、百度贴吧等,用户在使用这一类的 app 都可以滑动返回上一个页面。不得不说这个设计很赞,是不是心动了呢?那就继续往下看吧!
在GitHub上有实现该效果的开源库 SwipeBackLayout ,可以看到该库发展得已经非常成熟了。仔细看源码你会惊奇地发现其中的奥秘,没错,正是借助了 ViewDragHelper 来实现滑动返回的效果。ViewDragHelper 我想不必多说了,在我的博客中有很多的效果都是通过它来实现的。那么,下面我们就使用 ViewDragHelper 来实现这个效果吧。
自定义属性 首先,我们应该先定义几个自定义属性,比如说支持用户从左边或者右边滑动返回,丰富用户的选择性。所以现在 attrs.xml 中定义如下属性:
1 2 3 4 5 6 7 8 9 <?xml version="1.0" encoding="utf-8" ?> <resources > <declare-styleable name ="SwipeBackLayout" > <attr name ="swipe_mode" format ="enum" > <enum name ="left" value ="0" /> <enum name ="right" value ="1" /> </attr > </declare-styleable > </resources >
从上面的 xml 中可知,定义了一个枚举属性,左边为0,右边为1。
然后主角 SwipeBackLayout 就要登场了。
public class SwipeBackLayout extends FrameLayout {
private ViewDragHelper mViewDragHelper;
// 主界面
private View mainView;
// 主界面的宽度
private int mainViewWidth;
// 模式,默认是左滑
private int mode = MODE_LEFT;
// 监听器
private SwipeBackListener listener;
// 是否支持边缘滑动返回, 默认是支持
private boolean isEdge = true;
private int mEdge;
// 阴影Drawable
private Drawable shadowDrawable;
// 阴影Drawable固有宽度
private int shadowDrawbleWidth;
// 已经滑动的百分比
private float movePercent;
// 滑动的总长度
private int totalWidth;
// 默认的遮罩透明度
private static final int DEFAULT_SCRIM_COLOR = 0x99000000;
// 遮罩颜色
private int scrimColor = DEFAULT_SCRIM_COLOR;
// 透明度
private static final int ALPHA = 255;
private Paint mPaint;
/**
* 滑动的模式,左滑
*/
public static final int MODE_LEFT = 0;
/**
* 滑动的模式,右滑
*/
public static final int MODE_RIGHT = 1;
// 最小滑动速度
private static final int MINIMUM_FLING_VELOCITY = 400;
private static final String TAG = "SwipeBackLayout";
public SwipeBackLayout(Context context) {
this(context, null);
}
public SwipeBackLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeBackLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SwipeBackLayout);
// 得到滑动模式,默认左滑
mode = a.getInt(R.styleable.SwipeBackLayout_swipe_mode, MODE_LEFT);
a.recycle();
initView();
}
...
}
initView 在构造器主要做的就是得到滑动模式,默认是左边滑动。之后调用 initView()
。那么我们来看看 initView()
的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 private void initShadowView () { if (Build.VERSION.SDK_INT >= 21 ) { shadowDrawable = getResources().getDrawable(mode == MODE_LEFT ? R.drawable.shadow_left : R.drawable.shadow_right, getContext().getTheme()); } else { shadowDrawable = getResources().getDrawable(mode == MODE_LEFT ? R.drawable.shadow_left : R.drawable.shadow_right); } if (shadowDrawable != null ) { shadowDrawbleWidth = shadowDrawable.getIntrinsicWidth(); } } private void initView () { float density = getResources().getDisplayMetrics().density; float minVel = density * MINIMUM_FLING_VELOCITY; initShadowView(); mViewDragHelper = ViewDragHelper.create(this , new ViewDragHelper .Callback() { @Override public boolean tryCaptureView (View child, int pointerId) { return mainView == child; } @Override public int clampViewPositionHorizontal (View child, int left, int dx) { switch (mode) { case MODE_LEFT: if (left < 0 ) { return 0 ; } else if (Math.abs(left) > totalWidth) { return totalWidth; } else { return left; } case MODE_RIGHT: if (left > 0 ) { return 0 ; } else if (Math.abs(left) > totalWidth) { return -totalWidth; } else { return left; } default : return left; } } @Override public void onViewPositionChanged (View changedView, int left, int top, int dx, int dy) { switch (mode) { case MODE_LEFT: movePercent = left * 1f / totalWidth; Log.i(TAG, "movePercent = " + movePercent); break ; case MODE_RIGHT: movePercent = Math.abs(left) * 1f / totalWidth; Log.i(TAG, "movePercent = " + movePercent); break ; } } @Override public int getViewHorizontalDragRange (View child) { if (mode == MODE_LEFT) { return Math.abs(totalWidth); } else { return -totalWidth; } } @Override public void onViewReleased (View releasedChild, float xvel, float yvel) { switch (mode) { case MODE_LEFT: if (xvel > -mViewDragHelper.getMinVelocity() && Math.abs(releasedChild.getLeft()) > mainViewWidth / 2.0f ) { swipeBackToFinish(totalWidth, 0 ); } else if (xvel > mViewDragHelper.getMinVelocity()) { swipeBackToFinish(totalWidth, 0 ); } else { swipeBackToRestore(); } break ; case MODE_RIGHT: if (xvel < mViewDragHelper.getMinVelocity() && Math.abs(releasedChild.getLeft()) > mainViewWidth / 2.0f ) { swipeBackToFinish(-totalWidth, 0 ); } else if (xvel < -mViewDragHelper.getMinVelocity()) { swipeBackToFinish(-totalWidth, 0 ); } else { swipeBackToRestore(); } break ; } } }); mViewDragHelper.setMinVelocity(minVel); } @Override protected void onFinishInflate () { super .onFinishInflate(); int count = getChildCount(); if (count == 1 ) { mainView = getChildAt(0 ); } else { throw new IllegalArgumentException ("the child of swipebacklayout can not be empty and must be the one" ); } } @Override protected void onSizeChanged (int w, int h, int oldw, int oldh) { super .onSizeChanged(w, h, oldw, oldh); mainViewWidth = w; totalWidth = mainViewWidth + shadowDrawbleWidth; }
在 initView()
中,设置了 mViewDragHelper 的最小滑动速度,并且设置了 mViewDragHelper 回调的接口。回调接口中的方法都有注释,相信大家应该都能看懂。另外在 initView()
中初始化了阴影图片,以备下面中使用。
drawChild 想要阴影在滑动中绘制出来,我们必须重写 drawChild(Canvas canvas, View child, long drawingTime)
方法,并且在 onTouchEvent(MotionEvent event)
里 invalidate()
,保证用户滑动过程中调用 drawChild(Canvas canvas, View child, long drawingTime)
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Override protected boolean drawChild (Canvas canvas, View child, long drawingTime) { Log.i(TAG, "" + (mViewDragHelper.getViewDragState() == ViewDragHelper.STATE_IDLE)); if (child == mainView && mViewDragHelper.getViewDragState() != ViewDragHelper.STATE_IDLE) { drawShadowDrawable(canvas, child); drawScrimColor(canvas, child); } return super .drawChild(canvas, child, drawingTime); } @Override public boolean onTouchEvent (MotionEvent event) { mViewDragHelper.processTouchEvent(event); invalidate(); return true ; }
在 drawChild(Canvas canvas, View child, long drawingTime)
中调用 drawShadowDrawable(Canvas canvas, View child)
来绘制阴影以及 drawScrimColor(Canvas canvas, View child)
来绘制遮罩层。下面分别是两个方法的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 private void drawShadowDrawable (Canvas canvas, View child) { Rect drawableRect = new Rect (); child.getHitRect(drawableRect); if (mode == MODE_LEFT) { shadowDrawable.setBounds(drawableRect.left - shadowDrawbleWidth, drawableRect.top, drawableRect.left, drawableRect.bottom); } else { shadowDrawable.setBounds(drawableRect.right, drawableRect.top, drawableRect.right + shadowDrawbleWidth, drawableRect.bottom); } shadowDrawable.setAlpha((int ) ((1 - movePercent > 0.3 ? 1 - movePercent : 0.3 ) * ALPHA)); shadowDrawable.draw(canvas); } private void drawScrimColor (Canvas canvas, View child) { int baseAlpha = (scrimColor & 0xFF000000 ) >>> 24 ; int alpha = (int ) (baseAlpha * (1 - movePercent)); int color = alpha << 24 | (scrimColor & 0xffffff ); Rect rect; if (mode == MODE_LEFT) { rect = new Rect (0 , 0 , child.getLeft(), getHeight()); } else { rect = new Rect (child.getRight(), 0 , getWidth(), getHeight()); } mPaint = new Paint (Paint.ANTI_ALIAS_FLAG); mPaint.setColor(color); canvas.drawRect(rect, mPaint); }
mainView 、阴影、遮罩层的关系示意图如下:
onViewReleased 看完了上面的两个方法的代码,最后就是当用户手指抬起时判断逻辑的代码了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public void swipeBackToFinish (int finalLeft, int finalTop) { if (mViewDragHelper.smoothSlideViewTo(mainView, finalLeft, finalTop)) { ViewCompat.postInvalidateOnAnimation(this ); } if (listener != null ) { listener.onSwipeBackFinish(); } } public void swipeBackToRestore () { if (mViewDragHelper.smoothSlideViewTo(mainView, 0 , 0 )) { ViewCompat.postInvalidateOnAnimation(this ); } } @Override public void computeScroll () { super .computeScroll(); if (mViewDragHelper.continueSettling(true )) { ViewCompat.postInvalidateOnAnimation(this ); } } public interface SwipeBackListener { void onSwipeBackFinish () ; } public void setSwipeBackListener (SwipeBackListener listener) { this .listener = listener; }
相应的代码还是比较简单的,主要使用了 smoothSlideViewTo(View view, int left, int top)
的方法来滑动到指定位置。若是结束当前界面的话,回调监听器的接口。
啰嗦了这么多,我们来看看运行时的效果图吧:
尾语 好了,SwipeBackLayout 大致的逻辑就是上面这样子的。整体来说还是比较通俗易懂的,而且对 ViewDragHelper 熟悉的人会发现,使用 ViewDragHelper 自定义一些 ViewGroup 的套路都是大同小异的。以后想要自定义一些 ViewGroup 都是得心应手了。