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),即视图的左上角。因为我们已经将图片移到视图中央,所以需要保证所有的变换也发生在同样的中央轴点。