这篇“Android自定义PhotoView使用的方法是什么”文章的知识点大部分人都不太理解,所以小编给大家总结了以下内容,内容详细,步骤清晰,具有一定的借鉴价值,希望大家阅读完这篇文章能有所收获,下面我们一起来看看这篇“Android自定义PhotoView使用的方法是什么”文章吧。
准备工作
自定义PhotoView
自定义 PhotoView 继承(extends)自 View。并在最中间显示后面操作的图片。绘制图片可以重写 onDraw()方法,并在里面通过Canvas.drawBitmap()来要绘制图片。
drawBitmap()的四个参数:
bitmap: 要在 Canvas 中绘制的位图
letf:正在绘制的位图左侧的位置
top:正在绘制的位图顶部的位置
paint: 画笔
其中 (left, top) 是要绘制图片的起始坐标。要将图片绘制在中间,我们就需要计算 left/top 的位置。我们重写 onSizeChanged() 函数,该函数在onDraw之前调用,且尺寸改变时也要调用。
其中:(下面代码中是用 originalOffsetX/originalOffsetY 来代替的)
left = (getWidth() - bitmap.getWidth()) / 2;
top =(getHeight() - bitmap.getHeight()) / 2;
public class PhotoView extends View { private static final float IMAGE_WIDTH = Utils.dpToPixel(300); private Bitmap bitmap; private Paint paint; // 画笔 private float originalOffsetX; private float originalOffsetY; 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(); } /** * 初始化操作 */ private void init() { bitmap = Utils.getPhoto(getResources(), (int) IMAGE_WIDTH); // 获取到图片 paint = new Paint(); } /** * TODO 在onDraw之前调用,且尺寸改变时也要调用 */ @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); originalOffsetX = (getWidth() - bitmap.getWidth()) / 2f; originalOffsetY = (getHeight() - bitmap.getHeight()) / 2f; } /** * 画出图片 * @param canvas 画布 */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint); } }
xml 布局
xml 布局中最外层是 FragmeLayout,里面只有一个自定义的 PhotoView 用来展示图片。
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <com.example.photoview2.PhotoView android:layout_width="match_parent" android:layout_height="match_parent"/> </FrameLayout>
Utils 工具类
Utils 工具类里主要有两个函数。dpToPixel() 将 dp 转换为像素;getPhot() 加载 Drawable 下的图片,并返回为 bitmap 类型。
public class Utils { public static float dpToPixel(float dp) { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().getDisplayMetrics()); } 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); } }
1、双击放大和缩小
设置图片的缩放比例
如下图的三种情况,左边的是原图;中间是小放大(smallScale),即图片左右两边贴进屏幕;右边是大放大(bigScale),即图片沾满整个屏幕。
根据上面的描述,设置两个变量即 smallScale 和 bigScale 分别代表上图"中"和“右”的缩放比例,smallScale 是初始样式,bigSmall 是双击后的样式。将 smallScale 和 bigScale 的设置放在 onSizeChanged() 函数里设值。如下图所示
/** * TODO 在onDraw之前调用,且尺寸改变时也要调用 */ @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); originalOffsetX = (getWidth() - bitmap.getWidth()) / 2f; originalOffsetY = (getHeight() - bitmap.getHeight()) / 2f; // TODO 判断 bitmap 是扁的还是长的 if ((float)bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) { // bitmap 的 width > height smallScale = (float) getWidth() / bitmap.getWidth(); bigScale = (float) getHeight() / bitmap.getHeight() * OVER_SCALE_FACTOR; }else { // bitmap 的 height > width smallScale = (float) getHeight() / bitmap.getHeight(); bigScale = (float) getWidth() / bitmap.getWidth() * OVER_SCALE_FACTOR; } currentScale = smallScale; }
注意 if 里的判断条件,判断图片是扁平还是长的。如下图理解,当然我们这里用的图是扁平的。currentScale 是当前的缩放比例,smallScale <= currentScale <= bigScale 。
最后设置了 smallScale 和 bigScale 后,我们还要在 onDraw 里将 smallScale 放大的图片绘制出来。这里用 currentScale ,因为在 onSizeChanged 函数里,我们将 smallScale 赋值给了 currentScale 。使用 Canvas.scale 函数进行缩放。
// TODO 图片放大, // 第1,2个参数是放大比例,第3,4个参数是缩放的起始点,默认是(0,0) canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);
双击击缩放
Android 为我们提供了一个 GestureDetector 类来实现双击、单击、滑动、惯性滑动等。在 init 函数里添加如下代码,初始化 GestureDetector。gestureDectector 是一个全局变量。
gestureDetector = new GestureDetector(context, new photoGestureListener());
GestureDetector 的第二个参数是一个 Listener ,所以我们写了个内部类 photoGestureListener 继承GestureDetector.SimpleOnGestureListener。SimpleOnGestureListener 是一个 interface, 所以我们重写里面的方法,其中onDoubleTap() 就是实现写双击缩放的。
注意:onDown() 方法要返回 true 才能响应到双击事件
/** * TODO 单击/双击/惯性滑动的监听 */ class photoGestureListener 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 就位置 - 新位置 * @param distanceY * @return */ @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return super.onScroll(e1, e2, distanceX, distanceY); } /** * 惯性滑动 * @param velocityX X轴方向运动速度 像素/s * @param velocityY Y轴方向运动速度 像素/s * @return */ @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return super.onFling(e1, e2, velocityX, velocityY); } // 处理点击效果 --延时 100ms 触发 @Override public void onShowPress(MotionEvent e) { super.onShowPress(e); } // 只需要关注 onDown 的返回值,默认返回 false @Override public boolean onDown(MotionEvent e) { return true; } // 双击的第二次点击 down 时触发 双击 40ms -- 300ms 之间 @Override public boolean onDoubleTap(MotionEvent e) { // // TODO 第一版,这种直接放大/缩小有点深硬,不平滑 // isEnlarge = !isEnlarge; // if (isEnlarge) { // currentScale = bigScale; // 双击放大 // }else { // currentScale = smallScale; // 再双击时放小 // } // invalidate(); // 刷新 //TODO 第二版,借助属性动画实现 isEnlarge = !isEnlarge; if (isEnlarge) { // TODO 双击时计算偏移,双击那个位置,就放大那个位置 / (e.getX(), e.getY()) 当前点击的位置 offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / smallScale; offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / smallScale; fitOffsets(); // 解决点击图片外时放大空白部分 getScaleAnimator().start(); }else { getScaleAnimator().reverse(); } 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 以后抬手 -- 不是双击或长按,则触发 @Override public boolean onSingleTapConfirmed(MotionEvent e) { return super.onSingleTapConfirmed(e); } }
onDoubleTap() 里的第一版代码里 currentScale 直接由 smallScale 变到 bigscale,一下子就放大了,就很生硬不平滑。为了实现平滑的效果,我们使用 属性动画(ObjectAnimator),使得currentScale 由 smallScale 逐步变化到 bigScale,即 currentScale
(smallScale, bigScale)
private ObjectAnimator getScaleAnimator(){ if (scaleAnimator == null) { scaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", 0); } // TODO 平滑的范围,从 smallScale --> bigScale scaleAnimator.setFloatValues(smallScale, bigScale); return scaleAnimator; } public float getCurrentScale() { return currentScale; } public void setCurrentScale(float currentScale) { this.currentScale = currentScale; // 每一次在 smallScale -- bigScale 直接变化时都刷新 invalidate(); }
注意:上面代码里的 offsetX / offsetY 两个变量这里没讲,是因为它们是滑动里用到的变量,所以我们放到下一小节里讲,这里用它们是为了实现双击那个位置,就放大那个位置。如果把下面两句代码注释掉,会发现双击的时候永远是从中间位置放大。实现原理就是 offsetX / offsetY 是两个偏移量,我们从中间放大后再移到 offsetX / offsetY 的位置,就实现了点击哪里就放大哪里。
offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / smallScale; offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / smallScale; fitOffsets(); // 解决点击图片外时放大空白部分
完成上面的代码,当我们运行程序然后双击屏幕时发现图片并没有放大,为什么?因为我们双击的时候触发的是 photoView 的 onTouchEvent(),而双击时需要触发 GestureDetector 的 onToucEvent()才能实现效果,所以我们再 photoView 里重写 onTouchEvent ,并用 GestureDetector 的 onTouchEvent() 来强制接管。
/** TODO 我们点击图片时,触发的是 PhotoView 里的 onTouchEvent, * TODO 并没有触发 GestureDetector 里的onTouchEvent, 所以才需要强制接管 */ @Override public boolean onTouchEvent(MotionEvent event) { return gestureDetector.onTouchEvent(event); //return super.onTouchEvent(event); }
2、滑动和惯性滑动
当我们双击放大图片后,可以通过手指滑动查看屏幕外面的内容,或者用力往某个方向滑动,实现惯性滑动的效果。
手指滑动
在上面一节提到的 SimpleOnGestureListener 接口,里面的 onScroll 函数实现滑动。offsetX offsetY 是滑动的偏移量,即滑动到了图片的那个位置,在绘制的时候才能把滑动到的位置的图片绘制出来。
/** * 滚动 --move * @param e1 手指按下 * @param e2 当前动作 * @param distanceX 就位置 - 新位置 * @param distanceY * @return */ @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // 图片放大时,才可以滑动,即改变 offsetX offsetY if (isEnlarge) { offsetX -= distanceX; offsetY -= distanceY; fitOffsets(); invalidate(); } return super.onScroll(e1, e2, distanceX, distanceY); }
if 里的判断条件是确保在图片放大的情况下才进行滑动。fitOffsets() 是一个功能函数,计算图片滑动到边界的情况,放大后图片的边界滑动到屏幕边界时就滑不动了。
/** * 计算图片滑动的边界情况 * TODO 当往某个方向滑动图片时,放大后的图片边界与手机屏幕边界重合时,就不能滑动了 */ private void fitOffsets(){ 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); }
对 offsetX 取值用 Math.min()和 Math.max() 的情况可以如下图理解。offsetY 同理。
设置好了 onScroll() 函数后,我们还要将滑动的图片绘制出来,所以我们还要在 onDraw 函数里调用 Canvas.translate(), 将滑动的偏移 offsetX / offsetY 设置进去。
// TODO 图片滑动查看隐藏部分 canvas.translate(offsetX, offsetY);
惯性滑动
SimpleOnGestureListener 接口里的 onFling 函数实现惯性滑动。通过 OverScroll.fling() 来实现,filing 函数的最后两个参数表示当滑动到边界时,如果还有速度,则会将边界外的空白部分拉出200像素,然后立马回弹回去的那种效果。可以尝试将这两个参数去掉对比两种情况的效果。
/** * 惯性滑动 * @param velocityX X轴方向运动速度 像素/s * @param velocityY Y轴方向运动速度 像素/s * @return */ @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { if (isEnlarge) { 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, 200, 200); // TODO 我们要不断的刷新界面,不断的改变 offsetX, offsetY, 参数:Runnable接口 // postOnAnimation 下一帧动画的时候执行 postOnAnimation(new flingRunner()); } return super.onFling(e1, e2, velocityX, velocityY); }
我们在惯性滑动时要不断的刷新界面,不断改变 offsetX / offsetY 。我们使用 postOnAnimation(),里面传入一个 filingRunner 接口,继承自Runnable 。然后在filingRunner 里再调用postOnAnimation() 实现循环的效果。用 overScroller.computeScrollOffset() 函数计算当前的偏移并赋值给 offsetX/offsetY,实现不断改变它的功能。当computeScrollOffset() 返回 false,则表明当前的惯性速度为0,惯性滑动就结束,则结束循环。
class flingRunner implements Runnable{ @Override public void run() { // TODO 用 overScroller 计算当前的偏移,并赋值给offsetX, offsetY if (overScroller.computeScrollOffset()) { // computeScrollOffset()会返回一个boolean值,为true, 说明动作还没完成,以此来作为循环结束条件 offsetX = overScroller.getCurrX(); offsetY = overScroller.getCurrY(); invalidate(); //在上面的onFling 方法里面,postOnAnimation 只会调用一次,所以我们这里再调用,参数:自己(flingRunner) //TODO postOnAnimation 下一帧动画的时候执行 postOnAnimation(this); } } }
注意:写到这里,就有了一个小 bug ,就是当我们滑动了图片后再双击放小,会发现图片不会显示在正中间了,只需在 onDraw() 函数里做如下修改:我们在 offsetX / offsetY 上乘以一个平移因子,当双击缩小的时候,currentScale == smallScale ,则 scaleFaction == 0 --> offsetX / offsetY ==0 ,就相当于没有平移了,所以双击缩小时就能显示在原位置。
// 解决:当位置移动后,双击缩小,让图片显示在最初的位置 // 双击缩小时,currentScale = smallScale, 所以 scaleFunction = 0, 所以 translate就相当于没有平移 float scaleFaction = (currentScale - smallScale) / (bigScale - smallScale); // TODO 图片滑动查看隐藏部分 canvas.translate(offsetX * scaleFaction, offsetY * scaleFaction);
3、双指放大和缩小
Android 为我们提供了一个 ScaleGestureDetector 类来实现双指缩放功能。在 init() 函数里初始化。
scaleGestureDetector = new ScaleGestureDetector(context, new photoScaleGestureListener());
photoScaleGestureListener() 实现了ScaleGestureDetector.onScaleGestureListener 接口,实现里面的三个方法。
onScale:处理正在缩放
onScaleBegin: 开始缩放
onScaleEnd: 结束缩放
/** * TODO 双指缩放大的监听 */ class photoScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener{ float initScale; // 处理正在缩放 @Override public boolean onScale(ScaleGestureDetector detector) { if (currentScale >= smallScale && !isEnlarge) { isEnlarge = !isEnlarge; } // 缩放因子 缩放后 / 缩放前 // eg 放大后=10,放大前=5, 缩放因子 == 10 / 5 == 2 currentScale = initScale * detector.getScaleFactor(); invalidate(); return false; } // 开始缩放 @Override public boolean onScaleBegin(ScaleGestureDetector detector) { initScale = currentScale; return true; } //结束缩放 @Override public void onScaleEnd(ScaleGestureDetector detector) { } }
同理,ScaleGestureDetector 的触发也需要在 photoView 里的 onTouchEvent 里强制接管,所以修改 onTouchEvnet() 里的代码如下:
/** TODO 我们点击图片时,触发的是 PhotoView 里的 onTouchEvent, * TODO 并没有触发 GestureDetector 里的onTouchEvent, 所以才需要强制接管 * TODO 同理,ScaleGestureDetector 也需要接管 */ @Override public boolean onTouchEvent(MotionEvent event) { // TODO 响应事件以双指缩放优先 boolean result = scaleGestureDetector.onTouchEvent(event); if(!scaleGestureDetector.isInProgress()){ // TODO 不是双指缩放,则用 GestureDetector 的 onTouchEvent 强制接管 result = gestureDetector.onTouchEvent(event); } return result; //return super.onTouchEvent(event); }
4、完整DEMO
完整的 photoView 代码(MainActivity里没写什么)
package com.example.photoview; import android.animation.ObjectAnimator; import android.content.Context; import android.graphics.Bitmap; 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; public class PhotoView extends View { private static final float IMAGE_WIDTH = Utils.dpToPixel(300); private Bitmap bitmap; private Paint paint; float originalOffsetX; float originalOffsetY; private float smallScale; private float bigScale; private float currentScale; //当前缩放值 private float OVER_SCALE_FACTOR = 1.5f; private boolean isEnlarge = false; //双击时放大/缩小的标志位 private ObjectAnimator scaleAnimator; // 双击放大/缩小时,通过属性动画做出平滑的效果 private GestureDetector gestureDetector; // android 提高的手势探测器,TODO 判断是单价还是双击 private ScaleGestureDetector scaleGestureDetector; // TODO 实现双指缩放 private float offsetX; // 图片放大后,手指滑动图片查看隐藏部分 private float offsetY; private OverScroller overScroller; // TODO 实现惯性滑动 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); } private void init(Context context){ bitmap = Utils.getPhoto(getResources(), (int) IMAGE_WIDTH); paint = new Paint(Paint.ANTI_ALIAS_FLAG); gestureDetector = new GestureDetector(context, new photoGestureListener()); scaleGestureDetector = new ScaleGestureDetector(context, new photoScaleGestureListener()); //&nb