前言

前面介绍了前面介绍了嵌套滑动、自定义Behavior等高级UI实现原理,今天介绍一种比较简单的高级UI用法——手势处理。

你可以点开自己的手机相册,点开任意一张图片,双击图片会有放大效果,再次双击会缩小为原来大小。并且双指操作可以放大缩小图片。今天,小编就来实现一下这样的PhotoView效果。但是我的效果处理肯定不是很完美,毕竟追求代码简洁。

效果展示图:

录制_2022_08_13_16_02_16_954

手势处理API

首先,先介绍一个Android处理单指手势的API GestureDetector,在创建一个GestureDetector对象时需要传入一个监听者OnGestureListener,所以我们需要实现OnGestureListener,通常实现SimpleOnGestureListener这个接口里面的功能就足够我们使用了,所以实现它即可。

SimpleOnGestureListener的方法介绍

class PhotoScaleGestureDetector extends GestureDetector.SimpleOnGestureListener{
//在ACTION_DOWN时触发,必定触发
@Override
public boolean onDown(@NonNull MotionEvent e) {
return super.onDown(e);
}
//延时100ms,处理点击效果
@Override
public void onShowPress(@NonNull MotionEvent e) {
super.onShowPress(e);
}
//up时触发,单击或双击的第一次,不是双击和长按
@Override
public boolean onSingleTapUp(@NonNull MotionEvent e) {
return super.onSingleTapUp(e);
}
/**
*
* @param e1 手指按下
* @param e2 当前的事件
* @param distanceX oldX - newX
* @param distanceY oldY - newY ->向右滑得到的是负数,所以需要取与distance相反的值
* @return
*/
@Override
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float distanceX, float distanceY) {
return super.onScroll(e1, e2, distanceX, distanceY);
}
// 长按触发,默认达到300ms就会触发
@Override
public void onLongPress(@NonNull MotionEvent e) {
super.onLongPress(e);
}

// 手指松开后,惯性滑动,大于50dp/s
@Override
public boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) {
return super.onFling(e1, e2, velocityX, velocityY);
}

// 单击按下时触发,双击不触发,up和down都有可能触发
// 延时300ms触发TAP事件
// 300ms以内抬手才会触发TAP
// 300ms以后抬手,不是双击,不是长按就触发

@Override
public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
return super.onSingleTapConfirmed(e);
}

// 双击,在ACTION_DWON中处理,触发时间 40-300ms
@Override
public boolean onDoubleTap(@NonNull MotionEvent e) {
return super.onDoubleTap(e);
}

// 双击的第二次Down、move、up都会触发这个
@Override
public boolean onDoubleTapEvent(@NonNull MotionEvent e) {
return super.onDoubleTapEvent(e);
}

}

下图是三个类或接口的时序关系,PhotoView为小编的自定义View。

从时序图可以看出,我们只需要实例化SimpleOnGestureListener并且在对应的方法中做自己的处理,在onTouchEvent触摸事件中回调gestureDetector.onTouchEvent(event)就能将自定义的触摸事件设置完成。

注:onDown必须返回true,否则就不会处理点击事件了。

另外需要注意的一个点是,如果GestureDetector是在子线程中创建的,还必须得调用Looper.prepare(),因为如果我们不手动传入一个handler实例,系统就会帮我们new一个,而Handler是会和所在线程进行一个绑定,必须进行prepare,而这个handler就是来处理触摸事件的,下面通过一张图来进行描述它们之间的关系

image-20220813160810620

ScaleGestureDetector.OnScaleGestureListener的方法

下面的代码是小编demo中的

scaleGestureDetector.getScaleFactor()为缩放因子

  • onScaleBegin:scale变化前的处理
  • onScale:scale变化时,也就是双指缩放图片scale处理的位置
  • onScaleEnd:scale变化后的处理

注:onScaleBegin方法中必须返回true,否则双指操作无效

class PhotoScaleGestureDetector implements ScaleGestureDetector.OnScaleGestureListener {

float initScale;
@Override
public boolean onScale(@NonNull ScaleGestureDetector scaleGestureDetector) {
if ((currentScale>smallScale && !isEnlarge)||(currentScale==smallScale && !isEnlarge)){
isEnlarge = !isEnlarge;
}
//缩放因子
currentScale = initScale * scaleGestureDetector.getScaleFactor();
//注意:一定要刷新,否则放大缩小效果就很生硬
invalidate();
return false;
}

@Override
public boolean onScaleBegin(@NonNull ScaleGestureDetector scaleGestureDetector) {
initScale = currentScale;
return true;
}

@Override
public void onScaleEnd(@NonNull ScaleGestureDetector scaleGestureDetector) {

}
}

图片缩放效果的实现

class PhotoGestureDetector extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(@NonNull MotionEvent e) {
return true;
}

@Override
public boolean onDoubleTap(@NonNull MotionEvent e) {
isEnlarge = !isEnlarge;
if (isEnlarge){
//经过一次放大后缩小后点击新的位置,从该位置为中心放大,所以需要重置offset
offsetX = (e.getX()-getWidth()/2f)-(e.getX()-getWidth()/2f) * (bigScale/smallScale);
offsetY = (e.getY()-getHeight()/2f)-(e.getY()-getHeight()/2f)* (bigScale/smallScale);
fixOffsets();
currentScale = bigScale;
getScaleAnim(smallScale,bigScale).start();
}else {
currentScale = smallScale;
getScaleAnim(smallScale,bigScale).reverse();
}
return super.onDoubleTap(e);
}

/**
*
* @param e1 手指按下
* @param e2 当前的事件
* @param distanceX oldX - newX
* @param distanceY oldY - newY ->向右滑得到的是负数,所以需要取与distance相反的值
* @return
*/
@Override
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float distanceX, float distanceY) {
if (isEnlarge){
offsetX -= distanceX;
offsetY -= distanceY;
fixOffsets();
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}

@Override
public boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) {
if (isEnlarge){
overScroller.fling(
(int)offsetX,
(int)offsetY,
(int) velocityX,
(int) velocityY,
-(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
-(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
300,
300
);
postOnAnimation(new FlingRunner());

}
return super.onFling(e1, e2, velocityX, velocityY);
}
}

这个算法用于限制图片偏移的范围

image-20220813164000778

image-20220813170526346

惯性事件处理

其他重要参数截图中都有介绍,下面主要介绍一下postAnimation,一次只能移动一小段距离(一帧),需要多次调度动画直到完成滑动偏移,这里采用递归的方式,实例化一个Runnable放入postOnAnimation()中

image-20220813164828771

image-20220813164423762

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);

//为了避免图片展示的边上有空隙,最好使用float
originalOffsetX = (getWidth() - bitmap.getWidth()) / 2f;
originalOffsetY = (getHeight() - bitmap.getHeight()) / 2f;

//判断是高度全屏还是宽度全屏
if ((float)bitmap.getWidth()/bitmap.getHeight()>(float) getWidth()/getHeight()){
smallScale = (float) getWidth()/ bitmap.getWidth();
bigScale = (float) getHeight()/bitmap.getHeight()*OVER_SCALE_FACTOR;
}else {
smallScale = (float) getHeight()/bitmap.getHeight();
bigScale = (float) getWidth()/bitmap.getWidth()*OVER_SCALE_FACTOR;
}
currentScale = smallScale;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

//根据放大比例,设置一次滑动时偏移的尺寸,视觉上来看就是滑动速度
float scaleFaction = (currentScale-smallScale)/(bigScale-smallScale);
canvas.translate(offsetX * scaleFaction,offsetY * scaleFaction);

canvas.scale(currentScale,currentScale,getWidth()/2f,getHeight()/2f);
canvas.drawBitmap(bitmap,originalOffsetX,originalOffsetY,paint);

}

判断是单指处理还是双指处理

@Override
public boolean onTouchEvent(MotionEvent event) {
boolean result = scaleGestureDetector.onTouchEvent(event);
if(!scaleGestureDetector.isInProgress()){
result = gestureDetector.onTouchEvent(event);
}
return result;
}

关于多指触摸事件

  • 注意:多指处理需要event.getActionMasked()

  • 一次触摸事件中Down和Up都只触发一次

  • 多指触摸事件是存储在一个键值(<id,index>)对数组中,从0开始,可以理解为一个SpareArray,需要一个currentPointId记录当前正在处理事件的手指。

    该数组的规则:

    • 当手指Up,对应的id和index会从数组中移除,移除后数组会偏移补全,比如0-4移除3,4会补全3的位置,并且index变为3,而id不变
    • 再有新的手指点击,如果前面的id不连续,会补全,比如接着上方的例子原来的index3会变为index4,而新的点击事件为id3、index3,也就是说id不会变,index会变
  • lastOffsetX、lastOffsetY记录上一次的偏移值,否则有新的手指点击的话会出现图片“闪现”效果

  • event.findPointerIndex(Id)通过Id获取Index

  • event.getActionIndex()获取当前触摸手指的index,event.getPointerId(actionIndex)通过index获取id

  • 抬起的手指是活跃手指并且是最后一个按下的手指,那么活跃手指更新为抬起手指的前一个->upIndex = event.getPointerCount() -2

  • 如果抬起手指是中间的并且是活跃手指,那么活跃手指更新为下一个->upIndex++

  • 以上两点都是根据多指触摸事件数组规则做出的判断

public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()){
//Down和Up都只触发一次
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
lastOffsetX = offsetX;
lastOffsetY = offsetY;

currentPointId = 0;
break;
case MotionEvent.ACTION_MOVE:
// 通过id 拿index
if (event.getPointerCount()!=0){
int index = event.findPointerIndex(currentPointId);
// event.getX()默认 index = 0的坐标 --- move操作的是后按下的手指
offsetX = lastOffsetX + event.getX(index) - downX;
offsetY = lastOffsetY + event.getY(index) - downY;
invalidate();
}
break;
case MotionEvent.ACTION_POINTER_DOWN:
//获取当前(最后一次)按下的index
if (event.getPointerCount()>1){
int actionIndex = event.getActionIndex();
currentPointId = event.getPointerId(actionIndex);

downX = event.getX(actionIndex);
downY = event.getY(actionIndex);
lastOffsetX = offsetX;
lastOffsetY = offsetY;
}
break;
case MotionEvent.ACTION_UP:
int upIndex = event.getActionIndex();
int pointId = event.getPointerId(upIndex);
//非活跃的手指不用处理
if (pointId==currentPointId){
//更新活跃手指
if (upIndex == event.getPointerCount()-1){
//抬起的手指是活跃手指并且是最后一个按下的手指,那么活跃手指更新为抬起手指的前一个
upIndex = event.getPointerCount() -2;
}else{
//如果抬起手指是中间的并且是活跃手指,那么活跃手指更新为下一个
upIndex++;
}
currentPointId = event.getPointerId(upIndex);
downX = event.getX(upIndex);
downY = event.getY(upIndex);
lastOffsetX = offsetX;
lastOffsetY = offsetY;
}
break;
}
return true;

总结

其实,多指触摸事件很少使用,不过应用起来还是有很不错的效果。

实现PhotoView的效果重点就是GestureDetector.SimpleOnGestureListenerScaleGestureDetector.OnScaleGestureListener这两个API的使用,其实自定义View这一块,只要把尺寸、偏移等这些处理好了实现想要的效果还是很容易的。

最后附上源码:

源码地址