13.處理複雜的觸控事件
13.1 問題
應用程式需要實現自定義的單點觸控或多點觸控來與UI進行互動。
13.2 解決方案
(API Level 3)
可以使用框架中的GestureDetector和ScaleGestureDetector,或者乾脆通過覆寫onTouchEvent()和onInterceptTouchEvent()方法來手動處理傳遞給檢視的所有觸控事件。前者可以很容易地在應用程式中新增複雜的手勢控制。後者則非常強大,但也有一些需要注意的地方。
Android通過自上而下的分發系統來處理UI上的觸控事件,這是框架在多層結構中傳送訊息的通用模式。觸控事件源於頂層視窗並首先發送給Activity。然後,這些事件被分發到已載入檢視層次結構中的根檢視,並從父檢視依次傳遞給相應的子檢視,直到事件被處理或者整個檢視鏈都已經傳遞。
每個父檢視的工作就是確認一個觸控事件應該傳送給哪個子檢視(通常通過檢查檢視的邊界)以及以正確的順序將事件分發出去。如果可以分發給多個子檢視(例如子檢視是重疊的),父檢視會按照子檢視的新增順序 反向地將事件分發出去,這樣就可以保證疊置順序中最高級別的檢視(頂層檢視)可以優先獲得觸控事件。如果沒有子檢視處理事件,則父檢視在該事件傳回到檢視層次結構之前會獲得處理該事件的機會。
任何檢視都可以通過在其onTouchEvent()方法中返回true來表明已經處理了某個特定的觸控事件,這樣該事件就不會再向其他地方分發了。所有ViewGroup的額外功能都可以通過onInterceptTouchEvent()回撥方法攔截或竊取傳遞給其子檢視的觸控事件。這在父檢視需要控制某個特定用例的場景下非常有用,例如ScrollView會在其檢測到使用者拖動手指之後控制觸控事件。
在手勢進行的過程中會有幾種不同的觸控事件動作識別符號:
- ACTION_DOWN : 當第一根手指點選螢幕時的第一個事件。這個事件通常是新手勢的開始。
- ACTION_MOVE :當第一根手指在螢幕上改變位置時的事件。
- ACTION_UP :最後一根手指離開螢幕時的接收事件。這個事件通常是一個手勢的結束。
- ACTION_CANCEL :這個事件被子檢視收到,即在子檢視接收事件時父檢視攔截了手勢事件。和ACTION_UP一樣,這標誌著檢視上的手勢操作已經結束。
- ACTION_POINTER_DOWN : 當另一根手指點選螢幕時的事件。在切換為多點觸控時很有用。
- ACTION_POINTER_UP : 當另一根手指離開螢幕時的事件。在切換出多點觸控時很有用。
為了提高效率,在一個檢視沒有處理ACTION_DOWN事件的情況下,Android將不會向該檢視傳遞後續的事件。因此,如果你正在自定義處理觸控事件並希望處理後續的事件,那麼必須在ACTION_DOWN事件中返回true。
如果在一個父ViewGroup的內部實現自定義觸控事件處理器,你可能還需要在onInterceptTouchEvent()方法中編寫一些程式碼。這個方法的工作方式和onTouchEvent()類似,如果返回true,自定義檢視就會接管手勢後續所有的觸控事件(即ACTION_UP和ACTION_UP之前的所有事件)。這個操作是不可取消的,在確定接管所有事件之前不要輕易攔截這些事件。
最後,Android提供了大量有用的閾值常量,這些值可以根據裝置螢幕的解析度進行縮放,可以用於構建自定義觸控互動。這些常數都儲存在ViewConfiguration類中。本例中會用到最小和最大急滑(fling)速率值以及觸控傾斜常量,表示ACTION_MOVE事件變化到什麼程度才表示是使用者手指的真實移動動作。
13.3 實現機制
以下清單程式碼演示了一個自定義的ViewGroup,該ViewGroup實現了平面滾動,即在內容足夠大的情況下,允許使用者在水平方向和垂直方向上進行滾動。該實現使用GestureDetector來處理觸控事件。
通過GestureDetector自定義ViewGroup
public class PanGestureScrollView extends FrameLayout { private GestureDetector mDetector; private Scroller mScroller; /* 最後位移事件的位置 */ private float mInitialX, mInitialY; /* 拖曳閾值*/ private int mTouchSlop; public PanGestureScrollView(Context context) { super(context); init(context); } public PanGestureScrollView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public PanGestureScrollView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(Context context) { mDetector = new GestureDetector(context, mListener); mScroller = new Scroller(context); // 獲得觸控閾值的系統常量 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); } /* * 覆寫measureChild…的實現來保證生成的子檢視儘可能大 * 預設實現會強制一些子檢視和該檢視一樣大 */ @Override protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) { int childWidthMeasureSpec; int childHeightMeasureSpec; childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } @Override protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { final MarginLayoutParams lp = (MarginLayoutParams) child .getLayoutParams(); final int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec( lp.leftMargin + lp.rightMargin, MeasureSpec.UNSPECIFIED); final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } // 處理所有觸控事件的監聽器 private SimpleOnGestureListener mListener = new SimpleOnGestureListener() { public boolean onDown(MotionEvent e) { // 取消當前的急滑動畫 if (!mScroller.isFinished()) { mScroller.abortAnimation(); } return true; } public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { //呼叫一個輔助方法來啟動滾動動畫 fling((int) -velocityX / 3, (int) -velocityY / 3); return true; } public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // 任何檢視都可以呼叫它的 scrollBy() 進行滾動 scrollBy((int) distanceX, (int) distanceY); return true; } }; @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { // 會在ViewGroup繪製時呼叫 //我們使用這個方法保證急滑動畫的順利完成 int oldX = getScrollX(); int oldY = getScrollY(); int x = mScroller.getCurrX(); int y = mScroller.getCurrY(); if (getChildCount() > 0) { View child = getChildAt(0); x = clamp(x, getWidth() - getPaddingRight() - getPaddingLeft(), child.getWidth()); y = clamp(y, getHeight() - getPaddingBottom() - getPaddingTop(), child.getHeight()); if (x != oldX || y != oldY) { scrollTo(x, y); } } // 在動畫完成前會一直繪製 postInvalidate(); } } // 覆寫 scrollTo 方法進行每個滾蛋請求的邊界檢查 @Override public void scrollTo(int x, int y) { // 我們依賴 View.scrollBy 呼叫 scrollTo. if (getChildCount() > 0) { View child = getChildAt(0); x = clamp(x, getWidth() - getPaddingRight() - getPaddingLeft(), child.getWidth()); y = clamp(y, getHeight() - getPaddingBottom() - getPaddingTop(), child.getHeight()); if (x != getScrollX() || y != getScrollY()) { super.scrollTo(x, y); } } } /* * 監控傳遞給子檢視的觸控事件,並且一旦確定拖曳就進行攔截 */ @Override public boolean onInterceptTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mInitialX = event.getX(); mInitialY = event.getY(); // 將按下事件傳給手勢檢測器,這樣當/如果拖曳開始就有了上下文 // context when/if dragging begins mDetector.onTouchEvent(event); break; case MotionEvent.ACTION_MOVE: final float x = event.getX(); final float y = event.getY(); final int yDiff = (int) Math.abs(y - mInitialY); final int xDiff = (int) Math.abs(x - mInitialX); // 檢查x或y上的距離是否適合拖曳 if (yDiff > mTouchSlop || xDiff > mTouchSlop) { // 開始捕捉事件 return true; } break; } return super.onInterceptTouchEvent(event); } /* * 將我們接受的所有觸控事件傳給檢測器處理 */ @Override public boolean onTouchEvent(MotionEvent event) { return mDetector.onTouchEvent(event); } /* * 初始化Scroller 和開始重新繪製的實用方法 */ public void fling(int velocityX, int velocityY) { if (getChildCount() > 0) { int height = getHeight() - getPaddingBottom() - getPaddingTop(); int width = getWidth() - getPaddingLeft() - getPaddingRight(); int bottom = getChildAt(0).getHeight(); int right = getChildAt(0).getWidth(); mScroller.fling(getScrollX(), getScrollY(), velocityX, velocityY, 0, Math.max(0, right - width), 0, Math.max(0, bottom - height)); invalidate(); } } /* * 用來進行邊界檢查的輔助實用方法 */ private int clamp(int n, int my, int child) { if (my >= child || n < 0) { /* * my >= child is this case: |--------------- me ---------------| * |------ child ------| or |--------------- me ---------------| * |------ child ------| or |--------------- me ---------------| * |------ child ------| * * n < 0 is this case: |------ me ------| |-------- child --------| * |-- mScrollX --| */ //子檢視超過了父檢視的邊界或者小於父檢視,不能滾動 return 0; }if ((my + n) > child) { /* * this case: |------ me ------| |------ child ------| |-- mScrollX * --| */ //請求的滾動超出了子檢視的右邊界 return child - my; } return n; } }
與ScrollView或HorizontalScrollView類似,這個示例有一個子檢視並可以根據使用者輸入滾動它的內容。這個示例的多數程式碼與觸控事件的處理並沒有直接關係,而是處理滾動並讓滾動位置不要超過子檢視的邊界。
作為一個ViewGroup,第一個可以看到所有觸控事件的地方就是onInterceptTouchEvent()。在這個方法中我們必須分析使用者的觸控行為,從而確定是否是真正的拖動。這個方法中ACTION_DOWN和ACTION_MOVE的處理一起決定了使用者的手指移動了多遠,只有該值大於系統的觸控閾值常量,我們才認為是拖動事件並攔截後續觸控事件。這種做法允許子檢視接收簡單的觸控事件,所以按鈕和其他小部件可以放心地作為這個檢視的子檢視,並且依然會得到觸控事件。如果該檢視沒有可互動的子檢視小部件,事件將會被直接傳遞到我們的onTouchEvent()方法中,但因為我們允許這種情況發生,所以這裡做了初始檢查。
這裡的onTouchEvent()方法很簡單,因為所有的事件都被轉發到了GestureDetector中,它會追蹤和計算使用者正在做的特定動作。然後我們會通過SimpleOnGestureListener對那些事件進行響應,特別是onScroll()和onFling()事件。為了保證GestureDetector能夠準確地設定手勢的初始觸點,我們還在onInterceptTouchEvent()中向它轉發了ACTION_DOWN事件。
onScroll()在使用者的手指移動一段距離時會被重複呼叫。所以,在手指拖動時,我們可以很方便地將這些值直接傳遞給檢視的scrollBy()來移動檢視的內容。
onFling()中需要做稍微多一點的工作。說明一下,急滑(fling)操作就是使用者在螢幕上快速移動手指並抬起的動作。這個動作期望的結果就是慣性的滾動動畫。同樣,當用戶手指抬起時會計算手指的速度,但必須依然保持滾動動畫。這就是引入Scroller的原因。Scroller是框架的一個元件,用來通過使用者的輸入值和時間插值設定來讓檢視滾動起來。本例中的動畫是通過Scroller的fing()方法並重新整理檢視實現的。
注意:
如果目標版本為API Level 9或更高,可以使用OverScroller代替Scroller,它會為較新的裝置提供更好的效能。它還允許包含拉到底發光的動畫(overscroller glow)。可以通過傳入自定義的Interpolator加工急滑動畫。
這會啟動一個迴圈程序,在這個程序中框架會定期呼叫computerScroll()來繪製檢視,我們剛好通過這個時機來檢查Scroller當前的狀態,並且將檢視向前滾動(如果動畫未完成的話)。這也是開發人員對Scroller感到困惑的地方。該控制元件是用來讓檢視動起來,但實際上卻沒有製作任何動畫。它只是簡單地提供了每個繪製幀移動的時機和距離計算。應用程式必須提示呼叫computerScrollOffset()來獲得新位置,然後再實際地呼叫一個方法(本例中為scrollTo()方法)漸進地改變檢視。
GestureDetector中使用的最後一個回撥方法是onDown(),它會在偵測器收到ACTION_DOWN事件時得到呼叫。如果使用者手指單擊螢幕,我們會通過這個回撥方法終止所有當前的急滑動畫。以下程式碼清單顯示了我們該如何在Activity中使用這個自定義檢視。
使用了PanGestureScrollView的Activity
public class PanScrollActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); PanScrollView scrollView = new PanScrollView(this); LinearLayout layout = new LinearLayout(this); layout.setOrientation(LinearLayout.VERTICAL); for(int i=0; i < 5; i++) { ImageView iv = new ImageButton(this); iv.setImageResource(R.drawable.ic_launcher); layout.addView(iv, new LinearLayout.LayoutParams(1000, 500)); } scrollView.addView(layout); setContentView(scrollView); } }
我們使用大量的ImageButton例項來填充這個自定義的PanGestureSrollView,這是為了演示這些按鈕都是可以單擊的,並且可以接收單擊事件,但是隻要你拖動或急滑手指,檢視就會開始滾動。要想了解GestureDetector為我們做了多少工作,可檢視以下程式碼清單,它實現了相同的功能,但需要在onTouchEvent()中手動處理所有的觸控事件。
使用了自定義觸控處理的PanScrollView
public class PanScrollView extends FrameLayout { //急滑控制元件 private Scroller mScroller; private VelocityTracker mVelocityTracker; /* 上一個移動事件的位置*/ private float mLastTouchX, mLastTouchY; /* 拖動閾值*/ private int mTouchSlop; /* 急滑的速度 */ private int mMaximumVelocity, mMinimumVelocity; /* 拖動鎖 */ private boolean mDragging = false; public PanScrollView(Context context) { super(context); init(context); } public PanScrollView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public PanScrollView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(Context context) { mScroller = new Scroller(context); mVelocityTracker = VelocityTracker.obtain(); // 獲得觸控閾值的系統常量 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mMaximumVelocity = ViewConfiguration.get(context) .getScaledMaximumFlingVelocity(); mMinimumVelocity = ViewConfiguration.get(context) .getScaledMinimumFlingVelocity(); } /* *覆寫measureChild... 的實現保證子檢視可以儘可能的大 * 預設實現會強制一些子檢視和該檢視一樣大 */ @Override protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) { int childWidthMeasureSpec; int childHeightMeasureSpec; childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } @Override protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { final MarginLayoutParams lp = (MarginLayoutParams) child .getLayoutParams(); final int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec( lp.leftMargin + lp.rightMargin, MeasureSpec.UNSPECIFIED); final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { // 這個方法會在ViewGroup繪製時呼叫 //我們使用這個方法保證急滑動畫的完成 int oldX = getScrollX(); int oldY = getScrollY(); int x = mScroller.getCurrX(); int y = mScroller.getCurrY(); if (getChildCount() > 0) { View child = getChildAt(0); x = clamp(x, getWidth() - getPaddingRight() - getPaddingLeft(), child.getWidth()); y = clamp(y, getHeight() - getPaddingBottom() - getPaddingTop(), child.getHeight()); if (x != oldX || y != oldY) { scrollTo(x, y); } } // 在動畫完成之前會一直繪製 postInvalidate(); } } // 覆寫scrollTo方法以進行每個滾動請求的邊界檢查 @Override public void scrollTo(int x, int y) { // 我們依賴View.scrollBy呼叫scrollTo if (getChildCount() > 0) { View child = getChildAt(0); x = clamp(x, getWidth() - getPaddingRight() - getPaddingLeft(), child.getWidth()); y = clamp(y, getHeight() - getPaddingBottom() - getPaddingTop(), child.getHeight()); if (x != getScrollX() || y != getScrollY()) { super.scrollTo(x, y); } } } /* * 監控傳遞給檢視的觸控事件,並且一旦確定拖曳就進行攔截 * 如果子檢視是可互動的(如按鈕),那麼依然允許子檢視接收觸控事件 */ @Override public boolean onInterceptTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // 終止所有正在進行的急滑動畫 if (!mScroller.isFinished()) { mScroller.abortAnimation(); } // 還原速度跟蹤器 mVelocityTracker.clear(); mVelocityTracker.addMovement(event); //儲存初始觸點 mLastTouchX = event.getX(); mLastTouchY = event.getY(); break; case MotionEvent.ACTION_MOVE: final float x = event.getX(); final float y = event.getY(); final int yDiff = (int) Math.abs(y - mLastTouchY); final int xDiff = (int) Math.abs(x - mLastTouchX); // 檢查x或y上的距離是否適合拖曳 if (yDiff > mTouchSlop || xDiff > mTouchSlop) { mDragging = true; mVelocityTracker.addMovement(event); // 我們自己開始捕捉事件 return true; } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: mDragging = false; mVelocityTracker.clear(); break; } return super.onInterceptTouchEvent(event); } /* *將我們接收到的所有觸控事件傳給檢測器處理 */ @Override public boolean onTouchEvent(MotionEvent event) { mVelocityTracker.addMovement(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // 我們已經儲存了初始觸點,但如果這裡發現有子檢視沒有捕捉事件 // 還是需要返回true的 return true; case MotionEvent.ACTION_MOVE: final float x = event.getX(); final float y = event.getY(); float deltaY = mLastTouchY - y; float deltaX = mLastTouchX - x; // 檢查各個方向事件上的閾值 if (!mDragging && (Math.abs(deltaY) > mTouchSlop || Math.abs(deltaX) > mTouchSlop)) { mDragging = true; } if (mDragging) { // 滾動檢視 scrollBy((int) deltaX, (int) deltaY); //更新最後一個觸控事件 mLastTouchX = x; mLastTouchY = y; } break; case MotionEvent.ACTION_CANCEL: mDragging = false; // 終止所有進行的急滑動畫 if (!mScroller.isFinished()) { mScroller.abortAnimation(); } break; case MotionEvent.ACTION_UP: mDragging = false; //計算當前的速度,如果高於最小閾值,則啟動一個急滑 mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); int velocityX = (int) mVelocityTracker.getXVelocity(); int velocityY = (int) mVelocityTracker.getYVelocity(); if (Math.abs(velocityX) > mMinimumVelocity || Math.abs(velocityY) > mMinimumVelocity) { fling(-velocityX, -velocityY); } break; } return super.onTouchEvent(event); } /* * 初始化Scroller和開始重新繪製的實用方法 */ public void fling(int velocityX, int velocityY) { if (getChildCount() > 0) { int height = getHeight() - getPaddingBottom() - getPaddingTop(); int width = getWidth() - getPaddingLeft() - getPaddingRight(); int bottom = getChildAt(0).getHeight(); int right = getChildAt(0).getWidth(); mScroller.fling(getScrollX(), getScrollY(), velocityX, velocityY, 0, Math.max(0, right - width), 0, Math.max(0, bottom - height)); invalidate(); } } /* * 用來進行邊界檢查的輔助實用方法 */ private int clamp(int n, int my, int child) { if (my >= child || n < 0) { /* * 子檢視超過了父檢視的邊界或者小於父檢視,不能滾動 */ return 0; } if ((my + n) > child) { /* * 請求的滾動超出了子檢視的右邊界 */ return child - my; } return n; } }
本例中,onInterceptTouchEvent()和onTouchEvent()中的工作會多一點。如果當前存在子檢視處理初始的觸控事件,那麼在我們接管事件之前,ACTION_DOWN和開始的一些移動事件都會通過InterceptTouchEvent()進行傳遞;但是,如果並不存在可互動的子檢視,所有這些初始觸控事件都會直接傳遞到onTouchEvent中。在這兩個方法中,我們必須都要對初始拖動進行閾值檢查,如果確實開始了拖動事件,會設定一個標識。一旦標識使用者正在拖動,滾動檢視的程式碼就和之前的一樣了,及呼叫scrollBy()。
提示:
只要某個ViewGroup通過onTouchEvent()返回了"true",即使沒有顯式地請求攔截,也不會再有事件被傳遞到onInterceptTouchEvent()。
要想要實現急滑效果,我們必須手動使用VelocityTracker物件手動跟蹤使用者的滾動速度。該物件會將發生的事件通過addMovement()方法收集起來,然後通過computerCurrentVelocity()計算相應的平均速度。我們的自定義檢視會根據ViewConfiguration最小速度在每次使用者抬起手指計算這個速度值,從而決定是否要開始一段急滑動畫。
提示:
在不需要顯示返回true來處理事件的情形下,最好返回父類的實現而不是返回false.通常父類會有很多關於View和ViewGroup的隱藏處理(通常不要覆寫它們)。
以下程式碼清單中再次展示了示例Activity,這一次使用了新的自定義檢視。
使用了PanScrollActivity的Activity
public class PanScrollActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); PanScrollView scrollView = new PanScrollView(this); FrameLayout.LayoutParams(800, 1500); LinearLayout layout = new LinearLayout(this); layout.setOrientation(LinearLayout.VERTICAL); for(int i=0; i < 5; i++) { ImageView iv = new ImageButton(this); iv.setImageResource(R.drawable.ic_launcher); layout.addView(iv, new LinearLayout.LayoutParams(1000, 500)); } scrollView.addView(layout); setContentView(scrollView); } }
我們將檢視的內容設定為ImageView而非ImageButton,從而演示了檢視不能互動時的對比效果。
多點觸控處理
(API Level 8)
現在,讓我們看一個處理多點觸控事件的示例。以下程式碼清單是一個自定義的添加了多點觸控互動的ImageView。
帶有處理多點觸控的ImageView
public class RotateZoomImageView extends ImageView { private ScaleGestureDetector mScaleDetector; private Matrix mImageMatrix; /* 上次的旋轉角度 */ private int mLastAngle = 0; /* 變換時軸點 */ private int mPivotX, mPivotY; public RotateZoomImageView(Context context) { super(context); init(context); } public RotateZoomImageView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public RotateZoomImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(Context context) { mScaleDetector = new ScaleGestureDetector(context, mScaleListener); setScaleType(ScaleType.MATRIX); mImageMatrix = new Matrix(); } /* * 在onSizeChanged() 中根據檢視的尺寸計算一些值 * 這個檢視在init()期間並沒有尺寸,因為必須等到這個回撥方法才能得到尺寸 */ @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { if (w != oldw || h != oldh) { //將圖片移到檢視的中央 int translateX = Math.abs(w - getDrawable().getIntrinsicWidth()) / 2; int translateY = Math.abs(h - getDrawable().getIntrinsicHeight()) / 2; mImageMatrix.setTranslate(translateX, translateY); setImageMatrix(mImageMatrix); //得到未來縮放和旋轉變換時的中軸點 mPivotX = w / 2; mPivotY = h / 2; } } private SimpleOnScaleGestureListener mScaleListener = new SimpleOnScaleGestureListener() { @Override public boolean onScale(ScaleGestureDetector detector) { // ScaleGestureDetector 會根據手指的分開和合攏計算出縮放因子 float scaleFactor = detector.getScaleFactor(); //將縮放因子傳給圖片進行縮放 mImageMatrix.postScale(scaleFactor, scaleFactor, mPivotX, mPivotY); setImageMatrix(mImageMatrix); return true; } }; /* * 處理兩根手指的事件來旋轉圖片 *這個方法根據觸點間的角度變化對圖片進行相應的旋轉 *當用戶旋轉手指時,圖片也會跟著旋轉 */ private boolean doRotationEvent(MotionEvent event) { //計算兩根手指間的角度 float deltaX = event.getX(0) - event.getX(1); float deltaY = event.getY(0) - event.getY(1); double radians = Math.atan(deltaY / deltaX); //轉換為角度 int degrees = (int)(radians * 180 / Math.PI); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //記住初始角度 mLastAngle = degrees; break; case MotionEvent.ACTION_MOVE: // 返回一個轉換後介於 -90° 和 +90°的值, //這樣在兩根手指垂直觸控時可以得到翻轉訊號和相應的角度 //這種情況下會將圖片在我們偵測到的方向上旋轉一個很小的角度(5°) if ((degrees - mLastAngle) > 45) { //逆時針選擇(可以超出邊界) mImageMatrix.postRotate(-5, mPivotX, mPivotY); } else if ((degrees - mLastAngle) < -45) { //順時針旋轉(可以超出邊界) mImageMatrix.postRotate(5, mPivotX, mPivotY); } else { //正常旋轉,旋轉角度即為手指的旋轉角度 mImageMatrix.postRotate(degrees - mLastAngle, mPivotX, mPivotY); } //將旋轉矩陣傳送給圖片 setImageMatrix(mImageMatrix); //儲存當前的角度 mLastAngle = degrees; break; } return true; } @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { //我們並不直接關心這個事件,但會宣告要處理後續的多點觸控 return true; } switch (event.getPointerCount()) { case 3: //按下三根手指時,使用ScaleGestureDetector縮放圖片 return mScaleDetector.onTouchEvent(event); case 2: // 放下兩根手指時,根據手指操作旋轉圖片 return doRotationEvent(event); default: //忽略這個事件 return super.onTouchEvent(event); } } }
這個示例建立了一個自定義的ImageView來監聽多點觸控事件並及時變換影象的內容。這個檢視可以偵測到的兩種事件就是兩根手指的旋轉操作和三根手指的縮放操作。旋轉事件是通過每個MotionEvent來處理的,縮放事件則是通過ScaleGestureDetector來處理的。這個檢視的ScaleType被設定為MATRIX,這樣就可以讓我們通過應用不同的Matrix變換來調整圖片的外觀。
在該檢視構建並佈局完成後,就會觸發onSizeChanged()回撥方法。這個方法可以被多次呼叫,所以我們只會在上次值和本次值不同時計算相應的值。這裡,我們會根據檢視的尺寸設定一些值,以便將圖片放置到檢視的中央稍後進行正確的變換。同時我們執行了第一次變換,即將圖片移到檢視的資源。
我們會分析onTouchEvent()接收的觸控事件來決定處理哪個事件。通過檢查每個MotionEvent的getPointerCount()方法,可以判斷按下了幾根手指並將事件傳遞給相應的處理程式。正如之前所說的一樣,這裡也必須處理第一個ACTION_DOWN事件;否則使用者其他手指的後續觸控事件將不會傳遞到這個檢視。雖然我們不想對這個事件做任何操作,但仍然需要顯示地返回true。
ScaleGestureDetector()會分析應用程式反饋的每個觸控事件,當出現縮放事件時,就呼叫一系列的OnScaleGestureListener回撥方法。最重要的回撥方法就是onScale(),它在使用者手指移動時就會被經常呼叫,但開發人員還可以使用onScaleBegin()和onScaleEnd()在手勢開始和結束時進行一些操作。
ScaleGestureDetector提供了很多有用的計算值,應用程式可以使用這些值來修改UI:
- getCurrentSpan() : 獲得該手勢中兩個觸點間的距離。
- getFocusX()/getFocusY() : 獲得當前手勢的焦點座標。它是觸點收縮時的平均位置。
- getScaleFactor() : 得到當前事件和之前事件之間的變化比例。多根手指分開時,這個值稍微大於1,收攏時會稍微小於1。
這個示例從偵測器中得到縮放因子並使用它通過postScale()設定影象的Matrix,從而縮放檢視中的圖片內容。
我們兩根手指的旋轉事件是手動處理的。對於每個傳入的事件,會通過getX()和getY()計算兩根手指間x和y方向的距離。getX()和getY()方法使用的引數為點的索引,0表示第一個觸點,1表示第二個觸點。
這個示例必須處理一種邊界情況,並且必須使用Math.atan()三角函式。這個函式會返回一個介於-90°和+90°的角度值,而這種翻轉發生在一根手指垂直地位於
另一根手指上方的情況。這個問題會導致觸控角度不再是逐漸改變的:在手指旋轉時,角度值會從+90°立即變為-90°,從而導致圖片跳動。為了解決這個問題,我們會檢查之前角度和當前角度超過這種邊界值的情況,然後在相同的行進方向做5°的小旋轉,從而保證動畫的流暢性。
注意,變換圖片的所有操作都是使用postScale()和postRotate()完成的,而不是之前的這些方法的setXXX版本(如setTranslation())。這是因為每個變換都只是一種新增的變換,這意味著只能適合地改變當前的狀態而不是替換。呼叫setScale()和setRotate()將會清除當前的狀態,從而導致只剩下Matrix中的變換。
這些變換都是圍繞我們在onSizeChanged()中計算出的軸點(檢視的中點)進行的。這麼做是因為預設情況下變換髮生在目標點(0,0),即檢視的左上角。因為我們已經將圖片移到檢視中央,所以需要保證所有的變換也發生在同樣的中央軸點。