Android觸控事件的應用
前言
上一篇講了Android觸控事件的傳遞機制,具體可以看這裡 初識Android觸控事件傳遞機制。既然知道Android中觸控事件的傳遞分發,那麼它能解決什麼樣的問題,在我們實際開發中如何應用,這點很重要,知道原理是為了解決問題而準備的。這篇文章的核心講的如何解決View的滑動衝突,這個問題在日常開發中很常見,比如內部巢狀Fragment檢視是左右滑動,外部用一個ScrollView來包含,可以上下滑動,如果不進行滑動衝突處理的話,就會造成外部滑動方向和內部滑動方向不一致。
目錄
- 常見的滑動衝突場景
- 滑動衝突的處理規則
- 外部攔截法
- 內部攔截法
- 小結
常見的滑動衝突場景
常見的滑動衝突場景可以簡單分為以下三種:
- 場景1:外部滑動方向和內部滑動方向不一致
- 場景2:外部滑動方向和內部滑動方向一致
- 場景3:上面兩種情況的巢狀
如圖:
場景1,主要是將ViewPager和Fragment配合使用所組成的頁面滑動效果,主流應用幾乎都會使用這個效果。在這個效果中可以通過左右滑動來切換頁面,而每個頁面內部往往又是一個ListView,所以就造成了滑動衝突,但是在ViewPager內部處理了這種滑動衝突,因此在採用ViewPager時我們就無須關注這個問題,而如果把ViewPager換成ScrollView,那就必須自己手動處理,不然造成的結果就是內外兩層只能一層能夠滑動。
場景2,就複雜一點,當內外兩層都在同一個方向可以滑動的時候,顯然存在邏輯問題。因為當手指開始滑動的時候,系統無法知道使用者到底是想讓哪一層滑動,所以當手指滑動的時候就會出現問題,要麼只有一層滑動,要麼就是內外兩層都滑動但很卡頓。
場景3,是場景1和場景2兩種情況的巢狀,顯得更復雜了。比如外部有一個SlideMenu效果,內部有一個ViewPager,ViewPager的每一個頁面中又是一個ListView。雖然場景3滑動衝突看起來很複雜,但都是幾個單一的滑動衝突的疊加,因此需要一一拆解開來即可。
滑動衝突的處理規則
一般來說,不管滑動衝突有多麼複雜,它都有既定的規則,根據這些規則我們就可以選擇合適的方法去處理。
對於場景1,它的處理規則就是:當用戶左右滑動時,需要讓外部的View攔截點選事件,當用戶上下滑動,需要讓內部View攔截點選事件。具體來說就是根據滑動是水平滑動還是豎直滑動來判斷到底是由誰來攔截事件。
如圖:
簡單來說,就是根據水平方向和豎直方向的距離差來判斷,如果是Dx>Dy,那麼則是水平滑動,如果是Dy>Dx,那麼則是豎直滑動。
場景2,則是比較特殊,它無法根據滑動的角度,距離差以及速度差來做判斷。這個時候就需要從業務上找到突破點,比如,當處於某種狀態時需要外部View響應使用者的滑動,而處於另外一種狀態時需要內部View來響應View的滑動
對於場景3的話,它的滑動規則也更復雜,和場景2一樣,同樣是從業務上找到突破點。
外部攔截法
外部攔截法是指點選事件都是先經過父容器的攔截處理,如果父容器需要此事件就攔截,如果不需要此事件,就不攔截了,這樣就可以解決滑動衝突的問題,外部攔截法需要重寫父容器的onInterceptTouchEvent方法,在內部做相應的攔截即可,虛擬碼如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
if (父容器需要點選當前事件) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
首先ACTION_DOWN這個事件,父容器必須返回false,這樣保證後續move和up的事件可以傳遞給子View,根據move事件來決定是否攔截,如果父容器攔截就返回true,否則返回false。
實現一個自定義類似ViewPager的控制元件,巢狀ListView的效果,原始碼如下:
public class HorizontalScrollViewEx extends ViewGroup {
private static final String TAG = "HorizontalScrollViewEx";
private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;
// 分別記錄上次滑動的座標
private int mLastX = 0;
private int mLastY = 0;
// 分別記錄上次滑動的座標(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller; //彈性滑動物件
private VelocityTracker mVelocityTracker; //追蹤滑動速度
public HorizontalScrollViewEx(Context context) {
super(context);
init();
}
public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalScrollViewEx(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
Log.d(TAG, "intercepted=" + intercepted);
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(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;
scrollBy(-deltaX, 0);
break;
}
case MotionEvent.ACTION_UP: {
int scrollX = getScrollX();
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;
}
@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 widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
if (childCount == 0) {
setMeasuredDimension(0, 0);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
setMeasuredDimension(measuredWidth, heightSpaceSize);
} else {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(measuredWidth, measuredHeight);
}
}
@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() != View.GONE) {
final int childWidth = childView.getMeasuredWidth();
mChildWidth = childWidth;
childView.layout(childLeft, 0, childLeft + childWidth,
childView.getMeasuredHeight());
childLeft += childWidth;
}
}
}
private void smoothScrollBy(int dx, int dy) {
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}
這個情況的攔截條件就是父容器在滑動過程中水平距離差比垂直距離差大,那麼就進行攔截,否則就不攔截,繼續傳遞事件。
內部攔截法
內部攔截法是指父容器不攔截任何事件,所有的事件都傳遞給子元素,如果子元素需要此事件就直接消耗掉,否則就交給父容器進行處理,這種方法和Android中的事件分發機制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起來較外部攔截法複雜。虛擬碼如下:
@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;
if (父容器需要此類點選事件) {
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
當子元素呼叫requestDisallowInterceptTouchEvent(false)方法時,父元素才能繼續攔截所需的事件。
前面是用自定義類似的ViewPager,現在重寫一個ListView,我們可以自定義一個ListView,叫做ListViewEx,然後對內部攔截法的模板程式碼進行修改即可。
public class ListViewEx extends ListView {
private static final String TAG = "ListViewEx";
private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;
// 分別記錄上次滑動的座標
private int mLastX = 0;
private int mLastY = 0;
public ListViewEx(Context context) {
super(context);
}
public ListViewEx(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ListViewEx(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public void setHorizontalScrollViewEx2(
HorizontalScrollViewEx2 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);
}
}
同時對於包含ListViewEx外部佈局進行修改,在onInterceptTouchEvent事件上不進行攔截
public class HorizontalScrollViewEx2 extends ViewGroup {
private static final String TAG = "HorizontalScrollViewEx2";
private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;
// 分別記錄上次滑動的座標
private int mLastX = 0;
private int mLastY = 0;
// 分別記錄上次滑動的座標(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
public HorizontalScrollViewEx2(Context context) {
super(context);
init();
}
public HorizontalScrollViewEx2(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalScrollViewEx2(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
@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;
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.d(TAG, "onTouchEvent action:" + event.getAction());
mVelocityTracker.addMovement(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;
Log.d(TAG, "move, deltaX:" + deltaX + " deltaY:" + deltaY);
scrollBy(-deltaX, 0);
break;
}
case MotionEvent.ACTION_UP: {
int scrollX = getScrollX();
int scrollToChildIndex = scrollX / mChildWidth;
Log.d(TAG, "current index:" + scrollToChildIndex);
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();
Log.d(TAG, "index:" + scrollToChildIndex + " dx:" + dx);
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
@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 widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
if (childCount == 0) {
setMeasuredDimension(0, 0);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
setMeasuredDimension(measuredWidth, heightSpaceSize);
} else {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(measuredWidth, measuredHeight);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
Log.d(TAG, "width:" + getWidth());
int childLeft = 0;
final int childCount = getChildCount();
mChildrenSize = childCount;
for (int i = 0; i < childCount; i++) {
final View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {
final int childWidth = childView.getMeasuredWidth();
mChildWidth = childWidth;
childView.layout(childLeft, 0, childLeft + childWidth,
childView.getMeasuredHeight());
childLeft += childWidth;
}
}
}
private void smoothScrollBy(int dx, int dy) {
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}
這個攔截規則也是父容器在滑動過程中水平距離差與垂直距離差相比。
小結
總的來說,滑動衝突的場景可以分為三種,內外部方向不一致、內外部方向一致、巢狀前面兩種情況。如何解決,不管多麼複雜的滑動衝突,可以進行拆分,根據的一定的規則,第一種情況可根據滑動距離差、速度差和角度差來解決,第二種和第三種情況,可根據業務上找到突破點,業務上一種狀態需要響應,切換到另外一種狀態時則不響應,根據業務需求得出相應的處理規則,有了處理規則可以進行下一步處理。