实现的是一些基础效果:显示一张图片,可以对其进行双击放大缩小、双指手势放大缩小,并且在放大状态下可以滑动图片。效果图:
就是个非常简单的 Demo,实现功能的方式都很基础,肯定有逻辑上考虑的不严谨导致的 bug,主要为了了解功能如何实现。
以下是实现步骤。
1.绘制图片到屏幕中间
private float mOriginalOffsetX;
private float mOriginalOffsetY;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 居中绘制图片
canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
}
/**
* 初始会在 onDraw() 之前调用一次,之后当尺寸发生变化时回调
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 求出初始偏移量,让图片居中
mOriginalOffsetX = (getWidth() - mBitmap.getWidth()) / 2f;
mOriginalOffsetY = (getHeight() - mBitmap.getHeight()) / 2f;
}
2.计算缩放比例
默认状态下图片的显示可能在左右两边有空白,需要对图片进行缩放,让图片的宽填充满图片的宽:
图 1 是图片当前的显示状态,我们需要缩放图片,让它默认显示的状态变成图 2 那样,宽度占满屏幕的宽。除此之外还有一中更大的缩放比例,使得图片的高度占满屏幕的高度,我们将图 2 的缩放比例称为小比例,图 3 的缩放比例称为大比例。
private float mSmallScale;
private float mBigScale;
private float mCurrentScale;
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
......
// 计算缩放比例,若图片的宽高比大于屏幕的宽高比,说明宽比高更容易占满屏幕,宽就用小比例
if (mBitmap.getWidth() / mBitmap.getHeight() > getWidth() / getHeight()) {
// 放大后一边全屏,一边留白叫小缩放
mSmallScale = (float) getWidth() / mBitmap.getWidth();
// 放大后一边全屏,一边超出界面叫大缩放
mBigScale = (float) getHeight() / mBitmap.getHeight();
} else {
mSmallScale = (float) getHeight() / mBitmap.getHeight();
mBigScale = (float) getWidth() / mBitmap.getWidth();
}
mCurrentScale = mSmallScale;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 以 canvas 中心为缩放中心进行缩放
canvas.scale(mCurrentScale, mCurrentScale, getWidth() / 2f, getHeight() / 2f);
// 居中绘制图片
canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
}
3.双击缩放
3-1.GestureDetector
双击事件可以通过 GestureDetector 的 OnDoubleTapListener 接口进行监听:
public interface OnDoubleTapListener {
/**
* 单击按下时触发,双击时不会触发。
* 单击事件为什么不直接用 onClick 方法?
* 1.onClickListener 中的 onClick 方法与该方法冲突
* 2.用 onClick 方法,不管单击还是双击都会触发(双击就触发两次)
*/
boolean onSingleTapConfirmed(MotionEvent e);
/**
* 双击第二次点击按下时发生回调。
* 300ms 内点击两次才算双击。
*/
boolean onDoubleTap(MotionEvent e);
/**
* 双击的第二次点击时,按下、移动和抬起事件都会回调。
*/
boolean onDoubleTapEvent(MotionEvent e);
}
onDoubleTap() 判断双击事件是在 40ms ~300ms 之内生效,40ms 以内认为是抖动。在 ACTION_DOWN 内进行处理的时候,双击的第一次点击时,hadTapMessage 为 false,会进入 else 条件发送一个延时消息,第二次点击后 hadTapMessage 才会为 true,进入到 if 条件回调 onDoubleTap() 和 onDoubleTapEvent():
case MotionEvent.ACTION_DOWN:
if (mDoubleTapListener != null) {
boolean hadTapMessage = mHandler.hasMessages(TAP);
if (hadTapMessage) mHandler.removeMessages(TAP);
if ((mCurrentDownEvent != null) && (mPreviousUpEvent != null)
&& hadTapMessage
&& isConsideredDoubleTap(mCurrentDownEvent, mPreviousUpEvent, ev)) {
// This is a second tap
mIsDoubleTapping = true;
recordGestureClassification(
TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__DOUBLE_TAP);
// Give a callback with the first tap of the double-tap
handled |= mDoubleTapListener.onDoubleTap(mCurrentDownEvent);
// Give a callback with down event of the double-tap
handled |= mDoubleTapListener.onDoubleTapEvent(ev);
} else {
// This is a first tap
mHandler.sendEmptyMessageDelayed(TAP, DOUBLE_TAP_TIMEOUT); // DOUBLE_TAP_TIMEOUT 为 300
}
}
......
两个方法的区别是,onDoubleTap() 只有双击才会触发,而 onDoubleTapEvent() 的第二个事件是 DOWN、MOVE、UP 都可以触发:
case MotionEvent.ACTION_MOVE:
if (mInLongPress) {
break;
}
final float scrollX = mLastFocusX - focusX;
final float scrollY = mLastFocusY - focusY;
if (mIsDoubleTapping) {
// Give the move events of the double-tap
handled |= mDoubleTapListener.onDoubleTapEvent(ev);
}
......
case MotionEvent.ACTION_UP:
mStillDown = false;
MotionEvent currentUpEvent = MotionEvent.obtain(ev);
if (mIsDoubleTapping) {
// Finally, give the up event of the double-tap
handled |= mDoubleTapListener.onDoubleTapEvent(ev);
}
......
另外在 GestureDetector 的构造方法中,如果传入的 OnGestureListener listener 同时也是 OnDoubleTapListener 或 OnContextClickListener 的接口实例,就会设置对应的监听器:
public GestureDetector(Context context, OnGestureListener listener, Handler handler) {
if (handler != null) {
mHandler = new GestureHandler(handler);
} else {
mHandler = new GestureHandler();
}
mListener = listener;
if (listener instanceof OnDoubleTapListener) {
setOnDoubleTapListener((OnDoubleTapListener) listener);
}
if (listener instanceof OnContextClickListener) {
setContextClickListener((OnContextClickListener) listener);
}
init(context);
}
既然说到 OnGestureListener 我们也顺便看一下,之后的用手指滑动图片和惯性滑动图片时用得到:
public interface OnGestureListener {
/**
* 按下,返回 true 表示消费事件
*/
boolean onDown(MotionEvent e);
/**
* 触摸反馈
* 在 View 被点击(按下)时调用,其作用是给用户一个视觉反馈,让用户知道我这个控件被点击了,
* 这样的效果我们也可以用 Material design 的 ripple 实现,或者直接 drawable 写个背景也行。
* 它是一种延时回调,延迟时间是 100 ms。也就是说用户手指按下后,如果立即抬起或者事件立即被拦截,
* 时间没有超过 100ms 的话,这条消息会被 remove 掉,也就不会触发这个回调。
*/
void onShowPress(MotionEvent e);
/**
* 单击抬起
* 单击抬起时触发,或在双击的第一次抬起时触发。(连续点击三次,则会触发两次)
*/
boolean onSingleTapUp(MotionEvent e);
/**
* 手指按下后移动,类似 move事件
* e1:手指按下时的 MotionEvent
* e2:手指当前的 MotionEvent
* distanceX:在X轴上划过的距离 --- 旧位置 减去 新位置
* distanceY:在Y轴上划过的距离
* @return true 表示事件被消费
*/
boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);
/**
* 长按事件,可以通过 setIsLongpressEnabled(false) 取消长按事件的响应
* 在 dispatchLongPress() 分发长按事件时被回调
* 按住超过 300ms 才认为是长按
*/
void onLongPress(MotionEvent e);
/**
* 惯性滑动,最小滑动速度50dip/s(dp=dip),最大8000dp/s
* 在 onTouchEvent() 处理 ACTION_UP 时判断,大于 mMinimumFlingVelocity 才被回调
* e1、e2 含义与 onScroll() 相同
* velocityX:在X轴上的运动速度(像素/秒)
* velocityY:在Y轴上的运动速度(像素/秒)
* @param e1 The first down motion event that started the fling.
* @param e2 The move motion event that triggered the current onFling.
* @return true 表示被消费
*/
boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
}
3-2.利用 GestureDetector 实现双击缩放
实现双击缩放时要用到 OnDoubleTapListener,而实现手指拖动缩放时要用到 OnGestureListener,如果直接实现这俩接口,要实现的方法过多。所以可以利用 GestureDetector.SimpleOnGestureListener,它实现了 GestureDetector 内的 OnDoubleTapListener、OnGestureListener 和 OnContextClickListener 接口,这样我们继承 SimpleOnGestureListener,然后重写需要的接口方法即可:
private class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
mIsInBigScale = !mIsInBigScale;
if (mIsInBigScale) {
mCurrentScale = mBigScale;
} else {
mCurrentScale = mSmallScale;
}
invalidate();
return super.onDoubleTap(e);
}
}
然后创建一个 GestureDetector 对象,把 PhotoGestureListener 的实例传递给构造方法,再把事件处理交给 GestureDetector:
private GestureDetector mGestureDetector;
private void init(Context context) {
......
mGestureDetector = new GestureDetector(context, new PhotoGestureListener());
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return mGestureDetector.onTouchEvent(event);
}
3-3.添加动画让缩放更平滑
双击前后缩放倍数 mCurrentScale 直接在 mSmallScale 与 mBigScale 之间跳转显得不够平滑,加个动画:
private ObjectAnimator getScaleAnimator() {
if (mScaleAnimator == null) {
// 属性名称不是看声明的变量名,而是看 getter 和 setter 方法名中的 getXXX 和 setXXX 的 XXX
mScaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", 0);
}
mScaleAnimator.setFloatValues(mSmallScale, mBigScale);
return mScaleAnimator;
}
public float getCurrentScale() {
return mCurrentScale;
}
public void setCurrentScale(float currentScale) {
mCurrentScale = currentScale;
invalidate();
}
然后把 onDoubleTap() 这个回调方法修改一下:
@Override
public boolean onDoubleTap(MotionEvent e) {
mIsInBigScale = !mIsInBigScale;
if (mIsInBigScale) {
getScaleAnimator().start();
} else {
getScaleAnimator().reverse();
}
invalidate();
return super.onDoubleTap(e);
}
4.滑动图片
只要当前的缩放比例大于最小的缩放比例 mSmallScale 就可以用手指滑动图片,滑动类型分为两种,触摸滑动与惯性滑动,其实都是在通过改变 Canvas 的偏移量实现滑动效果。
4-1.触摸滑动
在 onScroll() 中监听滑动事件:
private float mCanvasOffsetX;
private float mCanvasOffsetY;
private class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
......
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (mCurrentScale > mSmallScale) {
// 由于 distance 是旧点坐标 - 新点坐标,所以偏移量应该减掉 distance 而不是加
mCanvasOffsetX -= distanceX;
mCanvasOffsetY -= distanceY;
// 修复边界
fixOffsets();
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
}
目的是为了得到 Canvas 应该平移的偏移量 mCanvasOffsetX 和 mCanvasOffsetY,然后在 onDraw() 时绘制出来:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// canvas 平移
canvas.translate(mCanvasOffsetX, mCanvasOffsetY);
// 以 canvas 中心为缩放中心进行缩放
canvas.scale(mCurrentScale, mCurrentScale, getWidth() / 2f, getHeight() / 2f);
// 居中绘制图片
canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
}
注意 fixOffsets() 是为了给偏移量设置边界,让图片在向外滑动时刚好能显示到图片的四个边界就停止,不再继续向外滑动:
private void fixOffsets() {
mCanvasOffsetX = Math.min(mCanvasOffsetX, (mBitmap.getWidth() * mBigScale- getWidth()) / 2f);
mCanvasOffsetX = Math.max(mCanvasOffsetX, -(mBitmap.getWidth() * mBigScale- getWidth()) / 2f);
mCanvasOffsetY = Math.min(mCanvasOffsetY, (mBitmap.getHeight() * mBigScale- getHeight()) / 2f);
mCanvasOffsetY = Math.max(mCanvasOffsetY, -(mBitmap.getHeight() * mBigScale- getHeight()) / 2f);
}
以右边界为例,当手指向左滑动时,图片向右滑动,到达右边界时的状态如下:
上面是初始状态,下面是左滑让图片右边正好显示在 PhotoView 的右边界上的情况,这是极限状态。缩放中心的 x 轴坐标变化就是平移时用到的偏移量,看下图就容易看出,这个极限偏移量 maxOffset = 图片宽度/2 - PhotoView 宽度/2。由于是对称的,所以另外一侧的极限偏移量是 maxOffset 直接取反。因此 x 轴的偏移量要在 [-maxOffset,maxOffset] 这个区间内,于是就有了 fixOffsets() 的计算方式。
4-2.惯性滑动
惯性滑动要借助 OverScroller 重写 GestureDetector.SimpleOnGestureListener 的 onFling():
private OverScroller mOverScroller;
private void init(Context context) {
......
mOverScroller = new OverScroller(context);
}
private class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
......
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (mCurrentScale > mSmallScale) {
mOverScroller.fling((int) mCanvasOffsetX, (int) mCanvasOffsetY, (int) velocityX, (int) velocityY,
-(int) ((mBitmap.getWidth() * mCurrentScale - getWidth()) / 2f),
(int) ((mBitmap.getWidth() * mCurrentScale - getWidth()) / 2f),
-(int) ((mBitmap.getHeight() * mCurrentScale - getHeight()) / 2f),
(int) ((mBitmap.getHeight() * mCurrentScale - getHeight()) / 2f),
100, 100);
postOnAnimation(new FlingRunner());
}
return super.onFling(e1, e2, velocityX, velocityY);
}
}
OverScroller 的 fling() 参数依次为滑动起点坐标、滑动速度、四个方向的边界,最后两个参数 100,100 表示的是 x,y 轴方向允许惯性滑动超出边界的距离,即下面这个效果:
postOnAnimation() 中传递的任务,是在 Fling 动画还未结束时,获取当前的 x,y 值作为 Canvas 的偏移量,刷新界面:
class FlingRunner implements Runnable {
@Override
public void run() {
// 如果 Fling 动画还没有结束
if (mOverScroller.computeScrollOffset()) {
mCanvasOffsetX = mOverScroller.getCurrX();
mCanvasOffsetY = mOverScroller.getCurrY();
invalidate();
// 不断调用自己,形成死循环,在 computeScrollOffset() 返回 false
// 之前会一直取出偏移量并刷新界面达到 Fling 的效果
postOnAnimation(this);
}
}
}
5.双指缩放
实现 ScaleGestureDetector.OnScaleGestureListener 接口可以完成双指缩放:
private ScaleGestureDetector mScaleGestureDetector;
private void init(Context context) {
......
mScaleGestureDetector = new ScaleGestureDetector(context, new PhotoScaleGestureListener());
}
private class PhotoScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener {
private float initScale;
@Override
public boolean onScale(ScaleGestureDetector detector) {
// getScaleFactor:比例因子,是个动态值,由上一个缩放事件到当前缩放事件
// 缩放比例不能小于 mSmallScale,否则图片的宽就不能占满 PhotoView 的宽了
if (initScale * detector.getScaleFactor() >= mSmallScale) {
mCurrentScale = initScale * detector.getScaleFactor();
}
invalidate();
return false;
}
// 缩放前回调,返回 true 表示消费这个缩放事件
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
initScale = mCurrentScale;
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
}
此外还要将事件优先交给 ScaleGestureDetector 处理:
@Override
public boolean onTouchEvent(MotionEvent event) {
// 双指缩放操作优先处理事件
boolean result = mScaleGestureDetector.onTouchEvent(event);
// 如果不是双指缩放才处理手势事件
if (!mScaleGestureDetector.isInProgress()) {
result = mGestureDetector.onTouchEvent(event);
}
return result;
}
6.Bug 解决与优化
问题1.双击放大后平移再双击缩小,图片位置不对
缩小后的图片应该位于 PhotoView 的中央才对,出现这个问题的原因是平移了图片之后,图片的缩放中心不再与 PhotoView 的中心重合,再双击缩小时,仍然是以图片本身的缩放中心(上图中应该是在屏幕右侧)进行缩放,所以缩小后它不在 PhotoView 的中央。
解决办法:设置一个缩放因子 scaleFraction,用来表示当前缩放的程度占整个缩放跨度的百分比,即 scaleFraction = (mCurrentScale - mSmallScale) / (mBigScale - mSmallScale),显然这是一个 [0,1] 的值,当 mCurrentScale 为最大缩放值 mBigScale 时取到 1,当 mCurrentScale 为 mSmallScale 时 scaleFraction 取到 0,把缩放因子与平移的偏移量绑定起来:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float scaleFraction = (mCurrentScale - mSmallScale) / (mBigScale - mSmallScale);
// canvas 平移
canvas.translate(mCanvasOffsetX * scaleFraction, mCanvasOffsetY * scaleFraction);
// 以 canvas 中心为缩放中心进行缩放
canvas.scale(mCurrentScale, mCurrentScale, getWidth() / 2f, getHeight() / 2f);
// 居中绘制图片
canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
}
这样当缩放比例为 mSmallScale 时 Canvas 就不会进行平移了,双击缩小后就能处于 PhotoView 中央了。
问题2.没有从双击的位置放大
手机系统中的相册,双击一个位置后似乎是以该点为准进行的放大,而 Demo 目前的效果则总是以图片中心为准放大。看对比图:
|
|
根本原因是,双击某一个位置点 A 放大图片后,A 点的位置在 PhotoView 中的坐标应该是不变的,但我们的 Demo 目前的效果则是 A 点会随着缩放进行移动:
上图蓝色背景是按照小比例缩放的图片,而绿色背景是按照大比例缩放后的图片。上面的图解释了问题产生的原因,就是被点击的点在按照大比例缩放后,它在 PhotoView 中的位置就发生了变化,如果想做到修复后的效果,需要在执行缩放之前,先平移图片,让上图中的两个红点重合,达到下面图的位置即可:
@Override
public boolean onDoubleTap(MotionEvent e) {
mIsInBigScale = !mIsInBigScale;
if (mIsInBigScale) {
mCanvasOffsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * mBigScale / mSmallScale;
mCanvasOffsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * mBigScale / mSmallScale;
fixOffsets();
getScaleAnimator().start();
} else {
getScaleAnimator().reverse();
}
invalidate();
return super.onDoubleTap(e);
}
结合图片说明如何计算 mCanvasOffsetX 和 mCanvasOffsetY:
将缩放中心 O 点视为坐标原点 (0,0),被双击的点 A 在放大后到了点 B,过点 A 与 X 轴平行的线与 PhotoView 的左边界交于点 C,与 Y 轴交于点 D:
- A 点横坐标
x
A
x_A
xA = -(CD - AC) = -(photoViewWidth / 2 - eventA.getX()) =
e.getX() - getWidth() / 2
- B 点与 A 点在 Y 轴同侧,A 点是图片使用小比例时的位置,而 B 点是图片使用大比例的位置,因此可以直接使用比例计算 B 点横坐标
x
B
x_B
xB =
x
A
x_A
xA * mBigScale / mSmallScale =
(e.getX() - getWidth() / 2f) * mBigScale / mSmallScale
纵坐标计算方式与横坐标类似,不再赘述。