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 {
// Fling components
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
/* Positions of the last motion event */
private float mLastTouchX, mLastTouchY;
/* Drag threshold */
private int mTouchSlop;
/* Fling Velocity */
private int mMaximumVelocity, mMinimumVelocity;
/* Drag Lock */
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();
// Get system constants for touch thresholds
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
mMaximumVelocity = ViewConfiguration.get(context)
.getScaledMaximumFlingVelocity();
mMinimumVelocity = ViewConfiguration.get(context)
.getScaledMinimumFlingVelocity();
}
/*
* Override the measureChild... implementations to guarantee that the child
* view gets measured to be as large as it wants to be. The default
* implementation will force some children to be only as large as this view.
*/
@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()) {
// This is called at drawing time by ViewGroup. We use
// this method to keep the fling animation going through
// to completion.
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);
}
}
// Keep on drawing until the animation has finished.
postInvalidate();
}
}
// Override scrollTo to do bounds checks on any scrolling request
@Override
public void scrollTo(int x, int y) {
// we rely on the fact the View.scrollBy calls 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);
}
}
}
/*
* Monitor touch events passed down to the children and intercept as soon as
* it is determined we are dragging. This allows child views to still
* receive touch events if they are interactive (i.e. Buttons)
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// Stop any flinging in progress
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
// Reset the velocity tracker
mVelocityTracker.clear();
mVelocityTracker.addMovement(event);
// Save the initial touch point
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);
// Verify that either difference is enough to be a drag
if (yDiff > mTouchSlop || xDiff > mTouchSlop) {
mDragging = true;
mVelocityTracker.addMovement(event);
// Start capturing events ourselves
return true;
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mDragging = false;
mVelocityTracker.clear();
break;
}
return super.onInterceptTouchEvent(event);
}
/*
* Feed all touch events we receive to the detector for processing.
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// We've already stored the initial point,
// but if we got here a child view didn't capture
// the event, so we need to.
return true;
case MotionEvent.ACTION_MOVE:
final float x = event.getX();
final float y = event.getY();
float deltaY = mLastTouchY - y;
float deltaX = mLastTouchX - x;
// Check for slop on direct events
if (!mDragging
&& (Math.abs(deltaY) > mTouchSlop || Math.abs(deltaX) > mTouchSlop)) {
mDragging = true;
}
if (mDragging) {
// Scroll the view
scrollBy((int) deltaX, (int) deltaY);
// Update the last touch event
mLastTouchX = x;
mLastTouchY = y;
}
break;
case MotionEvent.ACTION_CANCEL:
mDragging = false;
// Stop any flinging in progress
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_UP:
mDragging = false;
// Compute the current velocity and start a fling if it is above
// the minimum threshold.
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);
}
/*
* Utility method to initialize the Scroller and start redrawing
*/
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();
}
}
/*
* Utility method to assist in doing bounds checking
*/
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;
}
}
本例中,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);
// TwoDimensionGestureScrollView scrollView = new TwoDimensionGestureScrollView(this);
// ImageView iv = new ImageView(this);
// iv.setImageResource(R.drawable.ic_launcher);
//
// FrameLayout.LayoutParams lp = new 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;
/* Last Rotation Angle */
private int mLastAngle = 0;
/* Pivot Point for Transforms */
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();
}
/*
* Use onSizeChanged() to calculate values based on the view's size.
* The view has no size during init(), so we must wait for this
* callback.
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if (w != oldw || h != oldh) {
//Shift the image to the center of the view
int translateX = Math.abs(w - getDrawable().getIntrinsicWidth()) / 2;
int translateY = Math.abs(h - getDrawable().getIntrinsicHeight()) / 2;
mImageMatrix.setTranslate(translateX, translateY);
setImageMatrix(mImageMatrix);
//Get the center point for future scale and rotate transforms
mPivotX = w / 2;
mPivotY = h / 2;
}
}
private SimpleOnScaleGestureListener mScaleListener = new SimpleOnScaleGestureListener() {
@Override
public boolean onScale(ScaleGestureDetector detector) {
// ScaleGestureDetector calculates a scale factor based on whether
// the fingers are moving apart or together
float scaleFactor = detector.getScaleFactor();
//Pass that factor to a scale for the image
mImageMatrix.postScale(scaleFactor, scaleFactor, mPivotX, mPivotY);
setImageMatrix(mImageMatrix);
return true;
}
};
/*
* Operate on two-finger events to rotate the image.
* This method calculates the change in angle between the
* pointers and rotates the image accordingly. As the user
* rotates their fingers, the image will follow.
*/
private boolean doRotationEvent(MotionEvent event) {
//Calculate the angle between the two fingers
float deltaX = event.getX(0) - event.getX(1);
float deltaY = event.getY(0) - event.getY(1);
double radians = Math.atan(deltaY / deltaX);
//Convert to degrees
int degrees = (int)(radians * 180 / Math.PI);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//Mark the initial angle
mLastAngle = degrees;
break;
case MotionEvent.ACTION_MOVE:
// ATAN returns a converted value between -90deg and +90deg
// which creates a point when two fingers are vertical where the
// angle flips sign. We handle this case by rotating a small amount
// (5 degrees) in the direction we were traveling
if ((degrees - mLastAngle) > 45) {
//Going CCW across the boundary
mImageMatrix.postRotate(-5, mPivotX, mPivotY);
} else if ((degrees - mLastAngle) < -45) {
//Going CW across the boundary
mImageMatrix.postRotate(5, mPivotX, mPivotY);
} else {
//Normal rotation, rotate the difference
mImageMatrix.postRotate(degrees - mLastAngle, mPivotX, mPivotY);
}
//Post the rotation to the image
setImageMatrix(mImageMatrix);
//Save the current angle
mLastAngle = degrees;
break;
}
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
// We don't care about this event directly, but we declare
// interest so we can get later multi-touch events.
return true;
}
switch (event.getPointerCount()) {
case 3:
// With three fingers down, zoom the image
// using the ScaleGestureDetector
return mScaleDetector.onTouchEvent(event);
case 2:
// With two fingers down, rotate the image
// following the fingers
return doRotationEvent(event);
default:
//Ignore this event
return super.onTouchEvent(event);
}
}
}
這個示例建立了一個自定義的ImageView來監聽多點觸控事件並及時變換影象的內容。這個檢視可以偵測到的兩種事件就是兩根手指的旋轉操作和三根手指的縮放操作。旋轉事件是通過每個MotionEvent來處理的,縮放事件則是通過ScaleGestureDetector來處理的。這個檢視的ScaleType被設定為MATRIX,這樣就可以讓我們通過應用不同的Matrix變換來調整圖片的外觀。
在該檢視構建並佈局完成後,就會觸發onSizeChanged()回撥方法。這個方法可以被多次呼叫,所以我們只會在上次值和本次值不同時計算相應的值。這裡,我們會根據檢視的尺寸設定一些值,以便將圖片放置
ScaleGestureDetector()會分析應用程式反饋的每個觸控事件,當出現縮放事件時,就呼叫一系列的OnScaleGestureListener回撥方法。最重要的回撥方法就是onScale(),它在使用者手指移動時就會被經常呼叫,但開發人員還可以使用onScaleBegin()和onScaleEnd()在手勢開始和結束時進行一些操作。
ScaleGestureDetector提供了很多有用的計算值,應用程式可以使用這些值來修改UI:
- getCurrentSpan() : 獲得該手勢中兩個觸點間的距離。
- getFocusX()/getFocusY() : 獲得當前手勢的焦點座標。它是觸點收縮時的平均位置。
- getScaleFactor() : 得到當前事件和之前事件之間的變化比例。多根手指分開時,這個值稍微大於1,收攏時會稍微小於1。
這個示例從偵測器中得到縮放因子並使用它通過postScale()設定影象的Matrix,從而縮放檢視中的圖片內容。
這個示例必須處理一種邊界情況,並且必須使用Math.atan()三角函式。這個函式會返回一個介於
注意,變換圖片的所有操作都是使用postScale()和postRotate()完成的,而不是之前的這些方法的setXXX版本(如setTranslation())。這是因為每個變換都只是一種新增的變換,這意味著只能適合地改變當前的狀態而不是替換。呼叫setScale()和setRotate()將會清除當前的狀態,從而導致只剩下Matrix中的變換。
這些變換都是圍繞我們在onSizeChanged()中計算出的軸點(檢視的中點)進行的。這麼做是因為預設情況下變換髮生在目標點(0,0),即檢視的左上角。因為我們已經將圖片移到檢視中央,所以需要保證所有的變換也發生在同樣的中央軸點。