android 中如果要想让某个view横滑消失,一般情况我们需要取自定义这个view,通过触摸事件来移动它的位置,手指松开时判断位移是否达到了隐藏或删除的条件,这个一般比较麻烦,并且不同类型的控件都需要重新写一遍,如果有个抽取好的公共类或方法,适配所有view,那么我们就省力了。google 推出的 SwipeDismissBehavior 类就能满足这个条件,搭配着
CoordinatorLayout 使用,下面就介绍一下它的用法。
xml 布局
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ff0000"/>
<TextView
android:id="@+id/tv_swip"
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="#ff0000"
android:gravity="center"
android:layout_gravity="bottom"
android:text="Hello World 1"
android:textColor="#ffffff"
android:textSize="18sp"
app:layout_behavior="@string/behavior_swipe" />
</android.support.design.widget.CoordinatorLayout>
<string name="behavior_swipe">com.desigin.view.Behavior.SampleSwipeBehavior</string>
public class SampleSwipeBehavior extends SwipeDismissBehavior {
public SampleSwipeBehavior() {
}
public SampleSwipeBehavior(Context context, AttributeSet attrs) {
super();
}
}
如此,id为 tv_swip 的 TextView 便可以随着手指的滑动而横滑消失了。上一章讲了,拥有 Behavior 属性的 view,必须是 CoordinatorLayout 的直接子控件才有效,而通过xml方式使用 Behavior 属性,则定义的 Behavior 的构造方法必须支持两个参数的构造,SwipeDismissBehavior 中只有一个无参构造,所以直接使用会报错,因此我们自定义一个类来继承它,写个两个参数的构造方法,调用 SwipeDismissBehavior 的无参构造方法即可。为什么 SwipeDismissBehavior 可以帮助 view 实现滑动消失呢?
上一章分析了, 触摸事件分发,会通过 CoordinatorLayout 中分发,通过判断,会调用 Behavior 中的onInterceptTouchEvent() 方法,根据它返回的值决定是否调用 Behavior 中 onTouchEvent() 方法,我们看看 SwipeDismissBehavior 类中这两个方法
@Override
public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent event) {
switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// Reset the ignore flag
if (mIgnoreEvents) {
mIgnoreEvents = false;
return false;
}
break;
default:
mIgnoreEvents = !parent.isPointInChildBounds(child,
(int) event.getX(), (int) event.getY());
break;
}
if (mIgnoreEvents) {
return false;
}
ensureViewDragHelper(parent);
return mViewDragHelper.shouldInterceptTouchEvent(event);
}
@Override
public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent event) {
if (mViewDragHelper != null) {
mViewDragHelper.processTouchEvent(event);
return true;
}
return false;
}
onInterceptTouchEvent() 方法中 switch 方法,parent.isPointInChildBounds(child, (int) event.getX(), (int) event.getY()) 的意思是判断手指按下时,触摸点有没有在 child 所在位置的范围内,也就说手指按下时,有没有落在 SwipeDismissBehavior 对应的view上面,在xml中指的是 TextView。如果没落在 TextView 上面,mIgnoreEvents 值是取反,值为true,此时,在下一行if判断中,直接 return false,这是为什么呢?上面提到了 onInterceptTouchEvent() 方法会被从 CoordinatorLayout 中调用,如果没触摸到当前 TextView,它是没必要做多余的操作,所以这里来了个判断拦截。看看下面的方法 ensureViewDragHelper(parent),看看它的代码
private void ensureViewDragHelper(ViewGroup parent) {
if (mViewDragHelper == null) {
mViewDragHelper = mSensitivitySet
? ViewDragHelper.create(parent, mSensitivity, mDragCallback)
: ViewDragHelper.create(parent, mDragCallback);
}
}
在它里面,创建了一个 ViewDragHelper 对象,然后就是紧接着的 mViewDragHelper.shouldInterceptTouchEvent(event),看到这,就明白了,原来是用了 ViewDragHelper 辅助类,不清楚它源码的朋友可以看看前面的两章。既然 onInterceptTouchEvent() 中使用了 mViewDragHelper,那么 onTouchEvent() 方法中,果不其然,调用了它的 processTouchEvent() 方法。
看看 ViewDragHelper.Callback 的回调方法,先看看 tryCaptureView() 方法,这个是开头方法
public boolean tryCaptureView(View child, int pointerId) {
return mActivePointerId == INVALID_POINTER_ID && canSwipeDismissView(child);
}
public boolean canSwipeDismissView(@NonNull View view) {
return true;
}
mActivePointerId 默认值本身就是 INVALID_POINTER_ID,canSwipeDismissView() 方法默认返回true,则 tryCaptureView() 方法返回true。如果我们想动态控制view是否可以滑动,可以重写 canSwipeDismissView() 方法,控制它的返回值来控制是否可以横滑消失。
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
mActivePointerId = activePointerId;
mOriginalCapturedViewLeft = capturedChild.getLeft();
final ViewParent parent = capturedChild.getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
这个方法是手指按下触发的,此时 mActivePointerId 值修改,并非是INVALID_POINTER_ID,记录 TextView 距离父容器左边的位置 mOriginalCapturedViewLeft,同时调用父容器的请求触摸事件不拦截方法,这样触摸事件会一直分发到 TextView 的 SwipeDismissBehavior 中。
@Override
public void onViewDragStateChanged(int state) {
if (mListener != null) {
mListener.onDragStateChanged(state);
}
}
private OnDismissListener mListener;
public interface OnDismissListener {
public void onDismiss(View view);
public void onDragStateChanged(int state);
}
onViewDragStateChanged() 主要是修改状态值,拖拽-滑动-无 等三个状态,这里有个回调,我们看看它的定义,它定义了两个方法,另外一个是消失时的回调,后面再分析。
@Override
public void onViewReleased(View child, float xvel, float yvel) {
mActivePointerId = INVALID_POINTER_ID;
final int childWidth = child.getWidth();
int targetLeft;
boolean dismiss = false;
if (shouldDismiss(child, xvel)) {
targetLeft = child.getLeft() < mOriginalCapturedViewLeft ? mOriginalCapturedViewLeft - childWidth : mOriginalCapturedViewLeft + childWidth;
dismiss = true;
} else {
targetLeft = mOriginalCapturedViewLeft;
}
if (mViewDragHelper.settleCapturedViewAt(targetLeft, child.getTop())) {
ViewCompat.postOnAnimation(child, new SettleRunnable(child, dismiss));
} else if (dismiss && mListener != null) {
mListener.onDismiss(child);
}
}
这个方法是手指松开时调用的,牵涉的有几处逻辑,我们好好分析一下。第一行,恢复 mActivePointerId 的值为 INVALID_POINTER_ID,childWidth 是 TextView 的宽,首先是 shouldDismiss() 方法,看看它的源码
private boolean shouldDismiss(View child, float xvel) {
if (xvel != 0f) {
final boolean isRtl = ViewCompat.getLayoutDirection(child) == ViewCompat.LAYOUT_DIRECTION_RTL;
if (mSwipeDirection == SWIPE_DIRECTION_ANY) {
return true;
} else if (mSwipeDirection == SWIPE_DIRECTION_START_TO_END) {
return isRtl ? xvel < 0f : xvel > 0f;
} else if (mSwipeDirection == SWIPE_DIRECTION_END_TO_START) {
return isRtl ? xvel > 0f : xvel < 0f;
}
} else {
final int distance = child.getLeft() - mOriginalCapturedViewLeft;
final int thresholdDistance = Math.round(child.getWidth() * mDragDismissThreshold);
return Math.abs(distance) >= thresholdDistance;
}
return false;
}
看看参数中的 xvel,它的意思是手指松开时x轴上的滑动速率,如果手指按下不动,松开时,值为0;如果是向右滑动时松开,值为正数;如果向左滑动时松开,值为负数。先看看值为0的情况,distance 获取的是此时的滑动距离,thresholdDistance 是允许滑动消失的最小距离,mDragDismissThreshold 值默认是0.5,可以通过 setDragDismissDistance() 来修改这个值的大小,最后就是比较 distance 值是否不小于 thresholdDistance。 看看 xvel != 0f 的情况,默认情况下 isRtl 为 false,如果手机设置了左右颠倒,那么值为true, mSwipeDirection 也是个标识,默认值是 SWIPE_DIRECTION_ANY,表示左右滑动皆可;SWIPE_DIRECTION_END_TO_START 意思是只能向左滑动,SWIPE_DIRECTION_START_TO_END 是只能向右滑动,它是怎么产生出这个效果?先看这里,mSwipeDirection == SWIPE_DIRECTION_START_TO_END 中,如果是向右滑动,xvel 大于0,则返回true,同理SWIPE_DIRECTION_END_TO_START 时向左滑动时返回true,其他情况 shouldDismiss() 返回的值都是 false,它的值有什么用?继续看调用的地方,如果返回值为 true,则 child.getLeft() < mOriginalCapturedViewLeft 判断中,如果成立,说明向左滑动,否则是向右滑动,此时求取出TextView最终左边距离父容器的值,根据初始值mOriginalCapturedViewLeft 和 TextView 本身的宽计算出来;如果 shouldDismiss() 返回值为 false,则 targetLeft 值为初始值,也就是原始起点位置。然后线面的判断,使用了mViewDragHelper 的 settleCapturedViewAt() 方法,这个方法在上一章提过,意思是滑到指定的位置,如果已经到了指定位置,则返回false;假设手指松开时,已经把 TextView 滑到 targetLeft 的位置,并且 mListener 回调不为 null,则触发 onDismiss() 回调;如果 TextView 还没到指定位置,看看 SettleRunnable 这个类,会执行它的 run() 方法,
@Override
public void run() {
if (mViewDragHelper != null && mViewDragHelper.continueSettling(true)) {
ViewCompat.postOnAnimation(mView, this);
} else {
if (mDismiss && mListener != null) {
mListener.onDismiss(mView);
}
}
}
这次用的是 continueSettling() 方法, continueSettling() 方法前面也分析过,它里面会执行位移,直到到达要位移的地方才会返回false, ViewCompat.postOnAnimation(mView,this) 意思是只要满足条件,就不停重复 run() 方法,从这个方法中就可以看出,手指一旦松开,只要满足了 shouldDismiss() 判断,TextView 就会移动消失,同时触发回调。这里有个场景说一下,比如设置了 mSwipeDirection 为 SWIPE_DIRECTION_START_TO_END,表示只能向右滑动,即使我们滑动它,移动的距离超过TextView 自身宽度的一半,如果我们突然向左滑动一下松手,即使松手时位移仍超过了TextView的一半,但这时候 shouldDismiss() 会返回false,所以 TextView 会滑到原点,而不是消失。
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
final boolean isRtl = ViewCompat.getLayoutDirection(child) == ViewCompat.LAYOUT_DIRECTION_RTL;
int min, max;
if (mSwipeDirection == SWIPE_DIRECTION_START_TO_END) {
if (isRtl) {
min = mOriginalCapturedViewLeft - child.getWidth();
max = mOriginalCapturedViewLeft;
} else {
min = mOriginalCapturedViewLeft;
max = mOriginalCapturedViewLeft + child.getWidth();
}
} else if (mSwipeDirection == SWIPE_DIRECTION_END_TO_START) {
if (isRtl) {
min = mOriginalCapturedViewLeft;
max = mOriginalCapturedViewLeft + child.getWidth();
} else {
min = mOriginalCapturedViewLeft - child.getWidth();
max = mOriginalCapturedViewLeft;
}
} else {
min = mOriginalCapturedViewLeft - child.getWidth();
max = mOriginalCapturedViewLeft + child.getWidth();
}
return clamp(min, left, max);
}
private static int clamp(int min, int value, int max) {
return Math.min(Math.max(min, value), max);
}
clampViewPositionHorizontal() 是自定义获取横向位移的值,这里面if判断的作用是获取TextView可以位移的两端的值,根据 mSwipeDirection 和 isRtl 组合除了集中情况,然后再用 clamp() 方法返回三个数的中位数。前面说到 mSwipeDirection 决定是否可以向左或向右滑动,原因就在这,这里界限了可以滑动的区域,道理比较简单,这里就不多说了。
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return child.getTop();
}
这个是决定竖直方向的位移,这里返回的是固定值,TextView 本身距离的 top,所以高度不变,因此TextView只能横向平移了。
@Override
public void onViewPositionChanged(View child, int left, int top, int dx, int dy) {
final float startAlphaDistance = mOriginalCapturedViewLeft + child.getWidth() * mAlphaStartSwipeDistance;
final float endAlphaDistance = mOriginalCapturedViewLeft + child.getWidth() * mAlphaEndSwipeDistance;
if (left <= startAlphaDistance) {
ViewCompat.setAlpha(child, 1f);
} else if (left >= endAlphaDistance) {
ViewCompat.setAlpha(child, 0f);
} else {
final float distance = fraction(startAlphaDistance, endAlphaDistance, left);
ViewCompat.setAlpha(child, clamp(0f, 1f - distance, 1f));
}
}
这个方法是位移的监听,前两行代码是定义了两个值,是说开始和结束透明度距离的值,由 mAlphaStartSwipeDistance 和 mAlphaEndSwipeDistance 变量控制,默认是0和0.5,它俩也可以通过方法设置新值,但方法中有限制,会限制在0和1之间;if判断 left <= startAlphaDistance 中,说明是向左移动,此时透明度为1,即不透明;left >= endAlphaDistance 则显透明度为0,百分之百透明,这里 endAlphaDistance 意思就是超过了这个值就是全透明,默认值是 TextView 的一半; else 中,fraction() 方法是计算位移的百分比
static float fraction(float startValue, float endValue, float value) {
return (value - startValue) / (endValue - startValue);
}
然后用1减去百分比的值 distance,再比较它和0以及1之间的中位数,则是TextView的透明度了。
注意,这里只是横滑消失,TextView本身并没有从ViewGroup中移除掉,如果想在消失时把TextView也移除,我们需要写上监听方法,举个例子
private void initSwipe() {
TextView tv = (TextView) findViewById(R.id.tv_swip);
ViewGroup.LayoutParams params = tv.getLayoutParams();
if(params instanceof CoordinatorLayout.LayoutParams){
CoordinatorLayout.LayoutParams p= (CoordinatorLayout.LayoutParams) params;
CoordinatorLayout.Behavior behavior = p.getBehavior();
if(behavior instanceof SwipeDismissBehavior){
SwipeDismissBehavior sb= (SwipeDismissBehavior) behavior;
sb.setListener(new SwipeDismissBehavior.OnDismissListener() {
@Override
public void onDismiss(View view) {
ViewGroup parent = (ViewGroup) view.getParent();
if(parent != null){
parent.removeView(view);
}
}
@Override
public void onDragStateChanged(int state) {
}
});
}
}
}
这样就可以了。
前几章分析 Snackbar 的代码,有个方法 showView() 是展示时调用,
final void showView() {
if (mView.getParent() == null) {
final ViewGroup.LayoutParams lp = mView.getLayoutParams();
if (lp instanceof CoordinatorLayout.LayoutParams) {
final Behavior behavior = new Behavior();
behavior.setStartAlphaSwipeDistance(0.1f);
behavior.setEndAlphaSwipeDistance(0.6f);
behavior.setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END);
behavior.setListener(new SwipeDismissBehavior.OnDismissListener() {
@Override
public void onDismiss(View view) {
dispatchDismiss(Callback.DISMISS_EVENT_SWIPE);
}
@Override
public void onDragStateChanged(int state) {
switch (state) {
case SwipeDismissBehavior.STATE_DRAGGING:
case SwipeDismissBehavior.STATE_SETTLING:
SnackbarManager.getInstance().cancelTimeout(mManagerCallback);
break;
case SwipeDismissBehavior.STATE_IDLE:
SnackbarManager.getInstance().restoreTimeout(mManagerCallback);
break;
}
}
});
((CoordinatorLayout.LayoutParams) lp).setBehavior(behavior);
}
mParent.addView(mView);
}
...
}
final class Behavior extends SwipeDismissBehavior<SnackbarLayout> {
@Override
public boolean onInterceptTouchEvent(CoordinatorLayout parent, SnackbarLayout child,
MotionEvent event) {
if (parent.isPointInChildBounds(child, (int) event.getX(), (int) event.getY())) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
SnackbarManager.getInstance().cancelTimeout(mManagerCallback);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
SnackbarManager.getInstance().restoreTimeout(mManagerCallback);
break;
}
}
return super.onInterceptTouchEvent(parent, child, event);
}
}
这个方法中,它是要嵌套在 CoordinatorLayout 中,这里给它设置了 Behavior,同时也有监听回调,Behavior 继承 SwipeDismissBehavior,这里重写了 onInterceptTouchEvent() 方法,意思是手指按到 Snackbar 时,调用 cancelTimeout() 方法,取消Snackbar要消失的时间限制,手指抬起时,restoreTimeout() 恢复时间限制,也就是说如果我们的 Snackbar 是显示3秒后消失,只要我们手指按到 Snackbar 上面,3秒后它还会存在,不会消失;手指松开后开始计时,3秒后 Snackbar 消失。 我们重新看 showView() 方法,behavior 设置连个位移透明度的值是 0.1 和 0.6,同时 setSwipeDirection() 设置的值是 SWIPE_DIRECTION_START_TO_END,也就是说只能向右滑动,并且滑动在 Snackbar 本身宽度0.1倍之内,不会有透明
度变化,位移到0.6倍时,变为全透明;再看看它的 setListener() 设置的回调方法,onDismiss() 消失回调,调用 dispatchDismiss(Callback.DISMISS_EVENT_SWIPE),这个方法是把 Snackbar 从它的父容器中删除;onDragStateChanged() 是拖动状态改变监听,拖拽或自身滑动时 cancelTimeout() 取消时间限制,恢复到自然状态时 restoreTimeout() 恢复时间限制
。