前言
先上效果:
实现的是双指放大和缩小、双击放大和缩小,以及放大之后支持拖动的一个效果;
基础架子
我们先来搭建一个简单的架子
java
public class PhotoView extends View {
private static final float IMAGE_WIDTH = Utils.dpToPixel(300);
private Bitmap bitmap;
private Paint paint;
public PhotoView(Context context) {
this(context, null);
}
public PhotoView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PhotoView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private void init() {
bitmap = Utils.getPhoto(getResources(), (int) IMAGE_WIDTH);
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(bitmap, 0, 0, paint);
}
}
我们运行看下效果:
接下来我们开始一步一步实现最终的效果
绘制图片到屏幕中间
想要将图片绘制到屏幕中间,也就是 canvas.drawBitmap 的时候,坐标起始点不是 0,0 了,而是 屏幕宽度的一半 减去 图片宽度的一半,屏幕高度的一半 减去 图片高度的一半;
scss
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
originalOffsetX = (float) getWidth() / 2f - (float) bitmap.getWidth() / 2f;
originalOffsetY = (float) getHeight() / 2f - (float) bitmap.getHeight() / 2f;
}
我们运行看下效果:
可以看到,图片绘制到了屏幕中心位置,接下来我们实现缩放能力
缩放和比例计算
我们再展示图片的时候,其实是需要一个缩放的,也就是说需要让我们的图片的宽或者高可以适配屏幕,所以是分为两种情况
一种是 宽度充满
一种是 高度充满
我们来给这两种充满分别定义一个概念,宽度充满我们称为『小放大 smallScale』,高度充满我们称为『大放大 bigScale』,我们需要计算使用哪种缩放方式
缩放计算方式(smallScale)
scss
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()){
smallScale = (float) getWidth() / bitmap.getWidth();
}
currentScale = smallScale;
}
也就是假设下面的 这种计算方式,图片的宽/高 = 2,屏幕的宽/高 = 0.5,所以是宽度充满的缩放比例
缩放计算方式(bigScale)
反之,就是大缩放
scss
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()){
smallScale = (float) getWidth() / bitmap.getWidth();
bigScale = (float) getHeight() / bitmap.getHeight();
}
currentScale = smallScale;
}
然后我们需要在绘制之前,设置一下 Canvas 的缩放,通过 canvas.scale(); 来设置,我们可以看下这个 scale 方法
sx 和 sy 这里就不做过多解释了,这个 px 和 py 指的是 scale 的中心点坐标,是从 0,0 点缩放,还是指定的位置缩放;
如果我们想让图片以中心点缩放,而我们的图片现在又是绘制在屏幕中间,所以缩放的中心点 其实就是屏幕的中心点;
scss
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.scale(currentScale, currentScale, (float) getWidth() / 2f, (float) getHeight() / 2f);
}
另外,图片放大之后,会进行一个平移来查看完整的图片,所以我们需要给图片进行一个边界的设置,例如:当我们的图片高度充满屏幕的时候,我们这个时候可以认为垂直方向是不可以移动的,只能水平方向左右移动,为了避免这种情况,bigScale 需要配置一个系数,让垂直方向也可以移动;
scss
private float OVER_SCALE_FACTOR = 1.5f;
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//
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;
}
手势处理
我们接下来进行手势的处理,也就是双击放大、缩小,放大之后可以拖动,以及原始状态双指缩放;
系统为我们提供了 GestureDetector(手势探测器)我们可以直接使用这个 API,它就会为我们来处理单击、双击等手势,我们来使用它完成手势的处理;
typescript
private ScaleGestureDetector scaleGestureDetector;
private void init(Context context) {
//
...
//
gestureDetector = new GestureDetector(context, new PhotoGestureDetector());
}
// 为了便于理解,重写了 SimpleOnGestureListener 的所有方法
private static class PhotoGestureDetector extends GestureDetector.SimpleOnGestureListener {
public PhotoGestureDetector() {
super();
}
/**
* up的时候触发,非长按单击或者双击的第一次点击触发
* @param e The up motion event that completed the first tap
* @return true
*/
@Override
public boolean onSingleTapUp(MotionEvent e) {
return super.onSingleTapUp(e);
}
/**
* 长按事件处理
* @param e The initial on down motion event that started the longpress.
*/
@Override
public void onLongPress(MotionEvent e) {
super.onLongPress(e);
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return super.onScroll(e1, e2, distanceX, distanceY);
}
/**
* up 手指抬起之后的惯性活动 -- 大于 50dp/s
*
* @param e1 The first down motion event that started the fling.
* @param e2 The move motion event that triggered the current onFling.
* @param velocityX The velocity of this fling measured in pixels per second
* along the x axis.
* @param velocityY The velocity of this fling measured in pixels per second
* along the y axis.
* @return true
*/
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return super.onFling(e1, e2, velocityX, velocityY);
}
/**
* 延迟 100ms 触发,用来处理点击效果
* @param e The down motion event
*/
@Override
public void onShowPress(MotionEvent e) {
super.onShowPress(e);
}
/**
* 只要想 响应事件,这里就需要返回 true
* @param e The down motion event.
* @return true
*/
@Override
public boolean onDown(MotionEvent e) {
return true;
}
/**
* 双击的第二次down触发,双击触发时间 需要 40ms- 300ms,小于40认为是抖动,超过300认为是单击
*
* @param e The down motion event of the first tap of the double-tap.
* @return true
*/
@Override
public boolean onDoubleTap(MotionEvent e) {
return super.onDoubleTap(e);
}
/**
* 双击的第二次down、move、up都触发
* @param e The motion event that occurred during the double-tap gesture.
* @return true
*/
@Override
public boolean onDoubleTapEvent(MotionEvent e) {
return super.onDoubleTapEvent(e);
}
/**
* 单击按下时触发,双击不触发,down、up时都可能触发,但是不会同时触发
* @param e The down motion event of the single-tap.
* @return true
*/
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
return super.onSingleTapConfirmed(e);
}
@Override
public boolean onContextClick(MotionEvent e) {
return super.onContextClick(e);
}
}
双击放大处理
前面我们已经分析了各个方法的作用,双击的处理,是在 onDoubleTap 中处理,我们需要一个标志位来判断,当次需要小缩放还是大缩放(双击放大 -> 双击缩小)
ini
priviate boolean isEnLarge = false;
public boolean onDoubleTap(MotionEvent e) {
isEnLarge = !isEnLarge;
if (isEnLarge) {
currentScale = bigScale;
} else {
currentScale = smallScale;
}
// 这个方法调用之后,会重新走 onDraw 逻辑,也就会还行 canvas.scale 逻辑
invalidate();
return super.onDoubleTap(e);
}
我们运行看下效果:
可以看到,双击并没有效果,这是为什么呢?
原因就是,我们的 GestureDetector 需要接管我们 View 的 onTouchEvent 事件才能响应,它本身就是对 Touch 事件的处理;
typescript
@Override
public boolean onTouchEvent(MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}
我们再来看下效果:
可以看到,实现了我们期望的双击放大,双击缩小的效果;
但是整体效果是瞬间变大,瞬间变小,缩放的效果不是很好,我们可以加一个缩放动画,来让它有一个变化的过程,我们使用缩放属性动画来搞一下;
csharp
private ObjectAnimator scaleAnimator;
private ObjectAnimator getScaleAnimator() {
if (scaleAnimator == null) {
scaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", 0);
}
scaleAnimator.setFloatValues(smallScale, bigScale);
return scaleAnimator;
}
public float getCurrentScale() {
return currentScale;
}
public void setCurrentScale(float currentScale) {
this.currentScale = currentScale;
invalidate();
}
然后我们的双击回调中,启动这个动画即可
scss
@Override
public boolean onDoubleTap(MotionEvent e) {
isEnLarge = !isEnLarge;
if (isEnLarge) {
// 放大的时候,启动缩放动画
getScaleAnimator().start();
} else {
// 缩小的时候,反转这个动画
getScaleAnimator().reverse();
}
return super.onDoubleTap(e);
}
我们运行看下效果:
可以看到有一个平滑的缩放效果了;
手指的滑动
我们接下来处理下放大之后的手指滑动,图片跟着滑动,其实就是 canvas 的 translate 手指滑动的距离;
java
private float offsetX;
private float offsetY;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(offsetX, offsetY);
}
然后我们只需要赋值这个 offsetX 和 offsetY 即可,我们需要在 onScroll 中进行滑动事件的处理,也就是 offsetX 和 offsetY 的处理;
java
/**
* @param e1 手指按下位置.
* @param e2 当前位置.
* @param distanceX 旧位置 - 新位置.
* @param distanceY 旧位置 - 新位置.
* @return true
*/
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// 放大才能滑动
if (isEnLarge) {
offsetX = offsetX - distanceX;
offsetY = offsetY - distanceY;
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
我们运行看下效果:
我们实现了滑动效果
设置滚动边界
可以看到上面的效果,当滑动到边界的时候,还是可以继续滑动,我们来处理下这个问题,当滑动到边界的时候,不再支持滑动
黑色是屏幕,红色是放大之后的默认位置,绿色表示我们在拖动,而 offsetY 的值,就是图片和屏幕之间的那个距离,同理 offsetX 就是左边距和右边距的值,这里要考虑坐标轴上的:『下正上负,右正左负』
即:(图片的高度 - 屏幕的高度)/ 2 就是 offsetY 可以移动的距离; 所以,边界设定如下:
ini
private void fixOffset() {
offsetX = Math.min(offsetX, ((bitmap.getWidth() * bigScale - getWidth()) / 2));
offsetX = Math.max(offsetX, -((bitmap.getWidth() * bigScale - getWidth()) / 2));
offsetY = Math.min(offsetY, ((bitmap.getHeight() * bigScale - getHeight()) / 2));
offsetY = Math.max(offsetY, -((bitmap.getHeight() * bigScale - getHeight()) / 2));
}
然后在 scroll 方法中调用这个方法,然后我们运行看下效果:
java
/**
* @param e1 手指按下位置.
* @param e2 当前位置.
* @param distanceX 旧位置 - 新位置.
* @param distanceY 旧位置 - 新位置.
* @return true
*/
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// 放大才能滑动
if (isEnLarge) {
offsetX = offsetX - distanceX;
offsetY = offsetY - distanceY;
fixOffset();
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
可以看到,滑动到边界之后就不能再滑动了,达到了我们期望的效果;
设置惯性滑动
上图可以看到,我们还没有处理惯性滑动的效果,我们来处理下,前面有介绍 onFling 是处理惯性滑动的,我们在这个方法中借用 OverScroll 这个 API 来实现我们的惯性滑动;
惯性滑动,也是依赖要在放大状态下处理:
scss
private OverScroller overScroller;
private void init(Context context) {
overScroller = new OverScroller(context);
}
/**
* up 手指抬起之后的惯性活动
*
* @param e1 The first down motion event that started the fling.
* @param e2 The move motion event that triggered the current onFling.
* @param velocityX x 轴方向运动速度,px/s.
* @param velocityY y 轴方向运动速度,px/s.
* @return true
*/
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (isEnLarge) {
// 使用 fling 实现惯性滑动
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),
400, 400);
// 下一帧动画的时候执行
postOnAnimation(new Runnable() {
@Override
public void run() {
if (overScroller.computeScrollOffset()) {
offsetX = overScroller.getCurrX();
offsetY = overScroller.getCurrY();
invalidate();
postOnAnimation(this);
}
}
});
}
return super.onFling(e1, e2, velocityX, velocityY);
}
我们运行看下效果:
可以看到,我们的惯性滑动,以及回弹效果都实现了;
双指缩放
可以看到,我们现在还没有实现双指缩放的能力,我们来搞一下,这里我们借助 ScaleGestureDetector 这个系统的提供的 API 来实现;
typescript
private ScaleGestureDetector scaleGestureDetector;
private void init(Context context) {
scaleGestureDetector = new ScaleGestureDetector(context, new PhotoScaleGestureListener);
}
private class PhotoScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
return false;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
// 这里一定要返回 true,否则不生效
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
}
我们需要在这个 onScale 和 onScaleBegin 方法中处理我们的缩放系数,然后进行重绘;
typescript
float initScale;
@Override
public boolean onScale(ScaleGestureDetector detector) {
// 缩放因子
currentScale = initScale * detector.getScaleFactor();
invalidate();
return false;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
initScale = currentScale;
return true;
}
同样的,只要是手势探测,我们都需要接管 onTouchEvent 事件,双指缩放的优先级高于手指滑动的优先级;
ini
@Override
public boolean onTouchEvent(MotionEvent event) {
// 优先双指缩放的手势处理
boolean result = scaleGestureDetector.onTouchEvent(event);
// 如果双指缩放不处理了,在交给滑动手势处理
if (!scaleGestureDetector.isInProgress()) {
result = gestureDetector.onTouchEvent(event);
}
return result;
}
我们运行看下效果:
可以看到,我们实现了双指缩放的效果;但是有一些不足之处,例如:平移后缩小,直接留白了很多,我们来处理下;
实际上我们的平移(canvas.translate)和缩放(canvas.scale)是可以进行绑定的,我们在缩放的时候是有一个缩放系数的,那么我们在平移的时候也可以和这个系数关联起来;
scss
@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, (float) getWidth() / 2, (float) getHeight() / 2);
}
我们运行看下效果:
可以看到,缩放的时候,并没有了留白的badcase;并且缩小到宽度充满屏幕之后,就不再响应缩放的手势了;
双击偏移
可以看到,我们再次双击放大的时候,并没有从点击位置开始缩放,我们需要在双击的时候修改下偏移量,也就是我们缩小之后,再次双击放大的时候,应该是从哪里点击,就以哪里为中心进行放大处理;
scss
/**
* 双击触发时间 需要 40ms- 300ms 之间来处理
*
* @param e The down motion event of the first tap of the double-tap.
* @return true
*/
@Override
public boolean onDoubleTap(MotionEvent e) {
isEnLarge = !isEnLarge;
if (isEnLarge) {
offsetX = (e.getX() - getWidth() / 2f) -
(e.getX() - getWidth() / 2f) * bigScale / smallScale;
offsetY = (e.getY() - getHeight() / 2f) -
(e.getY() - getHeight() / 2f) * bigScale / smallScale;
// 归正偏移量的值就可以
fixOffset();
getScaleAnimator().start();
} else {
getScaleAnimator().reverse();
}
return super.onDoubleTap(e);
}
我们运行看下效果:
可以看到,我们实现了哪里点击,哪里放大的效果;
初始状态双指放大
这是因为我们在执行 scale 方法的时候,没有进行是否是放大状态的判断以及改变;
ini
@Override
public boolean onScale(ScaleGestureDetector detector) {
if ((currentScale > smallScale && !isEnLarge) || (currentScale == smallScale && isEnLarge)) {
isEnLarge = !isEnLarge;
}
// 缩放因子
currentScale = initScale * detector.getScaleFactor();
// 执行重新绘制
invalidate();
return false;
}
我们运行看下效果:
好了,到这里 PhotoView 的能力基本上就全都实现了,在双指缩放的时候,大家可以自行加一个缩放动画的过度,让它看起来不那么生硬;
简历润色
熟练使用Gesture手势探测器实现复杂图片的处理;
下一章预告
我用自定义 View 画了一条鱼;
欢迎三连
来都来了,点个关注,点个赞吧,你的支持是我最大的动力~~