為了方便代碼的閱讀,我將類都寫成了內部類,下面的代碼拿了直接可以使用,換一下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); } }