自定义一个图片展示PhotoView,先看最终效果:
实现了双击放大缩小、双指缩放、拖动和惯性滑动功能。
这里有几个关键点:
- 重写onDraw方法,使用translate和scale来实现拖动和缩放功能
- 使用GestureDetector来处理双击事件和惯性滑动
- 使用ScaleGestureDetector来处理双指缩放
下面来一步步实现,首先自定义PhotoView,继承View并进行一些初始化工作:
public class PhotoView extends View {
private float fixScale; //基础缩放,让图片刚好充满view
private float bigScale; //大缩放,为小缩放的2倍
private float currentScale; //用于保存当前的缩放值
private boolean isInLarge; //是否在放大模式
private Paint paint; //画笔
private Bitmap bitmap; //图片bitmap
private float offsetX; //图片绘制开始x坐标
private float offsetY; //图片绘制开始y坐标
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);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
init();
}
}
这里定义了fixScale、bigScale和currentScale;分别是让图片刚好充满屏幕的缩放比例、双击放大的比例和当前缩放的比例。
isInLarge用于标记是否处于放大模式,offsetX和offsetY则是图片的x,y轴偏移量。
说明一下fixScale和bigScale,以横向图片为例:
黑色框为手机屏幕,绿色为原图,fixScale的作用就是进行一个初始的缩放,让原图的宽刚好充满屏幕。横向图的话bigScale则是让高度与屏幕高度相等。
接下来是init()方法:
private void init() {
paint = new Paint();
// 加载一个本地图片创建bitmap
bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.test_img2);
if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {//图片是横向图片
//smallScale*图片大小 刚好是view的大小
fixScale = (float) getWidth() / bitmap.getWidth();
//大放大的系数是让图片的高放大到整个view的总高度 ; * 1.2f是让图片更大点,使得上下也可以拖动
bigScale = (float) getHeight() / bitmap.getHeight() * 1.2f;
} else {
fixScale = (float) getHeight() / bitmap.getHeight();
bigScale = (float) getWidth() / bitmap.getWidth() * 1.2f;
}
currentScale = fixScale;
isInLarge = false;
}
根据图片的宽高比和屏幕的宽高比判断是横向还是纵向图片,再计算出fixScale和bigScale;然后是onDraw:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//平移需要放在缩放前面,因为如果先缩放(相当于屏幕方法了,像素没变)坐标系也会缩放,平移一点会移动很远的距离
canvas.translate(offsetX, offsetY);
//使用四参的这个方法,从view中心开始缩放
canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);
canvas.drawBitmap(bitmap, (getWidth() - bitmap.getWidth()) / 2f, (getHeight() - bitmap.getHeight()) / 2f, paint);
}
简单的绘制了图片,接下使用GestureDetector来处理双击放大和拖动:
class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
//和move事件类似
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (isInLarge) {
offsetX -= distanceX;
offsetY -= distanceY;
fixOffset();
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
//抛掷动作,惯性;只会调用一次
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return super.onFling(e1, e2, velocityX, velocityY);
}
//按下时触发
@Override
public boolean onDown(MotionEvent e) {
return true; //onDown需要return true 消费此事件
}
//双击第二次按下的时候触发
//两次时间间隔为40-300ms,限制大于40ms是为了防抖动
@Override
public boolean onDoubleTap(MotionEvent e) {
if (!isInLarge) {
//让图片中的点在放大后位于点击的地方
offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / fixScale;
offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / fixScale;
fixOffset();//计算完偏移再限制一下不能移除屏幕
}
//使用属性动画变化缩放值,让动画更平滑
getScaleAnimator().start();
return super.onDoubleTap(e);
}
}
private ObjectAnimator getScaleAnimator() {
if (scaleAnimator == null) {
//属性动画中的属性需要设置set方法,因为源码中会反射调用它的set方法
scaleAnimator = (ObjectAnimator) ObjectAnimator.ofFloat(this, "currentScale", 0);
//属性变化范围
//scaleAnimator.setDuration(10000);
}
if (currentScale >= bigScale) {//当当前缩放比例大于或等于bigScale时还原
scaleAnimator.setFloatValues(currentScale, fixScale);
isInLarge=false;
} else {//当当前缩放小于bigScale时,放大到bigScale
scaleAnimator.setFloatValues(currentScale, bigScale);
isInLarge=true;
}
return scaleAnimator;
}
拖动在onScroll中处理,如果是放大模式那么根据移动距离修改offset然后invalidate即可。放大时使用了属性动画来改变currentScale的值,让缩放更流畅;当当前比例大于或等于bigScale时将其还原为初始缩放fixScale,反之放大到bigScale。
另外,这里比较麻烦的是双击放大的时候对位移的处理,先分析下问题
因为目前是根据图片中心进行缩放的,缩放后图片的位置如黄色框。但是实际上我们希望是当我们点击某个点放大的时候,放大后的图片该点应该还在我们点下的地方。如上图点击B点时那么放大后效果的应该是绿色框所示,也就是说无论放大与否图片B点所示的事物都应该处于同一位置。然后我们希望放大后屏幕不会留白,还要将绿色框往下移,最终点击B点时图片的效果应该是红色框所示。
那么应该对图片进行平移,X轴的平移量应该是下图线段A-B(蓝色圆圈为点击位置):
也就是:
//除fixScale是因为最开始为了让图片充满屏幕乘了fixScale
offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / fixScale;
offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / fixScale;
对平移的限制:
黄框为原图,红框为放大后的图片,显然横向平移不能超过线段A,纵向移动不能超过线段B,因此:
private void fixOffset() {
//处理手指向左滑动,offset为负数递减,所以取max值
offsetX = Math.max(offsetX, -(bigScale * bitmap.getWidth() - getWidth()) / 2f);
//处理手指向右滑动,offsetX为正数递增,最大不能超过(bigScale * bitmap.getWidth() - getWidth()) / 2f
offsetX = Math.min(offsetX, (bigScale * bitmap.getWidth() - getWidth()) / 2f);
offsetY = Math.min(offsetY, (bigScale * bitmap.getHeight() - getHeight()) / 2f);
offsetY = Math.max(offsetY, -(bigScale * bitmap.getHeight() - getHeight()) / 2f);
}
目前为止,实现了双击放大缩小和拖动效果,但是出现了一个问题:双击还原的时候图片没法回到中心。这是因为我们做了平移但是还原的时候没有平移回去,在onDraw中进行处理
//处理放大后平移,再恢复到smallScale,图片回不到中心的问题
float fraction = (currentScale - fixScale) / (bigScale - fixScale);
canvas.translate(offsetX*fraction , offsetY*fraction );
根据缩放的比例对平移进行修正。
接着在GestureDetector的onFling方法中使用OverScroller处理惯性滑动
//抛掷动作,惯性;只会调用一次
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (isInLarge) {
overScroller.fling(
(int) offsetX, //开始位置为手指松开是的offsetX
(int) offsetY, //开始位置为手指松开是的offsetY
(int) velocityX,//系统默认速度
(int) velocityY,//
//x方向最小值
-(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
-(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
//回弹的范围
100, 100
);
//因为onFling只会处理一次,使用Runnable循环执行
postOnAnimation(flingRun);
}
return super.onFling(e1, e2, velocityX, velocityY);
}
因为抛掷动作完成onFling只会调用一次,所以要使用Runnable循环获取惯性滑动的偏移量
private class FlingRun implements Runnable {
@Override
public void run() {
//动画还在执行
if (overScroller.computeScrollOffset()) {
//overScroller调用了fling后CurrX会不断变化,拿到当前变化的值,更新到界面
offsetX = overScroller.getCurrX();
offsetY = overScroller.getCurrY();
invalidate();
//postOnAnimation是每帧执行一次,比post性能更好
postOnAnimation(this);//重复调用自己,更新offsetX
}
}
}
overScroller.fling()执行后会不断更新滑动的距离,所以在run方法中不断获取overScroller.getCurrX(Y)更新到offsetX(Y),调用invalidate更新画面。
剩下的双指缩放功能,使用了ScaleGestureDetector来实现
private class PhotoViewScaleGesture implements ScaleGestureDetector.OnScaleGestureListener {
private float initialScale;
@Override
public boolean onScale(ScaleGestureDetector detector) {
currentScale = initialScale * detector.getScaleFactor(); //detector.getScaleFactor()获取缩放因子
fixCurrentScale(); //限制最大最小缩放比例
if (currentScale > fixScale) isInLarge = true;
invalidate();
return false;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
initialScale = currentScale;
return true; //需要返回true,消费事件
}
}
在onScaleBegin,开始缩放的时候保存了当前缩放比例。onScale方法中得到缩放因子计算新的缩放比例,invalidate更新。
最后附上完整代码---------------------------------------->>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.widget.OverScroller;
import androidx.annotation.Nullable;
/**
* 自定义photoView,双击放大缩小,双指缩放,惯性滑动,边界回弹
*/
public class PhotoView extends View {
private float fixScale; //基础缩放,让图片刚好充满view
private float bigScale; //大缩放,为小缩放的2倍
private float currentScale; //用于保存当前的缩放值
private boolean isInLarge; //是否在放大模式
private Paint paint; //画笔
private Bitmap bitmap; //图片bitmap
private float offsetX; //图片绘制开始x坐标
private float offsetY; //图片绘制开始y坐标
private GestureDetector doubleTabGesture;
private OverScroller overScroller; //用于处理惯性滑动,相比Scroller有回弹效果
private FlingRun flingRun;
private ScaleGestureDetector photoViewScaleGesture;
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);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
init();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//将事件交给doubleTabGesture处理
boolean result = photoViewScaleGesture.onTouchEvent(event);
//如果没有在双指操作,那么交给双击doubleTabGesture处理
if (!photoViewScaleGesture.isInProgress()) {
result = doubleTabGesture.onTouchEvent(event);
}
return result;
}
private void init() {
paint = new Paint();
bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.test_img2);
if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {//图片是横向图片
//smallScale*图片大小 刚好是view的大小
fixScale = (float) getWidth() / bitmap.getWidth();
//大放大的系数是让图片的高放大到整个view的总高度 ; * 1.2f是让图片更大点,使得上下也可以拖动
bigScale = (float) getHeight() / bitmap.getHeight() * 1.2f;
} else {
fixScale = (float) getHeight() / bitmap.getHeight();
bigScale = (float) getWidth() / bitmap.getWidth() * 1.2f;
}
currentScale = fixScale;
isInLarge = false;
overScroller = new OverScroller(getContext());
flingRun = new FlingRun();
doubleTabGesture = new GestureDetector(getContext(), new PhotoGestureListener());
photoViewScaleGesture = new ScaleGestureDetector(getContext(), new PhotoViewScaleGesture());
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//处理放大后平移,再恢复到smallScale,图片回不到中心的问题
//float fraction=1;
float fraction = (currentScale - fixScale) / (bigScale - fixScale);
// float fraction=1;
//平移需要放在缩放前面,因为如果先缩放(相当于屏幕方法了,像素没变)坐标系也会缩放,平移一点会移动很远的距离
canvas.translate(offsetX*fraction , offsetY*fraction );
//使用四参的这个方法,从view中心开始缩放
canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);
canvas.drawBitmap(bitmap, (getWidth() - bitmap.getWidth()) / 2f, (getHeight() - bitmap.getHeight()) / 2f, paint);
}
private void fixOffset() {
System.out.println("bef offsetX = " + (int) offsetX);
//处理手指向左滑动,offset为负数递减,所以取max值
offsetX = Math.max(offsetX, -(bigScale * bitmap.getWidth() - getWidth()) / 2f);
//处理手指向右滑动,offsetX为正数递增,最大不能超过(bigScale * bitmap.getWidth() - getWidth()) / 2f
offsetX = Math.min(offsetX, (bigScale * bitmap.getWidth() - getWidth()) / 2f);
System.out.println("offsetX = " + (int) offsetX);
offsetY = Math.min(offsetY, (bigScale * bitmap.getHeight() - getHeight()) / 2f);
offsetY = Math.max(offsetY, -(bigScale * bitmap.getHeight() - getHeight()) / 2f);
}
//使用SimpleOnGestureListener来处理双击事件和惯性滑动
class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
//手指抬起时触发,双击时只会在第二次抬起时触发
@Override
public boolean onSingleTapUp(MotionEvent e) {
return super.onSingleTapUp(e);
}
//长按300ms触发
@Override
public void onLongPress(MotionEvent e) {
super.onLongPress(e);
}
//和move事件类似
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (isInLarge) {
offsetX -= distanceX;
offsetY -= distanceY;
fixOffset();
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
//抛掷动作,惯性;只会调用一次
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (isInLarge) {
overScroller.fling(
(int) offsetX, //开始位置为手指松开是的offsetX
(int) offsetY, //开始位置为手指松开是的offsetY
(int) velocityX,//系统默认速度
(int) velocityY,//
//x方向最小值
-(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
-(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
//回弹的范围
100, 100
);
//因为onFling只会处理一次,使用Runnable循环执行
postOnAnimation(flingRun);
}
return super.onFling(e1, e2, velocityX, velocityY);
}
//按下后延时100ms触发,一般用于添加按下效果
@Override
public void onShowPress(MotionEvent e) {
super.onShowPress(e);
}
//按下时触发
@Override
public boolean onDown(MotionEvent e) {
return true; //onDown需要return true 消费此事件
}
//双击第二次按下的时候触发
//两次时间间隔为40-300ms,限制大于40ms是为了防抖动
@Override
public boolean onDoubleTap(MotionEvent e) {
//offsetX = 0;
//offsetY = 0;
if (!isInLarge) {
//让图片中的点在放大后位于点击的地方
offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / fixScale;
offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / fixScale;
fixOffset();//计算完偏移再限制一下不能移除屏幕
//使用属性动画变化缩放值,让动画更平滑
// getScaleAnimator().start();
} else {
//offsetX=0;
//offsetY=0;
//getScaleAnimator().reverse();
}
getScaleAnimator().start();
return super.onDoubleTap(e);
}
//双击事件,第二次按下时 down、move、up都会触发
@Override
public boolean onDoubleTapEvent(MotionEvent e) {
return super.onDoubleTapEvent(e);
}
//单击按下时触发,双击时不触发
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
return super.onSingleTapConfirmed(e);
}
}
private class FlingRun implements Runnable {
@Override
public void run() {
//动画还在执行
if (overScroller.computeScrollOffset()) {
//overScroller调用了fling后CurrX会不断变化,拿到当前变化的值,更新到界面
offsetX = overScroller.getCurrX();
offsetY = overScroller.getCurrY();
invalidate();
//postOnAnimation是每帧执行一次,比post性能更好
postOnAnimation(this);//重复调用自己,更新offsetX
}
}
}
private ObjectAnimator scaleAnimator;
public void setCurrentScale(float currentScale) {
this.currentScale = currentScale;
//属性值变化时更新
invalidate();
}
private ObjectAnimator getScaleAnimator() {
if (scaleAnimator == null) {
//属性动画中的属性需要设置set方法,因为源码中会反射调用它的set方法
scaleAnimator = (ObjectAnimator) ObjectAnimator.ofFloat(this, "currentScale", 0);
//属性变化范围
//scaleAnimator.setDuration(10000);
}
if (currentScale >= bigScale) {//当当前缩放比例大于或等于bigScale时还原
scaleAnimator.setFloatValues(currentScale, fixScale);
isInLarge=false;
} else {//当当前缩放小于bigScale时,放大到bigScale
scaleAnimator.setFloatValues(currentScale, bigScale);
isInLarge=true;
}
return scaleAnimator;
}
//限制双指缩放的大小,不能小于初始的smallScale,不能大于bigScale的1.5倍
private void fixCurrentScale() {
currentScale = Math.min(bigScale * 1.5f, currentScale);
currentScale = Math.max(currentScale, fixScale);
}
//OnScaleGestureListener处理双指缩放
private class PhotoViewScaleGesture implements ScaleGestureDetector.OnScaleGestureListener {
private float initialScale;
@Override
public boolean onScale(ScaleGestureDetector detector) {
currentScale = initialScale * detector.getScaleFactor(); //detector.getScaleFactor()获取缩放因子
fixCurrentScale();
if (currentScale > fixScale) isInLarge = true;
invalidate();
return false;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
initialScale = currentScale;
return true; //需要返回true,消费事件
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
}
}
完。