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