隔壁 iOS 的小伙伴有一个功能就是左手向右手一个慢动作,轻轻一划就可以关闭界面,这种操作感觉还是很丝滑的,而且这还是 iOS 系统自带的功能,由于 Android 手机早期是有 back 键,home 键 和菜单键(现在大部分手机都只保留一个键了),所以 Android 是没有这个功能的。现在用户越来越注重体验,一般为了降低设计成本,在 App 的设计上 iOS 与 Android 也力求风格统一,那么如果需要我们也实现这样的功能怎么办?程序猿可是不会被难倒的一个物种,有很多这样功能的开源控件,目前公司项目也有用到,但是用起来却是有点问题,于是决定自己试着实现一下,也是一种学习。
1 思路
首先,侧滑这个动作并不难,我们监听到一个控件的触摸事件,然后改变它的横纵坐标即可(这个我之前有写过可以自由移动的控件,http://blog.csdn.net/zgcqflqinhao/article/details/72731633,准备重新改一下这个控件,但是原理是一样的),问题是我们应该监听哪个控件的触摸。我们可以获取到 Activity 的根视图,于是我就想着把这个根视图再放到一个自定义的容器上,那样就好处理了。然后除了侧滑的效果,我们还得处理它的事件冲突(鄙人对事件冲突也简单学习过 http://blog.csdn.net/zgcqflqinhao/article/details/72110352),毕竟 Android 中可以滑动的控件还不少,比如 ViewPager、SeekBar 等等。OK,既然有思路了,那就可以开始我们的光荣之路了。
2 侧滑的基本实现
public class SlideBackLayout extends FrameLayout {
private boolean startSlide = false;
private float lastX;
private Activity activity;
public SlideBackLayout(@NonNull Context context) {
this(context, null);
}
public SlideBackLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SlideBackLayout(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (event.getX() < getMeasuredWidth() / 10) {
startSlide = true;
lastX = event.getRawX();
}
break;
case MotionEvent.ACTION_MOVE:
if (startSlide) {
float distanceX = event.getRawX() - lastX;
float nextX = getX() + distanceX;
setX(nextX);
lastX = event.getRawX();
}
break;
case MotionEvent.ACTION_UP:
if (startSlide && event.getRawX() > getMeasuredWidth() / 2) {
setX(getMeasuredWidth());
//Finish activity
activity.finish();
} else {
setX(0);
}
startSlide = false;
break;
}
return true;
}
public void bindActivity(Activity activity) {
this.activity = activity;
ViewGroup mDecorView = (ViewGroup) activity.getWindow().getDecorView();
View mRootView = mDecorView.getChildAt(0);
mDecorView.removeView(mRootView);
addView(mRootView);
mDecorView.addView(this);
}
public void unbindActivity() {
this.activity = null;
}
}
在需要侧滑关闭的 Activity 中(一般会在 BaseActivity 中)添加如下代码(在 setContentView() 方法后调用就行,其他地方暂时还未测试):
SlideBackLayout mSlideBackLayout = new SlideBackLayout(this);
mSlideBackLayout.bindActivity(this);
3 拦截子 View 的触摸事件
可以看到我们实现了基本的侧滑操作了,滑完后未超过一半自动回到原来的样子,超过一半则关闭 Activity,这中间我还点了一下按钮,不是我手贱,是想说明子控件的点击事件依然可以响应,但是这就有个问题了,如果这个控件的宽高全屏了,那就没有办法执行侧滑动作了。如下图:
这是因为子 View 把我们的触摸事件给消费了,那么应该在适当的时机来拦截一下触摸事件,让我们侧滑控件自己消费而不下发到子 View,重写 onInterceptTouchEvent() 方法,当触摸点的横坐标小于屏幕宽度十分之一时就不下发触摸事件:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (event.getX() < getMeasuredWidth() / 10) {
startSlide = true;
return true;
}
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
break;
}
return super.onInterceptTouchEvent(event);
}
目前效果如下:
对了,这里还有一个 Bug,如果调用了 bindActivity() 再调用 unbindActivity() 方法,侧滑操作仍会执行,关闭的时候就会由于 activity 为 null 而报 NullPointException,所以我们在拦截触摸事件和响应触摸事件的时候先进行非空判断:
if (activity == null) {
return super.onInterceptTouchEvent(event);
}
if (activity == null) {
return super.onTouchEvent(event);
}
到目前为止,基本的侧滑我们能实现了,对子 View 触摸事件的拦截我们也做了一些处理。
4 处理滑动冲突
接下来我们要处理一些控件的滑动冲突,左右滑动的控件最典型的也就是 ViewPager 了,目前公司使用的侧滑关闭菜单中也是只处理了 ViewPager 的滑动冲突,如果不处理冲突,那么在存在 ViewPager 的时候,无论 ViewPager 当前处于第几个页卡,只要我们在小于屏幕宽度十分之一的地方开始滑动,那就会响应侧滑事件而不是 ViewPager 的滑动,我们希望的应该是在 ViewPager 处于非第一个页卡时,先响应 ViewPager 的滑动,ViewPager 处于第一个页卡时才响应侧滑关闭事件。那么首先需要一个容器来存放当前布局中存在的 ViewPager,然后检查到当前布局中有 ViewPager 的时候就存到容器中,这样我们在处理触摸事件的时候再根据有没有 ViewPager 和 ViewPager 当前页卡是第几项来绝对如何处理触摸事件。
检查当前布局是否有 ViewPager 这个方法在绑定 Activity 的时候调用:
private void checkHasViewPager(ViewGroup viewGroup) {
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
if (viewGroup.getChildAt(i) instanceof ViewPager) {
viewPagerList.add((ViewPager) viewGroup.getChildAt(i));
} else if (viewGroup.getChildAt(i) instanceof ViewGroup) {
checkHasViewPager((ViewGroup) viewGroup.getChildAt(i));
}
}
}
然后在 onInterceptTouchEvent() 方法中处理事件之前加入对 ViewPager 的处理:
if (!viewPagerList.isEmpty()) {
for (int i = 0; i < viewPagerList.size(); i++) {
if (viewPagerList.get(i).getCurrentItem() != 0) {
return super.onInterceptTouchEvent(event);
}
}
}
这样处理过后就可以看到即使在小于屏幕宽度十分之一的地方滑动时也是先响应 ViewPager 的触摸事件,直到处于第一个页卡才开始侧滑关闭。细心的你会发现我又自作主张的在界面顶部加上了一个 SeekBar,而且当我想滑动 SeekBar 时并没有成功,而是响应了侧滑事件,其实这就是我为什么想写这个控件的起因。我希望我可以自定义某些控件也跟 ViewPager 一样不被拦截触摸事件,所以我还加入了一个存放不想被拦截事件的容器。
然后给外部提供一个方法,可以添加希望不被拦截触摸事件的 View:
public void addNotInterceptView(View view) {
notInterceptViewList.add(view);
}
onInterceptTouchEvent() 方法中处理完 ViewPager 就可以处理这些 View 了:
if (!notInterceptViewList.isEmpty()) {
for (int i = 0; i < notInterceptViewList.size(); i++) {
View mView = notInterceptViewList.get(i);
int[] location = new int[2];
mView.getLocationOnScreen(location);
if (event.getX() >= location[0] && event.getX() <= location[0] + mView.getWidth()
&& event.getY() >= location[1] && event.getY() <= location[1] + mView.getHeight()) {
return super.onInterceptTouchEvent(event);
}
}
}
当我们希望 SeekBar 的触摸事件不被拦截时,就可以调用 addNotInterceptView() 方法:
SeekBar sbTest = (SeekBar) findViewById(R.id.sb_test);
mSlideBackLayout.addNotInterceptView(sbTest);
现在效果如下:
5 总结
这个控件在使用时仍然和其他的控件一样,必须设置 Activity 的主题为透明主题,继承 Activity 时可以使用
@android:style/Theme.Translucent.NoTitleBar
继承 AppCompat 时需自定义主题,然后在自定义主题中加入如下两行:
<!-- 透明背景 -->
<item name="android:windowBackground">@android:color/transparent</item>
<!-- 设置是否透明 -->
<item name="android:windowIsTranslucent">true</item>
这次的自定义侧滑关闭 Activity 控件比起现在公司用的多了两个功能,一是取消 Activity 的绑定,因为有的 Activity 并不希望有这个功能,二是可以添加自定义的希望不被拦截触摸事件的 View。当然我现在还没有大量测试这个控件的稳定性,如果有发现问题,欢迎留言。
6 Github 传送门
https://github.com/mrqinshou/SlideBackLayoutDemo
7 优化后的代码
利用屏幕宽度 1/10 来判断感觉有点不好,后来决定给一个固定距离来判断侧滑关闭 Activity 是否生效,并且这个距离是可以设置的,最终代码如下:
/**
* Description:侧滑关闭 Activity 的控件,推荐 ImmersiveBaseActivity 中使用该控件
* 然后不需要侧滑的 Activity 调用 unbindActivity() 方法即可
* 如果有希望不被拦截 Touch 事件的 View 也可以调用 addNotInterceptView(View view) 添加
* Created by 禽兽先生
* Created on 2018/1/24
*/
public class SlideBackLayout extends FrameLayout {
private boolean startSlide = false; //是否开始侧滑动作的标志位
private float lastX; //最后一次触摸事件的坐标
private Activity activity; //需要关闭的 Activity
private List<ViewPager> viewPagerList; //存放当前布局中的 ViewPager
private List<View> notInterceptViewList; //存放希望不被拦截 Touch 事件的 View
private int mSafetyMargin; //安全边距,即侧滑事件开始生效的距离,默认 15dp,现在大部分 App 的安全边距也是这个距离。
public SlideBackLayout(@NonNull Context context) {
this(context, null);
}
public SlideBackLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SlideBackLayout(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
//设置容器宽度为最大宽度,高度为最大高度
setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
//设置一个默认灰色背景,因为该控件必须设置 Activity 的主题为透明,如果布局中没有设置背景色的话,那么没有控件的部分会透明
//显示下层的 Activity,体验不舒服,这里就设置一个默认背景
setBackgroundColor(Color.argb(255, 250, 250, 250));
viewPagerList = new ArrayList<>();
notInterceptViewList = new ArrayList<>();
mSafetyMargin = (int) (15 * context.getResources().getDisplayMetrics().density + 0.5f);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
//如果没有绑定 Activity,那就该干嘛干嘛
if (activity == null) {
return super.onInterceptTouchEvent(event);
}
//当当前布局中存在 ViewPager 并且该 ViewPager 的当前页卡不是第一个时,让 ViewPager 先获得触摸事件
if (!viewPagerList.isEmpty()) {
for (int i = 0; i < viewPagerList.size(); i++) {
if (viewPagerList.get(i).getCurrentItem() != 0) {
return super.onInterceptTouchEvent(event);
}
}
}
//当有自定义的不希望被拦截 Touch 事件的 View 时,如果当前触摸的位置在该控件区域内,不拦截触摸事件
if (!notInterceptViewList.isEmpty()) {
for (int i = 0; i < notInterceptViewList.size(); i++) {
View mView = notInterceptViewList.get(i);
//获取不希望被拦截触摸事件的控件在屏幕上的位置
int[] location = new int[2];
mView.getLocationOnScreen(location);
//当触摸点在该控件区域内时,不拦截触摸事件
if (event.getX() >= location[0] && event.getX() <= location[0] + mView.getWidth()
&& event.getY() >= location[1] && event.getY() <= location[1] + mView.getHeight()) {
return super.onInterceptTouchEvent(event);
}
}
}
//OK,如果上面的条件都不满足,那么在触摸点的横坐标小于屏幕宽度的十分之一时就拦截触摸事件,不下发了,交给自己的 onTouchEvent() 处理
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (event.getX() < mSafetyMargin) {
//当触摸点的横坐标小于屏幕宽度的十分之一,设置 startSlide,代表要开始侧滑动作了
startSlide = true;
return true;
}
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
break;
}
return super.onInterceptTouchEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//如果没有绑定 Activity,那就该干嘛干嘛
if (activity == null) {
return super.onTouchEvent(event);
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (event.getX() < mSafetyMargin) {
lastX = event.getRawX();
}
break;
case MotionEvent.ACTION_MOVE:
if (startSlide) {
float distanceX = event.getRawX() - lastX;
//控件将要移动到的位置
float nextX = getX() + distanceX;
//不能向左侧滑,在右边露出上一个 Activity,所以横坐标大于 0 才让其可以移动
if (nextX > 0) {
setX(nextX);
}
//移动完之后记录当前坐标
lastX = event.getRawX();
}
break;
case MotionEvent.ACTION_UP:
//手指抬起时,如果横坐标超过屏幕的一半,那就关闭绑定的 Activity,否则恢复初始状态,并重置标志位
if (startSlide && event.getRawX() > getMeasuredWidth() / 2) {
setX(getMeasuredWidth());
//Finish activity
activity.onBackPressed();
} else {
setX(0);
}
startSlide = false;
break;
}
return true;
}
/**
* Description:绑定 Activity,获取该 Activity 的根视图
* 该根视图包含一个子 View,是一个 LinearLayout,包含 TitleBar 和 Content(详情请移步 Android 群英传第 3 章)
* 然后将其从根视图上移除掉,添加到该控件上,然后将该控件添加到 Activity 的根视图中,实现偷梁换柱的目的
* Date:2018/1/25
*/
public void bindActivity(Activity activity) {
this.activity = activity;
ViewGroup mDecorView = (ViewGroup) activity.getWindow().getDecorView();
View mRootView = mDecorView.getChildAt(0);
mDecorView.removeView(mRootView);
addView(mRootView);
mDecorView.addView(this);
checkHasViewPager(this);
}
/**
* Description:取消绑定
* Date:2018/1/25
*/
public void unbindActivity() {
this.activity = null;
}
/**
* Description:递归检查当前的根视图中是否包含 ViewPager
* Date:2018/1/25
*/
private void checkHasViewPager(ViewGroup viewGroup) {
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
if (viewGroup.getChildAt(i) instanceof ViewPager) {
viewPagerList.add((ViewPager) viewGroup.getChildAt(i));
} else if (viewGroup.getChildAt(i) instanceof ViewGroup) {
checkHasViewPager((ViewGroup) viewGroup.getChildAt(i));
}
}
}
/**
* Description:提供给外部的方法,添加希望不被拦截 Touch 事件的 View
* Date:2018/1/25
*/
public void addNotInterceptView(View view) {
notInterceptViewList.add(view);
}
/**
* Description:提供给外部设置安全边距的方法
* Date:2018/7/26
*/
public void setSafetyMargin(int safetyMargin) {
mSafetyMargin = safetyMargin;
}
}