前言
以前就想着做一系列實際項目中用到關於事件分發的例子,以前研究過壁紙,有一種壁紙是音樂鎖屏壁紙,音樂鎖屏就需要用到右滑結束activity,遂想着研究事件分發不錯的例子,當時是使用ViewDraghelper實現的,不過部分事件分發沒處理好,此次正好處理一下。其實五一之前就做好一部分了,某一天清桌面誤刪文件,導致現在做的是重新做的,還有另一種方式放下一篇敘述。
分析階段
- 一:需要一個通用的ViewGroup隨着手勢右滑滾動
- 二:在Move事件結束之後,如果超過閾值就關閉當前ViewGroup,否者回歸到默認位置
- 三:滑動結束之後通過所處位置來判定當前activity的是否結束狀態
- 四:需要當前ViewGroup包裹activity的佈局一起滑動
- 五:滑動結束之後需要符合系統默認動畫,平滑過渡
- 六:滑動過程中需要下層activity顯示
- 七:右滑事件處理和事件衝突處理
通過以上七點分析,因此可以指定步驟,按照步驟一步一步解決即可。需要平滑滾動,選擇Scroller開實現平滑滾動效果,第四點的話,需要把自定義的ViewGroup添加到DecorView第一個位置,這樣即可實現包裹activity的佈局。具體步驟如下:
具體步驟
事件分發與消費
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean interceptd = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
interceptd = false;
mDownX = event.getX();
mDownY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
//計算移動距離 判定是否滑動
float dx = event.getX() - mDownX;
float dy = event.getY() - mDownY;
if (dx > minTouchSlop && dx - Math.abs(dy) > minTouchSlop ) {
interceptd = true;
} else {
interceptd = false;
}
break;
case MotionEvent.ACTION_UP:
interceptd = false;
break;
}
return interceptd;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
float dx = event.getX() - mDownX;
if (getScrollX() - dx >= 0) {
scrollTo(0, 0);
} else {
scrollBy((int) -dx, 0);
}
mDownX = event.getX();
break;
case MotionEvent.ACTION_UP:
// 根據手指釋放時的位置決定回彈還是關閉
int scrollX = getScrollX();
if (-scrollX < getWidth() * mSlideFinishRadio) {
smoothScrollX(scrollX, -scrollX, smoothscrollTime, mScroller);
} else {
smoothScrollX(scrollX, -scrollX - getWidth(), smoothscrollTime, mScroller);
}
break;
}
return true;
}
/**
* 平滑的滾動到某個位置
*
* @param startX 開始位置
* @param endX 結束位置
* @param duration 時間
* @param mScroller
*/
private void smoothScrollX(int startX, int endX, int duration, Scroller mScroller) {
mScroller.startScroll(startX, 0, endX, 0, duration);
invalidate();
}
說明:1、dx > minTouchSlop:這個條件判定右滑,並且超過系統能檢測到的最小滑動距離
2、dx - Math.abs(dy) > minTouchSlop:右滑優先級高於上下滑動,(亦可dx - Math.abs(dy) > 0)
3、getScrollX() - dx >= 0:判定滑動是否超越左邊界,超過的話滾動到默認位置(0,0)
4、scrollBy((int) -dx, 0):通過不斷修改當前的位置去滾到相應的位置
5、-scrollX < getWidth() * mSlideFinishRadio:判斷當前的滾動的位置與設置的閾值大小來斷定最終位置
6、mScroller.startScroll(startX, 0, endX, 0, duration):開啓鬆手之後滾動到指定位置(具體參數意思已經註釋)
下面方法主要是處理Scroller平滑滾動過程,
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), 0);
postInvalidate();
} else if (-getScrollX() >= getWidth()) {
mActivity.finish();
}
}
說明:mScroller.computeScrollOffset() :只要scrollTo()的過程沒完成,此方法的回調一直爲true,通過不斷的調用scrollTo()和postInvalidate()(線程安全的方法)去刷新界面,如果滑動結束,並且左邊界滑動的最右邊的時候結束activity
綁定activity
最終我們的效果是實現右滑到一定閾值結束當前的activity,因此需要把當前的viewgroup加入當前activity所在的DecorView 中.
/**
* 綁定Activity
*/
public void attachActivity(Activity activity) {
mActivity = activity;
ViewGroup decorView = (ViewGroup) mActivity.getWindow().getDecorView();
View child = decorView.getChildAt(0);
child.setBackgroundResource(android.R.color.white);
decorView.removeView(child);
addView(child);
decorView.addView(this);
}
說明: child.setBackgroundResource(android.R.color.white),爲什麼要設置背景色?暫且留着後面會說因爲說明原因
設置樣式
通過以上步驟已經可以實現滑動結束activity過程,但是滑動過程中,背景一直爲白色,結束之後並且會有突兀的動畫效果。爲了實現滑動過程中有漸變的效果,遂設置當前window背景透明色,但是如果這樣設置的話,當前activity在不滑動過程中也是透明狀態,因此需要給activity佈局設置一個背景色,上面attachActivity()設置白色就是爲了統一設置,避免每次都去設置activity的根佈局顏色。設置樣式如下:
<!--<item name="android:windowFullscreen">true</item>-->
<style name="SlideTheme" parent="@style/AppTheme">
<!--Required-->
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowAnimationStyle">@style/SlideAnimation</item>
</style>
說明:1、windowAnimationStyle的作用是滑動超過閾值之後結束activity的動畫與設置的滑動效果一致
windowIsTranslucent
這裏單獨說一下這個屬性windowIsTranslucent,先說這個屬性的作用,如果windowIsTranslucent爲false的話,無論windowBackground設置的是什麼顏色,此時window背景都不可能爲透明色,因此兩者要搭配使用纔有效果。windowBackground最終設置方法在源碼DecorView的裏面,也就是當前activity的最底層的背景色。下面是設置DecorView背景色在源碼中的方法。
設置背景色
public void setWindowBackground(Drawable drawable) {
if (getBackground() != drawable) {
setBackgroundDrawable(drawable);
if (drawable != null) {
mResizingBackgroundDrawable = enforceNonTranslucentBackground(drawable,
mWindow.isTranslucent() || mWindow.isShowingWallpaper());
} else {
mResizingBackgroundDrawable = getResizingBackgroundDrawable(
getContext(), 0, mWindow.mBackgroundFallbackResource,
mWindow.isTranslucent() || mWindow.isShowingWallpaper());
}
if (mResizingBackgroundDrawable != null) {
mResizingBackgroundDrawable.getPadding(mBackgroundPadding);
} else {
mBackgroundPadding.setEmpty();
}
drawableChanged();
}
}
/**
* Enforces a drawable to be non-translucent to act as a background if needed, i.e. if the
* window is not translucent.
*/
private static Drawable enforceNonTranslucentBackground(Drawable drawable,
boolean windowTranslucent) {
if (!windowTranslucent && drawable instanceof ColorDrawable) {
ColorDrawable colorDrawable = (ColorDrawable) drawable;
int color = colorDrawable.getColor();
if (Color.alpha(color) != 255) {
ColorDrawable copy = (ColorDrawable) colorDrawable.getConstantState().newDrawable()
.mutate();
copy.setColor(
Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)));
return copy;
}
}
return drawable;
}
事件衝突處理
上述步驟已經可以實現右滑結束activity效果了,但是未提及分析過程中的第七條。主要是因爲事件滑動衝突問題處理是Android系統事件分發中最難處理的一塊,因此放在最後處理,即嵌套滑動衝突問題。關於嵌套滑動,大家應該都不默認,剛接觸那會估計都被ScrollView裏面嵌套ListView或者RecyclerView困擾過,因爲兩者都是可以滾動的,到底滑動事件分發應該怎麼做呢,理想情況下是列表滾動到頭部或者尾部再把事件交給ScrollView處理,但是實際情況是,要麼是兩者同時滾動要麼是列表只顯示一部分。這是因爲ScrollView源碼裏也是使用Scroller來實現滑動,因此ScrollView的第一層子類只能有一個,通過遍歷,測量所有子View的寬和高,而ListView和RecyclerView內部都是使用緩存複用機制,因此ScrollView並不能一次性測量到所有的ListView或RecyclerView的item。網上有很多關於解決ScrollView嵌套ListView或RecyclerView的方案,其核心思想還是測量出所有ListView或RecyclerView的子類的寬高,因此導致ListView或RecyclerView緩存複用機制無效,谷歌也是不建議這樣做,因此最好不要做嵌套。迴歸主題,如果側滑的activity裏面有ViewPager會怎麼樣?沒錯,因爲自定義的攔截條件約束在:
dx > minTouchSlop && dx - Math.abs(dy) > minTouchSlop
假如現在ViewPager不是處於第一項,自定義的側滑ViewGroup如果滿足上面判定攔截條件,ViewPager的滾動機制一定會被攔截掉,一直響應側滑。但是理想情況下,希望ViewPager右滑的過程中滑動到左邊第一項的時候再被攔截。因爲ViewPager內部肯定是處理了滑動事件,因此可以參ViewPager內部怎麼處理方式。ViewPager內部也是通過Scroller來處理滑動過程的,查看ViewPager源碼有沒有檢測滑動的方法,如下所示:
protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) {
if (v instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) v;
final int scrollX = v.getScrollX();
final int scrollY = v.getScrollY();
final int count = group.getChildCount();
// Count backwards - let topmost views consume scroll distance first.
for (int i = count - 1; i >= 0; i--) {
// TODO: Add versioned support here for transformed views.
// This will not work for transformed views in Honeycomb+
final View child = group.getChildAt(i);
if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight()
&& y + scrollY >= child.getTop() && y + scrollY < child.getBottom()
&& canScroll(child, true, dx, x + scrollX - child.getLeft(),
y + scrollY - child.getTop())) {
return true;
}
}
}
return checkV && v.canScrollHorizontally(-dx);
}
ViewPager內部也是通過遍歷所有子View的滾動方向,然後調用v.canScrollHorizontally(-dx)來判定水平方向上是否有可以滾動的子View。主要研究v.canScrollHorizontally(-dx),此方法是View的可重寫方法。
/**
* Check if this view can be scrolled horizontally in a certain direction.
*
* @param direction Negative to check scrolling left, positive to check scrolling right.
* @return true if this view can be scrolled in the specified direction, false otherwise.
*/
public boolean canScrollHorizontally(int direction) {
final int offset = computeHorizontalScrollOffset();
final int range = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
說明:1、參數direction的意思是:檢查向左滾動爲負,檢查向右滾動爲正。(左滾動是從左到右,scrollx爲負值)
2、返回值的意思:如果這個視圖可以在指定的方向上滾動,則返回true,否則返回false。
3、由於computeHorizontalScrollRange() 與computeHorizontalScrollExtent()方法的返回值調用同一個方法,因此方法返回值默認爲false,後面會用到。
因爲之前研究刷新控件知道的這個方法,當然如果activity裏面只是ViewPager,通過調ViewPager重寫的canScrollHorizontally(int direction) 即可實現想要的效果,但是activity裏面也肯存在其他列表(ListView、RecyclerView、ScrollView等)),因此需要遍歷activity裏面View樹,只要有一個View或者ViewGroup可以在從左到右的方向上滾動,就不去攔截子類的右滑事件。所有的View或者ViewGroup的返回值都爲false才把右滑事件交給SlideLayout處理。因此採用遞歸方式遍歷所有的View樹結構:
/**
* 是否左右可以滾動
*
* @param direction
* @param view
*/
private boolean canScrollHorizontally(int direction, View view) {
if (view.canScrollHorizontally(direction)) {
return true;
} else {
if (view instanceof ViewGroup) {
ViewGroup viewParent = (ViewGroup) view;
int childCount = viewParent.getChildCount();
for (int i = 0; i < childCount; i++) {
View chideView = viewParent.getChildAt(i);
boolean childCanScroll = canScrollHorizontally(direction, chideView);
if (childCanScroll) {
return true;
}
}
}
return false;
}
}
說明:因爲要遍歷所有的View樹,並且canScrollHorizontally()方法默認返回值是false,因此必須重寫。(記得要再加個判空)
測試適配
完成上述步驟,遂做各種情況的適配,Android系統可以滾動的列表基本都實現了canScrollHorizontally(),目前爲止測試瞭如下圖所示的情況:
如上圖所示,通過上圖所有測試,發現兩個不適配,倒數第二個是前陣子做的Android 事件分發實例之可拖動的ViewGroup,因爲之前沒想到做這個側滑適配,因此不匹配,適配方案如下:
下面是onTouchEvent()中的Down事件,如果處於右邊緣,canScrollHorizontally()方法右滑返回值爲false,如果處於左邊緣,
case MotionEvent.ACTION_DOWN:
float rightX = mParentWidth - getWidth();
float x = getX();
canScrollH = x != rightX;
canScrollH2 = x != 0;
break;
目前已經更新,可適配。另一個不適配的是最後一個DrawerLayout,這個較爲特殊,內部維持了一個WindowInsets集合,不同的View可以通過注入的方式添加到DrawerLayout裏面,這個瞭解的不多,這個只支持SDK21以上版本,WindowInsetsCompat是其兼容版本,有空研究一下這個。接着說DrawerLayout是通過ViewDragHelper處理事件分發。
@SuppressWarnings("ShortCircuitBoolean")
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getActionMasked();
// "|" used deliberately here; both methods should be invoked.
final boolean interceptForDrag = mLeftDragger.shouldInterceptTouchEvent(ev)
| mRightDragger.shouldInterceptTouchEvent(ev);
boolean interceptForTap = false;
switch (action) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
mInitialMotionX = x;
mInitialMotionY = y;
if (mScrimOpacity > 0) {
final View child = mLeftDragger.findTopChildUnder((int) x, (int) y);
if (child != null && isContentView(child)) {
interceptForTap = true;
}
}
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
break;
}
case MotionEvent.ACTION_MOVE: {
// If we cross the touch slop, don't perform the delayed peek for an edge touch.
if (mLeftDragger.checkTouchSlop(ViewDragHelper.DIRECTION_ALL)) {
mLeftCallback.removeCallbacks();
mRightCallback.removeCallbacks();
}
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP: {
closeDrawers(true);
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
}
}
return interceptForDrag || interceptForTap || hasPeekingDrawer() || mChildrenCanceledTouch;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
mLeftDragger.processTouchEvent(ev);
mRightDragger.processTouchEvent(ev);
final int action = ev.getAction();
boolean wantTouchEvents = true;
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
mInitialMotionX = x;
mInitialMotionY = y;
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
break;
}
case MotionEvent.ACTION_UP: {
final float x = ev.getX();
final float y = ev.getY();
boolean peekingOnly = true;
final View touchedView = mLeftDragger.findTopChildUnder((int) x, (int) y);
if (touchedView != null && isContentView(touchedView)) {
final float dx = x - mInitialMotionX;
final float dy = y - mInitialMotionY;
final int slop = mLeftDragger.getTouchSlop();
if (dx * dx + dy * dy < slop * slop) {
// Taps close a dimmed open drawer but only if it isn't locked open.
final View openDrawer = findOpenDrawer();
if (openDrawer != null) {
peekingOnly = getDrawerLockMode(openDrawer) == LOCK_MODE_LOCKED_OPEN;
}
}
}
closeDrawers(peekingOnly);
mDisallowInterceptRequested = false;
break;
}
case MotionEvent.ACTION_CANCEL: {
closeDrawers(true);
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
break;
}
}
return wantTouchEvents;
}
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (CHILDREN_DISALLOW_INTERCEPT
|| (!mLeftDragger.isEdgeTouched(ViewDragHelper.EDGE_LEFT)
&& !mRightDragger.isEdgeTouched(ViewDragHelper.EDGE_RIGHT))) {
// If we have an edge touch we want to skip this and track it for later instead.
super.requestDisallowInterceptTouchEvent(disallowIntercept);
}
mDisallowInterceptRequested = disallowIntercept;
if (disallowIntercept) {
closeDrawers(true);
}
}
因此DrawerLayout並不需要處理canScrollHorizontally(direction)這個方法,爲了兼容,需要自行處理如下:
自定義LeftDrawerLayout繼承DrawerLayout,在分發事件處理。
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = event.getX();
if (mDownX <= mEdgeSize) {
canScrollH = !isDrawerOpen(GravityCompat.START);
} else {
canScrollH = false;
}
break;
}
return super.dispatchTouchEvent(event);
}
疑惑:1、爲什麼在dispatchTouchEvent()方法裏面處理,而不是onInterceptTouchEvent()方法裏處理
2、mDownX <= mEdgeSize爲什麼有這個判斷
解答疑惑:首先說明一個問題,一個事件總是包含三個Down、Move、Up的,偶爾還有Cancle。只要有一個Down流向某一塊,其他Move、Up也會流向到某個,舉個例子:客廳的燈是一條電路線,這條線中包含火線、零線、地線,客廳的燈不需要地線,但是地線也是跟着火線、零線綁定在一起流向客廳的燈的。此時來解釋第一個問題,爲什麼判斷條件放在dispatchTouchEvent()裏面,那是因爲onInterceptTouchEvent()交給ViewDragHelper處理,因此重寫onInterceptTouchEvent()是無法監聽到Move事件的,也就不難作出判斷。
解決第二個疑問,mEdgeSize是ViewDragHelper檢測邊緣的固定值(20dp),isDrawerOpen(GravityCompat.START)這個方法是檢測DrawerLayout的抽屜視圖是否打開狀態,按下位置在左邊緣的話有兩種情況,一種是:如果抽屜視圖是打開狀態,則交給側滑,第二種是:如果關閉狀態則交給ViewDragHelper處理。
特此說明:代碼中暫時只處理左邊抽屜視圖情況,右邊抽屜視圖情況同理解決。
總結
關於本篇實現右滑結束activity的方式,重點有四點,第一點:實現攔截, 第二點實現平滑滾動,第三點:遞歸遍歷子View是否可以右滑,第四點:樣式處理。具體情況還需參考代碼。其他細節,如側滑過程中,右邊緣和背景的繪製等暫時沒做特殊處理,現在還在做進一步的封裝,後續會持續更新最新代碼。
右滑結束Activity