前言 ====== 在如今 app 泛滥的年代里,越来越多的开发者注重用户体验这个方面了。其中,有很多的 app 都有一种功能,那就是滑动返回。比如知乎、百度贴吧等,用户在使用这一类的 app 都可以滑动返回上一个页面。不得不说这个设计很赞,是不是心动了呢?那就继续往下看吧!
在GitHub上有实现该效果的开源库 SwipeBackLayout ,可以看到该库发展得已经非常成熟了。仔细看源码你会惊奇地发现其中的奥秘,没错,正是借助了 ViewDragHelper 来实现滑动返回的效果。ViewDragHelper 我想不必多说了,在我的博客中有很多的效果都是通过它来实现的。那么,下面我们就使用 ViewDragHelper 来实现这个效果吧。
自定义属性
首先,我们应该先定义几个自定义属性,比如说支持用户从左边或者右边滑动返回,丰富用户的选择性。所以现在 attrs.xml 中定义如下属性:
代码语言:javascript复制<?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 就要登场了。
代码语言:javascript复制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()
的代码:
// 初始化阴影Drawable
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) { // 如果当前已经滑动超过子View宽度的一半,并且速度符合预期设置
swipeBackToFinish(totalWidth, 0); // 把当前界面finish
} 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) { // 子View只能有一个
// 获取子view
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;
//总长度,包含了mainView的宽度以及阴影图片的宽度
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)
方法。
@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)
来绘制遮罩层。下面分别是两个方法的源码:
// 绘制阴影
private void drawShadowDrawable(Canvas canvas, View child) {
Rect drawableRect = new Rect();
// 得到mainView的矩形
child.getHitRect(drawableRect);
// 设置shadowDrawable绘制的矩形
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的透明度,最低为0.3
shadowDrawable.setAlpha((int) ((1 - movePercent > 0.3 ? 1 - movePercent : 0.3) * ALPHA));
// 将shadowDrawable绘制在canvas上
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
看完了上面的两个方法的代码,最后就是当用户手指抬起时判断逻辑的代码了:
代码语言:javascript复制/**
* 滑动返回,结束该View
*/
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();
}
/**
* 设置滑动返回监听器
*
* @param listener
*/
public void setSwipeBackListener(SwipeBackListener listener) {
this.listener = listener;
}
相应的代码还是比较简单的,主要使用了 smoothSlideViewTo(View view, int left, int top)
的方法来滑动到指定位置。若是结束当前界面的话,回调监听器的接口。
啰嗦了这么多,我们来看看运行时的效果图吧:
滑动返回效果gif
尾语
好了,SwipeBackLayout 大致的逻辑就是上面这样子的。整体来说还是比较通俗易懂的,而且对 ViewDragHelper 熟悉的人会发现,使用 ViewDragHelper 自定义一些 ViewGroup 的套路都是大同小异的。以后想要自定义一些 ViewGroup 都是得心应手了。
如果对此有疑问的话可以在下面留言。
最后,国际惯例,附上 SwipeBackLayout Demo 的源码:
SwipeBackDemo.rar
Goodbye!