View事件體系

語言: CN / TW / HK

theme: cyanosis


開啟掘金成長之旅!這是我參與「掘金日新計劃 · 12 月更文挑戰」的第31天,點選檢視活動詳情

1.什麼是View?

View是Android中所有控制元件的基類,例如Android中的Button,TextView等元件的基類都是它,還有一個ViewGroup,意為控制元件組,也就是說包含了一組View,Android中的Linarlayout不僅是一個View還是一個ViewGroup,ViewGroup也是繼承了View

2.View的位置引數

left,top,right,bottom,原始碼中獲取他們引數的方法分別是,getLeft(),getTop(),getRight(),gitBottom(),Android3.0之後又加入了x,y,translationX,translationY,其中x,y指View左上角的座標,translationX,translationY是View左上角相對於容器的偏移量,並且預設為0,

3.VelocityTracker

熱度追蹤,用於追蹤手指在滑動過程中的速度,包括水平和垂直方向的速度。 VelocityTracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event); 獲取當前速度時可採用以下方式。 velocityTracker.computeCurrentVelocity(1000); int xVelocity = (int) velocityTracker.getXVelocity(); int yVelocity = (int) velocityTracker.getYVelocity(); 需要注意的是,獲取速度之前必須先計算速度即computeCurrentVelocity方法必須在getXVelocitygetYVelocity方法之前呼叫;這裡的速度是指一段時間內手指劃過的畫素數。 使用完畢後及的呼叫clear方法來重置並回收記憶體。

4.GestureDetector

手勢檢測,用於輔助檢測使用者的單擊,長按,滑動,雙擊等行為。 GestureDetector gestureDetector = new GestureDetector(this); //解決長按屏幕後無法拖動得現象 gestureDetector.setIsLongpressEnabled(false);

接著接管View的onTouchEvent,並實現如下程式碼

return gestureDetector.onTouchEvent(event);

5.View的滑動

  1. scrollTo/scrollBy

``` /* * Set the scrolled position of your view. This will cause a call to * {@link #onScrollChanged(int, int, int, int)} and the view will be * invalidated. * @param x the x position to scroll to * @param y the y position to scroll to / public void scrollTo(int x, int y) { if (mScrollX != x || mScrollY != y) { int oldX = mScrollX; int oldY = mScrollY; mScrollX = x; mScrollY = y; invalidateParentCaches(); onScrollChanged(mScrollX, mScrollY, oldX, oldY); if (!awakenScrollBars()) { postInvalidateOnAnimation(); } } }

/**
 * Move the scrolled position of your view. This will cause a call to
 * {@link #onScrollChanged(int, int, int, int)} and the view will be
 * invalidated.
 * @param x the amount of pixels to scroll by horizontally
 * @param y the amount of pixels to scroll by vertically
 */
public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
}

```

從原始碼可以看出scrollBy實際上呼叫了scrollTo方法,它實現了基於當前位置的相對滑動,而scrollTo實現了基於傳遞引數的絕對滑動。

其中mScrollX和mScrollY是View的兩個屬性,單位均為畫素,可以通過getScrollX和getScrollY得到,在滑動過程中,mScrollX的值總是等於View左邊緣和View內容左邊緣水平方向的距離,mScrollY的值總是等於View上邊緣和View內容上邊緣垂直方向的距離,View邊緣是指View的位置由四個頂點組成,View內容邊緣是指View中內容的邊緣。scrollBy和scrollTo的滑動中只能改變View內容的位置而不能改變View在佈局中的位置。mScrollX與mScrollY的單位為畫素並且當View內容從左向右滑動時mScrollX的值為負值,反之為正值,當View內容從上向下滑動時mScrollY的值為負值,反之為正值。

  1. 使用動畫

使用動畫可以使View進行平移,主要是操作View的translationX和translationY屬性

```

```

//使用屬性動畫,從原始位置向右移動100畫素 ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(100).start();

View動畫並不能真正改變View的位置,只是一個動畫而已,如果動畫執行完畢後保留移動後的位置還必須新增 android:fillAfter="true",如果是false則會回到原始位置。這就帶來一個問題,假設移動的View有點選事件並且View移動後保留在移動後的位置,此時點選事件只能在View原始的地方才能觸發,移動後所在的位置時無法觸發點選事件的。

  1. 改變佈局引數

這個比較簡單,修改LayoutParams的引數即可,例如將View向右平移100px,那麼只需要將View的LayoutParams中的marginLeft的值增加100px即可。

總結:上面描述了三種滑動方式,那麼該怎麼選擇呢?

(1)如果修改的是View的內容那麼選擇scrollTo/scrollBy即可;

(2)如果要做一些複雜且沒有互動的動畫效果那麼選擇動畫即可;

(3)如果有互動並且有滑動效果那麼選擇改變佈局引數即可。

6.彈性滑動

1.使用Scroll

``` Scroller scroller = new Scroller(this);

private void smoothScrollTo(int destX, int destY) { int scrollX = getScrollX(); int deltaX = destX - scrollX; //1000毫秒內滑向destX,效果就是慢慢滑動 mScroller.startScroll(scrollX, 0, deltaX, 0, 1000); invalidate(); }

@Override
public void computeScroll(){
    if(mScroller.computeScrollOffset()){
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

```

上面的程式碼是Scroller的典型的使用方法,從程式碼中看出滑動的過程是在mScroller.startScroll這一句,那我們看看startScroll做了什麼

/** * Start scrolling by providing a starting point, the distance to travel, * and the duration of the scroll. * * @param startX Starting horizontal scroll offset in pixels. Positive * numbers will scroll the content to the left. * @param startY Starting vertical scroll offset in pixels. Positive numbers * will scroll the content up. * @param dx Horizontal distance to travel. Positive numbers will scroll the * content to the left. * @param dy Vertical distance to travel. Positive numbers will scroll the * content up. * @param duration Duration of the scroll in milliseconds. */ public void startScroll(int startX, int startY, int dx, int dy, int duration) { mMode = SCROLL_MODE; mFinished = false; mDuration = duration; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mStartX = startX; mStartY = startY; mFinalX = startX + dx; mFinalY = startY + dy; mDeltaX = dx; mDeltaY = dy; mDurationReciprocal = 1.0f / (float) mDuration; }

startScroll什麼都沒有做,那滑動效果又是怎麼實現的呢,答案就是mScroller.startScroll下一行的invalidate();

invalidate()會導致View重繪,View的draw方法中又會呼叫computeScroll,computeScroll是一個空方法需要自己去實現,上面的程式碼中已經實現。

View在重繪後會在draw方法中呼叫computeScroll,computeScroll會通過Scroll獲取scrollX,scrollY,然後呼叫scrollTo方法實現滑動;接著呼叫postInvalidate方法進行第二次重繪,第二次的重繪與第一次的重繪一致,會呼叫computeScroll,獲取scrollX和scrollY,並通過scrollTo實現滑動到新的位置,如此反覆就完成了整個滑動過程。

再看一下Scroll中的computeScrollOffset方法

``` /* * Call this when you want to know the new location. If it returns true, * the animation is not yet finished. / public boolean computeScrollOffset() { ...

    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);

    if (timePassed < mDuration) {
        switch (mMode) {
        case SCROLL_MODE:
            final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
            mCurrX = mStartX + Math.round(x * mDeltaX);
            mCurrY = mStartY + Math.round(x * mDeltaY);
            break;
        ...
        }
    }
    else {
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

```

根據時間的流逝的百分比計算得出scrollX和scrollY改變的百分比並計算出當前的值,這個方法中如果最後返回false則表示繪製還未完成,返回true則表示繪製已完成。

總結:Scroll本身是不能實現View的滑動,需要藉助View的computeScroll方法才可以完成彈性滑動,它不斷的讓View重繪並得出每一次重繪需要的時間的間隔,通過這個間隔就可以得到View滑動的位置,知道了滑動位置通過scrollTo來完成View的滑動,View每一次的小幅度滑動組合在一起就組成了Scroll的彈性滑動。


2.通過動畫

動畫本身就具有彈性動畫的效果,這裡聊聊用模仿Scroller來實現View的彈性滑動

``` final int startX = 0; final int detalX = 100; ValueAnimator mAnimator = ValueAnimator.ofInt(0, 1).setDuration(1000);

mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float fraction = animation.getAnimatedFraction(); mBinding.btnForth.scrollTo(startX + (int) (detalX * fraction), 0); } }); mAnimator.start(); ```

根據上述程式碼可以得知,動畫本身並沒有作用於任何物件,它只是在1000毫秒內完成整個動畫過程。利用這個特性就可以在動畫的每一幀到來時獲取動畫完成的比例,然後再根據這個比例來計算出當前View要滑動的距離。這裡View滑動的不是View本身而是View的內容。

3.使用延時策略

核心思想是通過傳送一系列的延時訊息來達到漸近式的效果。可以使用Handler或者View的postDelayed,也可以使用執行緒的sleep方法,其中對於postDelayed方法可以不斷地傳送延時訊息,然後在訊息中進行View滑動,對於sleep可以再while迴圈中不斷地滑動View和sleep就可以實現彈性滑動的效果。

7.View的事件分發機制

事件分發本質上分發的是MotionEvent,當一個MotionEvent產生後,系統需要把它分配給一個具體的View,它由三個方法共同完成。

public boolean dispatchTouchEvent(MotionEvent ev)

用來進行事件的分發,如果事件能夠傳遞給當前View那麼該方法一定會被呼叫,返回結果受當前View的onTouchEvent和下級View的dispatchTouchEvent影響,表示是否消耗當前事件。

public boolean onInterceptTouchEvent(MotionEvent ev)

用來判斷是否攔截某個事件,如果當前View攔截了某個事件那麼在同一個事件序列中該方法將不會被再次呼叫,返回結果表示是否攔截了事件。

public boolean onTouchEvent(MotionEvent ev)

在dispatchTouchEvent方法中呼叫,表示是否消耗事件,如果沒有消耗事件那麼在同一個事件系列中當前View將不會再收到事件,返回結果表示是否消耗了事件。

上述三個方法可以用如下虛擬碼來表示

public boolean dispatchTouchEvent(MotionEvent ev){ boolean consume = false; if(onInterceptTouchEvent(ev)){ consume = onTouchEvent(ev); }else{ consume = child.dispatchTouchEvent(ev); } return consume; }

結論:

  1. 同一個事件序列是指從手指觸控式螢幕幕開始到手指離開螢幕結束,以down開始,以up結束,中間包含無數的move;
  2. 正常情況下,一個事件序列只能被一個View攔截且消耗。因為一旦一個元素攔截了某些事件,那麼剩下的事件都將會交給它來處理,一個事件是不可以交給兩個View來同時處理,但是通過特殊手段是可以的,比如一個View將本該自己處理的事件通過onTouchEvent強行傳遞給其他View處理。
  3. 某個View一旦決定攔截,那麼一個事件序列都只能由它來處理,並且它的onInterceptTouchEvent將不會再被呼叫。
  4. 某個View一旦開始處理事件,如果他不消耗ACTION_DOWN事件,那麼後續都不會再交給它來處理,後續事件都會由它的上級去處理。
  5. 如果View不消耗除ACTION_DOWN以外的事件,那麼點選事件將會消失,此時父元素的onTouchEvent並不會被呼叫,並且當前View仍然會收到後續的事件,最後這些事件將交給Activity處理。
  6. ViewGroup預設不攔截任何事件,在原始碼中onInterceptTouchEvent方法預設返回false。
  7. View沒有onInterceptTouchEvent方法,一旦有點選事件傳遞給它,那麼它的onTouchEvent方法就會被呼叫。
  8. View的OnTouchEvent預設是被消耗的,除非不可點選(clickable和longClickable都是false),longClickable預設返回false,clickable則是要分情況的,例如Button的clickable預設是true,TextView的clickable預設是false。
  9. View的enable屬性是不會影響onTouchEvent的返回結果的,只要longClickable和clickable有一個為true那麼onTouchEvent就返回true。
  10. onClick會發生的前提是View可點選,並且收到了down和up事件。
  11. 事件的分發是由外向內的,即事件總是先傳遞給父元素,然後由父元素分發給子View,通過requestDiallowInterceptTouchEvent可以在子元素中干預父元素的事件分發過程,但是ACTION_DOWN除外。

原始碼分析:

1.Activity對點選事件的分發

點選事件是由MotionEvent來表示,當一個點選操作發生時,先傳遞給Activity,然後Activity通過dispatchTouchEvent進行事件分發,具體工作是由Activity內部的Window完成,Window會將事件傳遞給DecroView,DecroView就是系統的底層容器,通過Activity.getWindow().getDeroView()獲得。

/** * Called to process touch screen events. You can override this to * intercept all touch screen events before they are dispatched to the * window. Be sure to call this implementation for touch screen events * that should be handled normally. * * @param ev The touch screen event. * * @return boolean Return true if this event was consumed. */ public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { onUserInteraction(); } if (getWindow().superDispatchTouchEvent(ev)) { return true; } return onTouchEvent(ev); }

首先由Activity所附屬的Window進行分發,如果返回true那麼事件迴圈就結束了,如果返回false則說明事件沒有被處理,所有View的onTouchEvent都會返回false,那麼Activity的onTouchEvent就會被呼叫。

那麼Window是如何進行事件分發的呢,或者說Window是如何將事件傳遞給ViewGroup的呢?

/** * Abstract base class for a top-level window look and behavior policy. An * instance of this class should be used as the top-level view added to the * window manager. It provides standard UI policies such as a background, title * area, default key processing, etc. * * <p>The only existing implementation of this abstract class is * android.view.PhoneWindow, which you should instantiate when needing a * Window. */ public abstract class Window { ... /** * Used by custom windows, such as Dialog, to pass the touch screen event * further down the view hierarchy. Application developers should * not need to implement or call this. * */ public abstract boolean superDispatchTouchEvent(MotionEvent event); ... }

檢視Window的原始碼得知Window是一個抽象類,superDispatchTouchEvent也是一個抽象方法,那麼Window的實現類是誰呢,看Window類的註釋,大意如下:

Window類可以控制頂級View的外觀和行為策略,它的唯一實現是android.view.PhoneWindow。那麼我們看一下PhoneWindow

``` // This is the top-level view of the window, containing the window decor. private DecorView mDecor;

@Override public boolean superDispatchTouchEvent(MotionEvent event) { return mDecor.superDispatchTouchEvent(event); }

@Override public final View getDecorView() { if (mDecor == null || mForceDecorInstall) { installDecor(); } return mDecor; } ```

可以看到PhoneWindow直接將事件傳遞給了DecorView,DecorView又是什麼呢

public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks { ... }

DecorView繼承了FrameLayout,是父View,我們知道(ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content).getChildAt(0)這種方式可以獲取Activity所設定的View,那麼上面的mDecorView就是getWindow().getDecorView()返回的View,而通過setContentView設定的View是它的子View,由於DecorView繼承的是FrameLayout且是父View,所以最終事件會傳遞給子View。

結論:點選事件到達頂級View(一般是一個ViewGroup)以後,會呼叫ViewGroup的dispatchTouchEvent,然後的邏輯是這樣的:如果ViewGroup的 onInterceptTouchEvent返回true,那麼事件將由ViewGroup來處理,如果設定了mOnTouchListener那麼onTouch將會被呼叫,否則onTouchEvent被呼叫。也就是說onTouch會遮蔽onTouchEvent。在onTouchEvent中如果設定了onClickListener那麼onClick就會執行。如果ViewGroup沒有攔截事件那麼會傳遞給這個點選事件鏈上的子View,這時子View的dispatchTouchEvent將會被呼叫。到此為止,事件已經從頂級View傳遞給了下一級View,如此迴圈就會完成一個完整的事件分發。

2.頂級View對事件的分發過程

ViewGroup中的dispatchTouchEvent是如何分發事件的呢?看一下ViewGroup中的dispatchTouchEvent部分原始碼

// Check for interception. final boolean intercepted; if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) { final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; if (!disallowIntercept) { intercepted = onInterceptTouchEvent(ev); ev.setAction(action); // restore action in case it was changed } else { intercepted = false; } } else { // There are no touch targets and this action is not an initial down // so this view group continues to intercept touches. intercepted = true; }

從上述程式碼可以看出,ViewGroup在actionMasked == MotionEvent.ACTION_DOWN和mFirstTouchTarget != null兩種情況下會判斷是否攔截當前事件,ACTION_DOWN很好理解,從後面的程式碼可以得知mFirstTouchTarget != null的意思是當事件由ViewGroup不攔截事件並將事件交給子元素處理時mFirstTouchTarget != null,反過來一旦事件由當前ViewGroup攔截時那麼mFirstTouchTarget != null就不成立。

3.View對點選事件的處理過程

View對點選事件的處理比較簡單,這裡不包含ViewGroup,因為View是一個單獨的元素,它沒有子元素因此無法向下傳遞,所以那他只能自己處理。

先檢視View的dispatchTouch原始碼

``` /* * Pass the touch screen motion event down to the target view, or this * view if it is the target. * * @param event The motion event to be dispatched. * @return True if the event was handled by the view, false otherwise. / public boolean dispatchTouchEvent(MotionEvent event) { ... boolean result = false;

    ...
    if (onFilterTouchEventForSecurity(event)) {
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
            result = true;
        }
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }

        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }
    ...

    return result;
}

```

從原始碼可以看出,首先判斷是否設定了onTouchListener,如果onTouchListener中的onTouch方法返回true,那麼onTouchEvent就不會被呼叫,由此可以驗證上面所說的onTouclhListener的優先順序高於onTouchEvent,這樣做的好處是方便在外界處理點選事件。

8.View的滑動衝突

1.常見的滑動衝突場景

  • 場景一:外部滑動方向和內部滑動方向不一致;
  • 場景二:外部滑動方向和內部滑動方向一致;
  • 上面兩種場景巢狀。

2.滑動衝突處理規則

  • 場景一:根據滑動方向來判斷到底由誰來攔截事件,左右滑動時由外部View來攔截事件,上下滑動時有內部View來攔截事件。如何知道是上下滑動還是左右滑動呢,可以在滑動過程中根據兩個點的座標來確定,通過座標來判斷的話最簡單的就是根據滑動的夾角或者根據水平方向或者垂直方向上滑動的距離。
  • 場景二:它無法根據夾角和距離來處理,但是大多數情況下可以根據業務來判斷,比如業務上規定,當處於某種狀態時需要外部View響應使用者的滑動,而處於另外一種狀態時則需要內部View來響應View的滑動。
  • 場景三:場景三的處理方式與場景二基本一致,都是要從業務上來解決這個問題。

3.滑動衝突的解決方式

  • 外部攔截法

外部攔截法就是指所有點選事件都先經過父容器的攔截,父容器需要則進行攔截,不需要則交給子容器處理,這樣就可以解決滑動衝突的問題,比較符合點選事件的分發機制。外部攔截法需要重寫onInterceptTouchEvent然後在內部做相應的處理即可。

@Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercepted = false; int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.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; } mLastXIntercept = 0; mLastYIntercept = 0; return intercepted; }

上面的程式碼中ACTION_DOWN必須返回false,因為ACTION_DOWN返回true的話就意味著事件被父容器攔截,後續事件ACTION_MOVE和ACTION_UP將不會再傳遞給子容器處理,ACTION_MOVE要根據父容器的需求來判定是否攔截,ACTION_UP沒有什麼意義,返回false即可。

如果事件交由子元素處理,父容器ACTION_UP返回了true,這就導致子元素無法收到ACTION_UP事件,子元素的onClick也就無法呼叫,但是父容器比較特殊,一旦開始攔截一個事件那後續的事件都會交給他來處理,而ACTION_UP作為最後一個事件也必定會傳遞給父容器,即使父容器的onInterceptTouchEvent方法中的ACTION_UP返回了false。

  • 內部攔截法

內部攔截法是指父容器不攔截任何事件,將事件全部傳遞給子元素,如果子元素需要該事件那麼直接消耗即可,不需要則交給父容器來處理,這種方法需要配合Android中的requestDisallowInterceptTouchEvent方法才能正常工作,虛擬碼如下。

``` @Override public boolean dispatchTouchEvent(MotionEvent ev) { int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: parent.requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: int deltaX = x - mLastX; int deltaY = y - mLastY; if (父容器需要此類點選事件) { parent.requestDisallowInterceptTouchEvent(true); } break; case MotionEvent.ACTION_UP:

        break;
    default:
        break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(ev);

} ```

除了子元素需要處理以外,父元素也要預設攔截除了ACTION_DOWN以外的其他事件,這樣當子元素呼叫了parent.requestDisallowInterceptTouchEvent(true)時,父元素才能繼續攔截所需的事件。