为了方便代码的阅读,我将类都写成了内部类,下面的代码拿了直接可以使用,换一下bitmap就行了。注释也是比较详细的,认真看再结合使用,应该很容易理解。
PhotoView.java
public class PhotoView extends View {
private static final float IMAGE_WIDTH = Utils.dpToPixel(300);
private Bitmap bitmap;
private Paint paint;
private float originalOffsetX; // X轴偏移 这里主要用于图片初始化时设置居中
private float originalOffsetY; // Y轴偏移 这里主要用于图片初始化时设置居中
private float smallScale;
private float bigScale;
private float currentScale;
private GestureDetector gestureDetector;
/*
* 标志位,用于判断当前图片是smallScale还是bigScale
* boolean值默认为false PhotoView默认也是smallScale
* 所以当图片为smallScale时isFlag=false 当图片为bigScale时isFlag=true 值在onDoubleTap方法里面进行修改
* */
private boolean isFlag;
private ObjectAnimator animator;// 双击图片时的动画
private float offsetX; // 拖动图片时X轴偏移量
private float offsetY; // 拖动图片时Y轴偏移量
private OverScroller scroller; // 惯性滑动
private ScaleGestureDetector scaleGestureDetector;
private void init(Context context) {
// todo 步骤1 初始化
bitmap = Utils.getPhoto(getResources(), (int) IMAGE_WIDTH);// 获取bitmap对象
paint = new Paint(Paint.ANTI_ALIAS_FLAG);// 使位图抗锯齿
// todo 步骤3 手势监听
gestureDetector = new GestureDetector(context, new PhotoGestureDetector());
// 关闭长按响应
// gestureDetector.setIsLongpressEnabled(false);
scroller = new OverScroller(context);
// todo 步骤7 双指缩放监听
scaleGestureDetector = new ScaleGestureDetector(context, new PhotoOnScaleGestureListener());
}
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);
init(context);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// todo 步骤9 处理图片放大平移后 再缩小时 图片不回到正中间的问题
/*
* 这个系数是如何来的可能有点绕
* 我们需要解决步骤9的问题,首先需要偏移值offsetX、offsetY与我们的缩放因子进行绑定,缩放因子越大,偏移的值也越大
* 我们知道,整个图片最大的缩放因子为bigScale(终点),最小缩放因子为smallScale(起点), bigScale-smallScale得到的就是总共缩放因子的区间值(距离)
* 那么当前缩放因子是currentScale(当前所在位置), 减去smallScale(起点),得到的是当前缩放因子距离最小缩放因子的值(当前位置-起点的位置)
* 那么 (当前位置-起点的位置)/ 总距离 得到的就是距离比 也就是当前我完成了总路程的百分之几
*
* 结合下面的公式我们可以知道当currentScale=smallScale时 scaleAction为0 此时图片不偏移
* 当currentScale=bigScale时 scaleAction为1此时的图片为最大图 那么这个时候如果移动图片的话offsetX、offsetY该偏移多少就偏移多少
*
* 所以当我们这样计算这个比例之后,当图片处于最大和最小之间时,我们手指平移100px,那么图片可能只会平移50px
* 当图片处于最大的时候,我们手指平移100px,那么图片会平移100px
* 当图片处于最小的时候,我们手指平移100px,那么图片会平移0px 也就是不平移
* */
float scaleAction = (currentScale - smallScale) / (bigScale - smallScale);
/* 图片拖动的效果 */
canvas.translate(offsetX * scaleAction, offsetY * scaleAction);
/* 四个参数的意思分别是:图片X轴缩放系数、图片Y轴缩放系数、缩放时以哪个点进行缩放(我们取的是屏幕的中心点,默认是屏幕左上角,即0,0) */
canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);
/* 居中显示 */
canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint);
}
/* 在控件大小发生改变时调用 初始化时会被调用一次 后续控件大小变化时也会调用*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// todo 步骤2 初始图片位置
/* 居中显示时 X、Y的值 */
originalOffsetX = (getWidth() - bitmap.getWidth()) / 2f;
originalOffsetY = (getHeight() - bitmap.getHeight()) / 2f;
/*
* 进入界面初始化图片时,需要满足左右两边填充整个屏幕或者上下两边填充整个屏幕
* 所以要判断图片是竖形状的图片 还是横形状的图片
* 这里用图片的宽高比与屏幕的宽高比进行对比来判断
*
* smallScale表示图片缩放的比例 图片最小是多小
* 命中if: 图片按照宽度比进行缩放,当图片的宽度与屏幕的宽度相等时停止缩放,图片上下边界与屏幕上下边界会有间距
* 命中else: 图片按照高度比进行缩放,当图片的高度与屏幕的高度相等时停止缩放,图片左右边界与屏幕左右边界会有间距
*
* bigScale表示图片缩放的比例 图片最大是多大
* 不*1.5f的话,那么图片最大就是窄边与屏边对齐 *1.5f表示图片放大后窄边也可以可以超出屏幕
*
* currentScale的值到底是什么,取决于我们的需求
* 在这里smallScale表示缩小 bigScale表示放大
* 当currentScale = smallScale时 双击图片之后currentScale = bigScale 否则相反 这个判断在下面的双击事件里面处理
* */
if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {
smallScale = (float) getWidth() / bitmap.getWidth();
bigScale = (float) getHeight() / bitmap.getHeight() * 1.5f;
} else {
smallScale = (float) getHeight() / bitmap.getHeight();
bigScale = (float) getWidth() / bitmap.getWidth() * 1.5f;
}
currentScale = smallScale;
}
/*
* 因为当view被点击的时候,进入的是view的onTouchEvent方法进行事件分发
* 而我们这里用到的是GestureDetector 所以view的onTouchEvent要托管给GestureDetector的onTouchEvent去执行
* 但是同时 双指缩放的监听也需要用到ScaleGestureDetector的onTouchEvent 所以还需要进行判断
*
* */
@Override
public boolean onTouchEvent(MotionEvent event) {
// 响应事件以双指缩放优先
boolean result = scaleGestureDetector.onTouchEvent(event);
// 判断 如果不是双指缩放 则把事件交给手势监听处理
if (!scaleGestureDetector.isInProgress()) {
result = gestureDetector.onTouchEvent(event);
}
return result;
}
/* 手势相关监听 */
class PhotoGestureDetector extends GestureDetector.SimpleOnGestureListener {
/*
* 单击或者双击的第一次up时触发
* 即如果不是长按、不是双击的第二次点击 则在up时触发
* */
@Override
public boolean onSingleTapUp(MotionEvent e) {
return super.onSingleTapUp(e);
}
/* 长按触发 默认超过300ms时触发 */
@Override
public void onLongPress(MotionEvent e) {
super.onLongPress(e);
}
/**
* 滚动时(拖动图片)触发 -- move
*
* @param e1 手指按下的事件
* @param e2 当前的事件
* @param distanceX 旧位置 - 新位置 所以小于0表示往右 大于0表示往左 所以计算偏移值时要取反
* @param distanceY 同上
* @return
*/
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// todo 步骤5 处理拖拽
// 当图片为放大模式时才允许拖动图片
// distanceX的值并不是起始位置与终点位置的差值,而是期间若干个小点汇聚而成的
// 比如当从0px滑动到100px时,distanceX在1px时会出现,此时distanceX就是-1,然后可能又在2px时出现,
// 在整个滑动过程中distanceX的值一直在变动,所以offsetX要一直计算 offsetY同理
if (isFlag) {
offsetX = offsetX - distanceX;
offsetY = offsetY - distanceY;
// 计算图片可拖动的范围
fixOffset();
// 刷新
invalidate();
}
return super.onScroll(e1, e2, distanceX, distanceY);
}
/**
* up时触发 手指拖动图片的时候 惯性滑动 -- 大于50dp/s
*
* @param velocityX x轴方向运动速度(像素/s)
* @param velocityY
* @return
*/
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// todo 步骤6 处理拖拽时的惯性滑动
// 当图片为放大模式时才允许惯性滑动
if (isFlag) {
// 最后两个参数表示 当惯性滑动超出图片范围多少之后回弹,这也是为什么用OverScroller而不用Scroller的原因
scroller.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 FlingRun());
}
return super.onFling(e1, e2, velocityX, velocityY);
}
/* 点击后延时100ms触发 -- 主要用于处理自定义的点击效果,例如水波纹等 */
@Override
public void onShowPress(MotionEvent e) {
super.onShowPress(e);
}
/*
* down时触发 在这个需求里面,我们需要返回true 这与事件分发有关 可以看下我相关的文章
* 这里只要知道, 当返回true的时候下面的双击等函数才会执行 否则直接在这里就拦截了
* */
@Override
public boolean onDown(MotionEvent e) {
return true;
}
/* 双击的第二次点击down时触发。双击的触发时间 -- 40ms -- 300ms */
@Override
public boolean onDoubleTap(MotionEvent e) {
// todo 步骤4 处理双击
if (!isFlag) {
// isFlag为false表示 取反前处于smallScale(缩小)状态,则双击后要变成bigScale(放大)
// currentScale = bigScale;
// 点击图片的哪个位置 哪个位置就进行放大 不设置的话 图片只会以中心进行放大
// 其原理是以中心店先进行放大 再进行偏移
offsetX = (e.getX() - getWidth() / 2f) -
(e.getX() - getWidth() / 2f) * bigScale / smallScale;
offsetY = (e.getY() - getHeight() / 2f) -
(e.getY() - getHeight() / 2f) * bigScale / smallScale;
fixOffset();
// 这里直接添加动画, 图片从小到大 在getAnimator()方法里面我们设置了setFloatValues的值
getAnimator(smallScale, bigScale).start();
} else {
// isFlag为true表示当前处于bigScale(放大)状态,则双击后要变成smallScale(缩小)
// currentScale = smallScale;
// 这里直接添加动画, 图片从大到小
// 如果没有双指缩放功能,直接用下面这行代码就行了,但是在有双指缩放的情况下,如果图片被双指缩放到一半的时候再进行双击
// 图片会有一个先缩小再放大的过程,这就是一个小BUG了
// getAnimator(bigScale, smallScale).start();
// 所以这里我们要用currentScale
getAnimator(currentScale, smallScale).start();
}
// 每次双击后取反
isFlag = !isFlag;
return super.onDoubleTap(e);
}
/* 双击的第二次down、move、up 都触发 */
@Override
public boolean onDoubleTapEvent(MotionEvent e) {
return super.onDoubleTapEvent(e);
}
/*
* 单击按下时触发,双击时不触发,down,up时都可能触发
* 延时300ms触发TAP事件
* 300ms以内抬手 -- 会触发TAP -- onSingleTapConfirmed
* 300ms以后抬手 -- 不是双击不是长按,则触发 但是300ms以后默认是长按事件
* 因此我们可以关闭长按事件的相应 上面代码有注释
* */
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
return super.onSingleTapConfirmed(e);
}
}
class FlingRun implements Runnable {
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
@Override
public void run() {
// 判断惯性动画是否结束 没结束返回true 结束了返回的是false
if (scroller.computeScrollOffset()) {
offsetX = scroller.getCurrX();
offsetY = scroller.getCurrY();
invalidate();
// 下一帧动画的时候执行
postOnAnimation(this);
}
}
}
/*
* 允许拖动图片的边界
*
* 设置图片最大拖动的距离 如果不设置,那么拖动的距离超出图片之后,看到的就是白色的背景
* 设置之后,当拖动图片到图片边界时,则不能继续往该方向拖了。
*
* */
private void fixOffset() {
// 注意:offsetX为拖动的距离,offsetX = -(旧位置-新位置)
// (bitmap.getWidth()*bigScale - getWidth())/2表示 图片宽度的一半-屏幕宽度的一半 得到的就是可以拖动的最大距离
// 当图片往右时,我们的手也是往右 offsetX为正数,图片可拖动的最大距离也为正数 此时取最小值为offsetX
// 例如我们手指往右滑动了100px 而图片最大只能动50px 再往右滑的话 图片的左边就超出图片范围了 因此取50px
offsetX = Math.min(offsetX, (bitmap.getWidth() * bigScale - getWidth()) / 2);
// 当图片往左时,我们的手也是往左 offsetX为负数,而我们计算的图片可拖动的距离是正数 因此这里要取反 并且取两者最大值
// 例如我们手指往左滑动了100px 那么offsetX = -100 而最大拖动距离为50px 取反为-50px -100 与-50 取最大值
offsetX = Math.max(offsetX, -(bitmap.getWidth() * bigScale - getWidth()) / 2);
// Y轴一样
offsetY = Math.min(offsetY, (bitmap.getHeight() * bigScale - getHeight()) / 2);
offsetY = Math.max(offsetY, -(bitmap.getHeight() * bigScale - getHeight()) / 2);
}
/* 图片放大缩小动画 */
private ObjectAnimator getAnimator(float scale1, float scale2) {
if (animator == null) {
// 这个方法内部是通过反射 设置currentScale的值 所以currentScale必须要有get\set方法
animator = ObjectAnimator.ofFloat(this, "currentScale", 0);
}
animator.setFloatValues(scale1, scale2);
return animator;
}
public float getCurrentScale() {
return currentScale;
}
public void setCurrentScale(float currentScale) {
this.currentScale = currentScale;
// 由于属性动画animator会不断的调用set方法, 所以刷新放在这里
invalidate();
}
/* 双指缩放监听类 */
class PhotoOnScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener {
float scale;
/* 双指缩放时 */
@Override
public boolean onScale(ScaleGestureDetector detector) {
// todo 步骤8 处理双指缩放以及缩放边距
/*
* detector.getScaleFactor()表示两个手指之间缩放的大小值
* 例如两个手指的距离缩短一半时 值为0.5 两个手指距离增加一倍时 值为2
*
* scale表示初始化时的缩放因子,currentScale为最终的缩放因子
* 这里不用currentScale直接乘的原因是 双指缩放的动作是持续性的,
* 因此如果用currentScale直接乘的话 缩放因子基数会一直变动,这样取值不正确,
* 正确的做法是要一直用缩放之前的因子 乘 detector.getScaleFactor()
* */
if ((currentScale >= bigScale && detector.getScaleFactor() < 1)
|| (currentScale <= smallScale && detector.getScaleFactor() > 1)
|| (currentScale > smallScale && currentScale < bigScale)) {
if(scale * detector.getScaleFactor() <= smallScale){
// 解决双指缩放时超过图片最小边界
currentScale = smallScale;
isFlag = false;
} else if(scale * detector.getScaleFactor() >= bigScale){
// 解决双指缩放时超过图片最大边界
currentScale = bigScale;
isFlag = true;
} else {
currentScale = scale * detector.getScaleFactor();
isFlag = true;
}
invalidate();
}
// 解决图片为smallScanle的时候 进行双指放大后无法拖拽图片的问题 因为上的else修改了isFlag的值,所以这里不用再次判断
/*if (currentScale >= smallScale && !isFlag) {
isFlag = !isFlag;
}*/
return false;
}
/* 双指缩放之前 这里要return true 与我们事件分发一样的道理 */
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
scale = currentScale;
return true;
}
/* 双指缩放之后 这里一般不做处理 */
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
}
}
Utils
public class Utils {
public static float dpToPixel(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
Resources.getSystem().getDisplayMetrics());
}
/* 获取bitmap */
public static Bitmap getPhoto(Resources res, int width) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, R.drawable.photo, options);
options.inJustDecodeBounds = false;
options.inDensity = options.outWidth;
options.inTargetDensity = width;
return BitmapFactory.decodeResource(res, R.drawable.photo, options);
}
}