Android开发实战——自定义view之PhotoView图片查看器
为了方便代码的阅读,我将类都写成了内部类,下面的代码拿了直接可以使用,换一下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); } }