相信android开发的同学大部分都会遇到view的滑动冲突问题,当然我也遇到过这类问题,在这里做一些相关记录方便遇到该问题时后期可以回顾。
下面进入正题:
一、常见滑动的冲突场景可分为三种
场景一:外部滑动方向和内部滑动方向不一致。
场景二:外部滑动方向和内部滑动方向一致。
场景三:上面两种情况的嵌套。
二、滑动冲突的处理规则
处理滑动冲突要了解view的事件分发机制,上面我的文章有事件分发机制简单记录,可以去看看。
场景一,它的处理规则是:当用户左右滑动时,需要让外部的view拦截点击事件,当用户上下滑动时,需要让内部的view拦截点击事件。具体来说:根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件。
如图slide.png所示,根据滑动过程中两个点之间的坐标就可以得出是水平滑动还是竖直滑动。
场景二,无法根据滑动的角度、距离差以及速度差来做判断,但是这个时候都在业务上找突破点,例如有的业务规定:当处于某种状态时需要外部View响应用户的滑动,而处于另外一种状态时需要响应内部View的滑动。根据这种业务做出相应的处理规则,文字描述比较抽象,在下面有解决示例,这样就比较好理解了。
场景三,和场景二一样,也无法直接根据滑动的角度、距离差以及速度差来做判断,都是从业务的需求上找到相应的处理规则。在下面会给出解决示例。
三、滑动冲突解决方式
这里只给出两种解决方式:外部拦截法和内部拦截法。
1、外部拦截法
该方法是指点击事件都先经过父容器的拦截处理,如果父容器需要些事件就进行拦截,如果不需要此事件就不拦截,这种方法比较符合点击事件的分发机制。
外部拦截法需要重写父容器的onInterceptTouchEvent方法,在内部做相应的拦截就好了。伪代码示例如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean interpected = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
//ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_DOWN事件,这
//是因为一旦父容器拦截了ACTION_DOWN,那到后续的ACTION_MOVE和ACTION_UP事件
// 都会直接交给父容器处理,这个时候事件没法再传递给子元素了;
case MotionEvent.ACTION_DOWN:
interpected = false;
break;
//其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截返回true,否则返回false;
case MotionEvent.ACTION_MOVE:
if (父容器需要当前点击事件) {
interpected = true;
} else {
interpected = false;
}
break;
// 最后是ACTION_UP事件,这里必须要返回false,因为ACTION_UP事件本身没有太多意义。
//(假设事件交由子元素处理,如果父容器在ACTION_UP时返回true,就会导致子元素无法接收到ACTION_UP事件,
//这个时候子元素中的onClick事件就无法触发,但是父容器比较特殊,一旦它开始拦截任何一个事件,
//那么后续的事件都会交给它来处理,而ACTION_UP作为最后一个事件也必定可以传递给父容器,
//即便父容器的onInterpectTouchEvent方法在ACTION_UP时返回了false. )
case MotionEvent.ACTION_UP:
interpected = false;
break;
default:
break;
}
mLastXIntercept=x;
mLastYIntercept=y;
return interpected;
}
上述代码是外部拦截法的典型实现逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可。
2、内部拦截法
内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起来比外部拦截法复杂一点。伪代码如下示例:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean interpected = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
//ACTION_DOWN 这个事件,和外部拦截不同,除了子元素需要做处理外,父元素也要默认拦截除了 ACTION_DOWN以
//外的其他事件,这样当子元素调用parent().requestDisallowInterceptTouchEvent(true)方法,像元素才能继续拦截所需事件。
//父容器为什么不能拦截ACTION_DOWN事件?那是因为ACTION_DOWN事件并不受FLAG_DISALLOW_INTERCEPT这个标记位控制。
case MotionEvent.ACTION_DOWN:
parent().requestDisallowInterceptTouchEvent(true);
break;
//其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截返回true,否则返回false;
case MotionEvent.ACTION_MOVE:
int deltax=x-mLastX;
int deltay=y-mLastY;
if (父容器需要当前点击事件) {
parent().requestDisallowInterceptTouchEvent(false);
}
break;
// 最后是ACTION_UP事件,这里必须要返回false,因为ACTION_UP事件本身没有太多意义。
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastXIntercept=x;
mLastYIntercept=y;
return super.dispatchHoverEvent(ev);
}
三、具体实现示例:
1.使用外部拦截法(解决场景一):
实现一个类似于ViewPage中嵌套ListView的效果,为了控制滑动冲突,叫 HorizontalSrollViewCustor。
在初始化时要在它的内部添加若干个ListView可以竖直滑动,而它本身又可以水平滑动,这样场景一的滑动冲突就有了。
public class ViewActivity extends Activity {
private static final String TAG = "ViewActivity";
private HorizontalScrollView mListContainer;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_demo_5_view);
initView();
}
//初始化控件
private void initView() {
LayoutInflater inflater = getLayoutInflater();
mListContainer = findViewById(R.id.container);
//获取屏幕的参数宽和高
final int screenWidth = MyDisplayUtil.getScreenMetrics(this).widthPixels;
final int screenHeight = MyDisplayUtil.getScreenMetrics(this).heightPixels;
//增加ListView
for (int i = 0; i < 3; i++) {
ViewGroup layout = (ViewGroup) inflater.inflate(
R.layout.content_layout, mListContainer, false);
layout.getLayoutParams().width = screenWidth;
//加上内容和背景色
TextView textView = (TextView) layout.findViewById(R.id.title);
textView.setText("page " + (i + 1));
layout.setBackgroundColor(Color.rgb(255 / (i + 1), 255 / (i + 1), 0));
createList(layout);
mListContainer.addView(layout);
}
}
//创建listView数据
private void createList(ViewGroup layout) {
ListView listView = (ListView) layout.findViewById(R.id.list);
ArrayList datas = new ArrayList();
for (int i = 0; i < 50; i++) {
datas.add("name " + i);
}
//创建adapter加上点击事件
ArrayAdapter adapter = new ArrayAdapter(this,
R.layout.content_list_item, R.id.name, datas);
listView.setAdapter(adapter);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView> parent, View view,
int position, long id) {
Toast.makeText(ViewActivity.this, "click item",
Toast.LENGTH_SHORT).show();
}
});
}
}
MyDisplayUtil:
/**
* 获取屏幕信息对象 DisplayMetrics
* @param context
* @return
*/
public static DisplayMetrics getScreenMetrics(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
return dm;
}
下面是HorizontalSrollViewCustor 代码使用了外部拦截法解决滑动冲容问题:
public class HorizontalSrollViewCustor extends ViewGroup {
private static final String TAG = "HorizontalSrollViewCustor";
//子View的个数
private int mChildrenSize;
private int mChildwidth;
private int mChildIndex;
//分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
//分别记录上次滑动的坐标()
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
public HorizontalSrollViewCustor(Context context) {
super(context);
init();
}
public HorizontalSrollViewCustor(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalSrollViewCustor(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean interpected = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
//ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_DOWN事件,这
//是因为一旦父容器拦截了ACTION_DOWN,那到后续的ACTION_MOVE和ACTION_UP事件
// 都会直接交给父容器处理,这个时候事件没法再传递给子元素了;
case MotionEvent.ACTION_DOWN:
interpected = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
interpected = true;
}
break;
//其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截返回true,否则返回false;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
//父容器需要当前点击事件
if (Math.abs(deltaX) > Math.abs(deltaY)) {
interpected = true;
} else {
interpected = false;
}
break;
// 最后是ACTION_UP事件,这里必须要返回false,因为ACTION_UP事件本身没有太多意义。
case MotionEvent.ACTION_UP:
interpected = false;
break;
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return interpected;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
int scrollToChildIndex = scrollX / mChildwidth;
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildwidth / 2) / mChildwidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildwidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
default:
break;
}
mLastX=x;
mLastY=y;
return true;
}
/**
* 缓慢滚动到指定位置
* @param dx
* @param dy
*/
private void smoothScrollBy(int dx, int dy) {
//在500毫秒内滑动dx距离,效果就是慢慢滑动,这样可以让用户体验更好
mScroller.startScroll(getScrollX(),0,dx,dy,500);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
/**
* 自定义view 测量 不是本文滑动冲容的重点
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth=0;
int measuredHeight=0;
final int childCount =getChildCount();
measureChildren(widthMeasureSpec,heightMeasureSpec);
int widthSpaceSize=MeasureSpec.getSize(widthMeasureSpec);
int widthSpaceMode=MeasureSpec.getMode(widthMeasureSpec);
int heightSpaceSize=MeasureSpec.getSize(heightMeasureSpec);
int heightSpaceMode=MeasureSpec.getMode(heightMeasureSpec);
if (childCount==0){
setMeasuredDimension(0,0);
}else if (widthSpaceMode==MeasureSpec.AT_MOST&&heightSpaceMode==MeasureSpec.AT_MOST){
final View childView=getChildAt(0);
measuredWidth=childView.getMeasuredWidth()*childCount;
measuredHeight=childView.getMeasuredHeight();
setMeasuredDimension(measuredWidth,measuredHeight);
}else if (heightSpaceMode==MeasureSpec.AT_MOST){
final View childView=getChildAt(0);
measuredHeight=childView.getMeasuredHeight();
setMeasuredDimension(widthSpaceSize,measuredHeight);
}else if (widthSpaceMode==MeasureSpec.AT_MOST){
final View childView=getChildAt(0);
measuredWidth=childView.getMeasuredWidth()*childCount;
setMeasuredDimension(measuredWidth,heightSpaceSize);
}
}
/**
* 自定义布局 不是本文重点
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childLeft=0;
final int childCount=getChildCount();
mChildrenSize=childCount;
for (int i = 0; i < childCount; i++) {
final View childView=getChildAt(i);
if (childView.getVisibility()!=GONE){
final int childWidth=childView.getMeasuredWidth();
mChildwidth=childWidth;
childView.layout(childLeft,0,childLeft+childWidth,childView.getMeasuredHeight());
childLeft +=childWidth;
}
}
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}
2.使用内部拦截法示例(场景一):
如果采用内部拦截法,只需要修改ListView的dispatchTouchEvent方法中的父容器的拦截逻辑,同是让父容器拦截 ACTION_MOVE和 ACTION_UP事件即可。为了证明内部拦截,需要对ListView改为自定义ListViewCustor,ListViewCustor实现如下:
public class ListViewCustor extends ListView {
private static final String TAG = "ListViewCustor";
private HorizontalScrollViewCustor2 mHorizontalScrollViewEx2;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
public ListViewCustor(Context context) {
super(context);
}
public ListViewCustor(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ListViewCustor(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setHorizontalScrollViewEx2(
HorizontalScrollViewCustor2 horizontalScrollViewEx2) {
mHorizontalScrollViewEx2 = horizontalScrollViewEx2;
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
if (Math.abs(deltaX) > Math.abs(deltaY)) {
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
}
上面代码里的HorizontalScrollViewCustor2 只是对 HorizontalScrollViewCustor例改了onInterceptTouchEvent方法如下所示:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
mLastX = x;
mLastY = y;
if (!mScroller.isFinished()) {
//滑动动画这里可以不加,只是为了优化用户体验
mScroller.abortAnimation();
return true;
}
return false;
} else {
return true;
}
}
从上面可以看现内部拦截法要比外部拦截法稍微复杂一些,因此推荐采用外部拦截法解决常见的滑动冲突。
3、下面是(场景二)用外部拦截法示例:(场景三的解决思路和场景一、场景二是一致的这里就不写了)
这个示例类叫SameLayout ,是可以上下滑动的竖直的LinearLayout,然后在它的内部分别放一个Header和ListView,这样都可以上下滑动,场景二的滑动冲突就有了。
处理思路是:当Header显示时或者ListView滑动到顶部时,由 SameLayout拦裁事件;当Header隐藏时,这要分情况,如果 ListView已经滑动到顶部并且当前手势是向下滑动的,这个时候还是 SameLayout拦截事件,其他情况就由ListView拦截事件。
public class SameLayout extends LinearLayout {
private static final String TAG = "SameLayout";
private View mHeader;
private View mContent;
private OnGiveUpTouchEventListener mGiveUpTouchEventListener;
// header的高度 单位:px
private int mOriginalHeaderHeight;
private int mHeaderHeight;
private int mStatus = STATUS_EXPANDED;
public static final int STATUS_EXPANDED = 1;
public static final int STATUS_COLLAPSED = 2;
private int mTouchSlop;
// 分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
// 分别记录上次滑动的坐标(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
// 用来控制滑动角度,仅当角度a满足如下条件才进行滑动:tan a = deltaX / deltaY > 2
private static final int TAN = 2;
private boolean mIsSameLayout = true;
private boolean mInitDataSucceed = false;
private boolean mDisallowInterceptTouchEventOnHeader = true;
public interface OnGiveUpTouchEventListener {
public boolean giveUpTouchEvent(MotionEvent event);
}
......
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int intercepted = 0;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastXIntercept = x;
mLastYIntercept = y;
mLastX = x;
mLastY = y;
intercepted = 0;
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (mDisallowInterceptTouchEventOnHeader && y <= getHeaderHeight()) {
intercepted = 0;
} else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
intercepted = 0;
} else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
intercepted = 1;
} else if (mGiveUpTouchEventListener != null) {
if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
intercepted = 1;
}
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = 0;
mLastXIntercept = mLastYIntercept = 0;
break;
}
default:
break;
}
return intercepted != 0 && mIsSameLayout;
}
public int getHeaderHeight() {
return mHeaderHeight;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsSameLayout) {
return true;
}
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
mHeaderHeight += deltaY;
break;
}
case MotionEvent.ACTION_UP: {
// 这里做了下判断,当松开手的时候,会自动向两边滑动,具体向哪边滑,要看当前所处的位置
int destHeight = 0;
if (mHeaderHeight <= mOriginalHeaderHeight * 0.5) {
destHeight = 0;
mStatus = STATUS_COLLAPSED;
} else {
destHeight = mOriginalHeaderHeight;
mStatus = STATUS_EXPANDED;
}
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
}
上面的代码主要以滑动冲突为重点