Android Image View Pinch Zooming

AndroidAndroid LayoutAndroid IntentAndroid Widget

Android Problem Overview


I am using code sample from [Making Sense of Multitouch][1] [1]: http://android-developers.blogspot.in/2010/06/making-sense-of-multitouch.html for zooming image view. On ScaleListener I added ScaleGestureDetector.getFocusX() and getFocusY()for content to zoom about the focal point of the gesture. It is working fine.

The problem is, on first multitouch the entire Image drawing position is changing to the current touch point and zooming it from there. Could you help me to resolve this issue?

Here is My Code Sample For TouchImageView.

public class TouchImageViewSample extends ImageView {

private Paint borderPaint = null;
private Paint backgroundPaint = null;

private float mPosX = 0f;
private float mPosY = 0f;

private float mLastTouchX;
private float mLastTouchY;
private static final int INVALID_POINTER_ID = -1;
private static final String LOG_TAG = "TouchImageView";

// The ‘active pointer’ is the one currently moving our object.
private int mActivePointerId = INVALID_POINTER_ID;

public TouchImageViewSample(Context context) {
	this(context, null, 0);
}

public TouchImageViewSample(Context context, AttributeSet attrs) {
	this(context, attrs, 0);
}

private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1.f;

// Existing code ...
public TouchImageViewSample(Context context, AttributeSet attrs, int defStyle) {
	super(context, attrs, defStyle);
	// Create our ScaleGestureDetector
	mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());

	borderPaint = new Paint();
	borderPaint.setARGB(255, 255, 128, 0);
	borderPaint.setStyle(Paint.Style.STROKE);
	borderPaint.setStrokeWidth(4);

	backgroundPaint = new Paint();
	backgroundPaint.setARGB(32, 255, 255, 255);
	backgroundPaint.setStyle(Paint.Style.FILL);

}

@Override
public boolean onTouchEvent(MotionEvent ev) {
	// Let the ScaleGestureDetector inspect all events.
	mScaleDetector.onTouchEvent(ev);

	final int action = ev.getAction();
	switch (action & MotionEvent.ACTION_MASK) {
	case MotionEvent.ACTION_DOWN: {
		final float x = ev.getX();
		final float y = ev.getY();

		mLastTouchX = x;
		mLastTouchY = y;

		mActivePointerId = ev.getPointerId(0);
		break;
	}

	case MotionEvent.ACTION_MOVE: {
		final int pointerIndex = ev.findPointerIndex(mActivePointerId);
		final float x = ev.getX(pointerIndex);
		final float y = ev.getY(pointerIndex);

		// Only move if the ScaleGestureDetector isn't processing a gesture.
		if (!mScaleDetector.isInProgress()) {
			final float dx = x - mLastTouchX;
			final float dy = y - mLastTouchY;

			mPosX += dx;
			mPosY += dy;

			invalidate();
		}

		mLastTouchX = x;
		mLastTouchY = y;
		break;
	}

	case MotionEvent.ACTION_UP: {
		mActivePointerId = INVALID_POINTER_ID;
		break;
	}

	case MotionEvent.ACTION_CANCEL: {
		mActivePointerId = INVALID_POINTER_ID;
		break;
	}

	case MotionEvent.ACTION_POINTER_UP: {
		final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
		final int pointerId = ev.getPointerId(pointerIndex);
		if (pointerId == mActivePointerId) {
			// This was our active pointer going up. Choose a new
			// active pointer and adjust accordingly.
			final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
			mLastTouchX = ev.getX(newPointerIndex);
			mLastTouchY = ev.getY(newPointerIndex);
			mActivePointerId = ev.getPointerId(newPointerIndex);
		}
		break;
	}
	}

	return true;
}

/*
 * (non-Javadoc)
 * 
 * @see android.view.View#draw(android.graphics.Canvas)
 */
@Override
public void draw(Canvas canvas) {
	super.draw(canvas);
	canvas.drawRect(0, 0, getWidth() - 1, getHeight() - 1, borderPaint);
}

@Override
public void onDraw(Canvas canvas) {
	canvas.drawRect(0, 0, getWidth() - 1, getHeight() - 1, backgroundPaint);
	if (this.getDrawable() != null) {
		canvas.save();
		canvas.translate(mPosX, mPosY);

		Matrix matrix = new Matrix();
		matrix.postScale(mScaleFactor, mScaleFactor, pivotPointX,
				pivotPointY);
		// canvas.setMatrix(matrix);

		canvas.drawBitmap(
				((BitmapDrawable) this.getDrawable()).getBitmap(), matrix,
				null);

		// this.getDrawable().draw(canvas);
		canvas.restore();
	}
}

/*
 * (non-Javadoc)
 * 
 * @see
 * android.widget.ImageView#setImageDrawable(android.graphics.drawable.Drawable
 * )
 */
@Override
public void setImageDrawable(Drawable drawable) {
	// Constrain to given size but keep aspect ratio
	int width = drawable.getIntrinsicWidth();
	int height = drawable.getIntrinsicHeight();
	mLastTouchX = mPosX = 0;
	mLastTouchY = mPosY = 0;

	int borderWidth = (int) borderPaint.getStrokeWidth();
	mScaleFactor = Math.min(((float) getLayoutParams().width - borderWidth)
			/ width, ((float) getLayoutParams().height - borderWidth)
			/ height);
	pivotPointX = (((float) getLayoutParams().width - borderWidth) - (int) (width * mScaleFactor)) / 2;
	pivotPointY = (((float) getLayoutParams().height - borderWidth) - (int) (height * mScaleFactor)) / 2;
	super.setImageDrawable(drawable);
}

float pivotPointX = 0f;
float pivotPointY = 0f;

private class ScaleListener extends
		ScaleGestureDetector.SimpleOnScaleGestureListener {

	@Override
	public boolean onScale(ScaleGestureDetector detector) {
		mScaleFactor *= detector.getScaleFactor();

		pivotPointX = detector.getFocusX();
		pivotPointY = detector.getFocusY();
		
		Log.d(LOG_TAG, "mScaleFactor " + mScaleFactor);
		Log.d(LOG_TAG, "pivotPointY " + pivotPointY + ", pivotPointX= "
				+ pivotPointX);
		mScaleFactor = Math.max(0.05f, mScaleFactor);

		invalidate();
		return true;
	}
}

And here how I used it within my activity.

ImageView imageView = (ImageView) findViewById(R.id.imgView);

int hMargin = (int) (displayMetrics.widthPixels * .10);
int vMargin = (int) (displayMetrics.heightPixels * .10);

RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(displayMetrics.widthPixels - (hMargin * 2), (int)(displayMetrics.heightPixels - btnCamera.getHeight()) - (vMargin * 2));
params.leftMargin = hMargin;
params.topMargin =  vMargin;
imageView.setLayoutParams(params);
imageView.setImageDrawable(drawable);

Android Solutions


Solution 1 - Android

You can use this class : TouchImageView

Solution 2 - Android

##Using a ScaleGestureDetector

When learning a new concept I don't like using libraries or code dumps. I found a good description here and in the documentation of how to resize an image by pinching. This answer is a slightly modified summary. You will probably want to add more functionality later, but it will help you get started.

https://i.stack.imgur.com/RfPGy.gif" width="200" alt="Animated gif: Scale image example">

##Layout

The ImageView just uses the app logo since it is already available. You can replace it with any image you like, though.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@mipmap/ic_launcher"
        android:layout_centerInParent="true"/>

</RelativeLayout>

##Activity

We use a ScaleGestureDetector on the activity to listen to touch events. When a scale (ie, pinch) gesture is detected, then the scale factor is used to resize the ImageView.

public class MainActivity extends AppCompatActivity {

    private ScaleGestureDetector mScaleGestureDetector;
    private float mScaleFactor = 1.0f;
    private ImageView mImageView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        // initialize the view and the gesture detector
        mImageView = findViewById(R.id.imageView);
        mScaleGestureDetector = new ScaleGestureDetector(this, new ScaleListener());
    }

    // this redirects all touch events in the activity to the gesture detector
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return mScaleGestureDetector.onTouchEvent(event);
    }

    private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {

        // when a scale gesture is detected, use it to resize the image
        @Override
        public boolean onScale(ScaleGestureDetector scaleGestureDetector){
            mScaleFactor *= scaleGestureDetector.getScaleFactor();
            mImageView.setScaleX(mScaleFactor);
            mImageView.setScaleY(mScaleFactor);
            return true;
        }
    }
}

##Notes

  • Although the activity had the gesture detector in the example above, it could have also been set on the image view itself.

  • You can limit the size of the scaling with something like

      mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));
    
  • Thanks again to Pinch-to-zoom with multi-touch gestures In Android

  • Documentation

  • Use Ctrl + mouse drag to simulate a pinch gesture in the emulator.

Going on

You will probably want to do other things like panning and scaling to some focus point. You can develop these things yourself, but if you would like to use a pre-made custom view, copy TouchImageView.java into your project and use it like a normal ImageView. It worked well for me and I only ran into one bug. I plan to further edit the code to remove the warning and the parts that I don't need. You can do the same.

Solution 3 - Android

I made my own custom imageview with pinch to zoom. There is no limits/borders on Chirag Ravals code, so user can drag the image off the screen. This will fix it.

Here is the CustomImageView class:

    public class CustomImageVIew extends ImageView implements OnTouchListener {

   
	private Matrix matrix = new Matrix();
	private Matrix savedMatrix = new Matrix();

    static final int NONE = 0;
    static final int DRAG = 1;
    static final int ZOOM = 2;

    private int mode = NONE;

    private PointF mStartPoint = new PointF();
    private PointF mMiddlePoint = new PointF();
    private Point mBitmapMiddlePoint = new Point();
    
    private float oldDist = 1f;
    private float matrixValues[] = {0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f};
    private float scale;
    private float oldEventX = 0;
    private float oldEventY = 0;
    private float oldStartPointX = 0;
    private float oldStartPointY = 0;
    private int mViewWidth = -1;
    private int mViewHeight = -1;
    private int mBitmapWidth = -1;
    private int mBitmapHeight = -1;
    private boolean mDraggable = false;
    
   
    public CustomImageVIew(Context context) {
        this(context, null, 0);
    }
   
    public CustomImageVIew(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
   
    public CustomImageVIew(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        this.setOnTouchListener(this);
    }
    
    @Override
    public void onSizeChanged (int w, int h, int oldw, int oldh){
        super.onSizeChanged(w, h, oldw, oldh);
        mViewWidth = w;
        mViewHeight = h;
    }

    public void setBitmap(Bitmap bitmap){
    	if(bitmap != null){
    		setImageBitmap(bitmap);
    		
    		mBitmapWidth = bitmap.getWidth();
    		mBitmapHeight = bitmap.getHeight();
    		mBitmapMiddlePoint.x = (mViewWidth / 2) - (mBitmapWidth /  2);
    		mBitmapMiddlePoint.y = (mViewHeight / 2) - (mBitmapHeight / 2);
            
            matrix.postTranslate(mBitmapMiddlePoint.x, mBitmapMiddlePoint.y);
            this.setImageMatrix(matrix);
    	}
    }
   
    @Override
	public boolean onTouch(View v, MotionEvent event){
        switch (event.getAction() & MotionEvent.ACTION_MASK) {
        case MotionEvent.ACTION_DOWN:
            savedMatrix.set(matrix);
            mStartPoint.set(event.getX(), event.getY());
            mode = DRAG;
            break;
        case MotionEvent.ACTION_POINTER_DOWN:
            oldDist = spacing(event);
            if(oldDist > 10f){
                savedMatrix.set(matrix);
                midPoint(mMiddlePoint, event);
                mode = ZOOM;
            }
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_POINTER_UP:
            mode = NONE;
            break;
        case MotionEvent.ACTION_MOVE:
        	if(mode == DRAG){
                drag(event);
            } else if(mode == ZOOM){
            	zoom(event);
            } 
            break;
        }

        return true;
    }
    
    
    
   public void drag(MotionEvent event){
       matrix.getValues(matrixValues);

	   float left = matrixValues[2];
	   float top = matrixValues[5];
	   float bottom = (top + (matrixValues[0] * mBitmapHeight)) - mViewHeight;
	   float right = (left + (matrixValues[0] * mBitmapWidth)) -mViewWidth;
	   
	   float eventX = event.getX();
	   float eventY = event.getY();
	   float spacingX = eventX - mStartPoint.x;
	   float spacingY = eventY - mStartPoint.y;
	   float newPositionLeft = (left  < 0 ? spacingX : spacingX * -1) + left;
	   float newPositionRight = (spacingX) + right;
	   float newPositionTop = (top  < 0 ? spacingY : spacingY * -1) + top;
	   float newPositionBottom = (spacingY) + bottom;
       boolean x = true;
       boolean y = true;

	   if(newPositionRight < 0.0f || newPositionLeft > 0.0f){
		   if(newPositionRight < 0.0f && newPositionLeft > 0.0f){
			   x = false;
		   } else{
			   eventX = oldEventX;
		       mStartPoint.x = oldStartPointX;
		   }
       }
	   if(newPositionBottom < 0.0f || newPositionTop > 0.0f){
		   if(newPositionBottom < 0.0f && newPositionTop > 0.0f){
			   y = false;
		   } else{
			   eventY = oldEventY;
		       mStartPoint.y = oldStartPointY;
		   }
	   }

	   if(mDraggable){
		   matrix.set(savedMatrix);
	       matrix.postTranslate(x? eventX - mStartPoint.x : 0, y? eventY - mStartPoint.y : 0);
	       this.setImageMatrix(matrix);
	       if(x)oldEventX = eventX;
	       if(y)oldEventY = eventY;
	       if(x)oldStartPointX = mStartPoint.x;
	       if(y)oldStartPointY = mStartPoint.y;
	   }

   }

   public void zoom(MotionEvent event){
   	   matrix.getValues(matrixValues);

	   float newDist = spacing(event);
	   float bitmapWidth = matrixValues[0] * mBitmapWidth;
	   float bimtapHeight = matrixValues[0] * mBitmapHeight;
	   boolean in = newDist > oldDist;
	   
	   if(!in && matrixValues[0] < 1){
		   return;
	   }
       if(bitmapWidth > mViewWidth || bimtapHeight > mViewHeight){
    	   mDraggable = true;
       } else{
    	   mDraggable = false;
       }
       
	   float midX = (mViewWidth / 2);
	   float midY = (mViewHeight / 2);
	   
       matrix.set(savedMatrix);
       scale = newDist / oldDist;
       matrix.postScale(scale, scale, bitmapWidth > mViewWidth ? mMiddlePoint.x : midX, bimtapHeight > mViewHeight ? mMiddlePoint.y : midY); 
       
       this.setImageMatrix(matrix);


   }

 
    
    

    /** Determine the space between the first two fingers */
    private float spacing(MotionEvent event) {
        float x = event.getX(0) - event.getX(1);
        float y = event.getY(0) - event.getY(1);
        
        return (float)Math.sqrt(x * x + y * y);
    }

    /** Calculate the mid point of the first two fingers */
    private void midPoint(PointF point, MotionEvent event) {
        float x = event.getX(0) + event.getX(1);
        float y = event.getY(0) + event.getY(1);
        point.set(x / 2, y / 2);
    }


}

This is how you can use it in your activity:

CustomImageVIew mImageView = (CustomImageVIew)findViewById(R.id.customImageVIew1);
mImage.setBitmap(your bitmap);

And layout:

<your.package.name.CustomImageVIew
        android:id="@+id/customImageVIew1"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_marginBottom="15dp"
        android:layout_marginLeft="15dp"
        android:layout_marginRight="15dp"
        android:layout_marginTop="15dp"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true" 
        android:scaleType="matrix"/> // important

Solution 4 - Android

Add bellow line in build.gradle:

compile 'com.commit451:PhotoView:1.2.4'

or

compile 'com.github.chrisbanes:PhotoView:1.3.0'

In Java file:

PhotoViewAttacher photoAttacher;
photoAttacher= new PhotoViewAttacher(Your_Image_View);
photoAttacher.update();

Solution 5 - Android

In the TouchImageViewSample class scaling happens around the pivot point. Pixel belong to the pivot point doesn't get effected by scaling of the image. When you are changing the pivot point, the view get redrawn and scaling happens around the new pivot point. This changes the location of previous pivot point and you see this as the image get shifted every time you touching down on the image. You have to compensate this shifting error by translating the image. See how this is done in my ZoomGestureDetector.updatePivotPoint() method.

ZoomGestureDetector

I created custom zoom gesture detector class. It can do scaling, translation and rotation at the same time. It also supports fling animation.

https://i.stack.imgur.com/83Zcr.gif" width="400" height="400">

import android.graphics.Canvas
import android.graphics.Matrix
import android.view.MotionEvent
import android.view.VelocityTracker
import androidx.core.math.MathUtils
import androidx.dynamicanimation.animation.FlingAnimation
import androidx.dynamicanimation.animation.FloatValueHolder
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2

class ZoomGestureDetector(private val listener: Listener) {

    companion object {
        const val MIN_SCALE = 0.01f
        const val MAX_SCALE = 100f
        const val MIN_FLING_VELOCITY = 50f
        const val MAX_FLING_VELOCITY = 8000f
    }

    // public
    var isZoomEnabled: Boolean = true
    var isScaleEnabled: Boolean = true
    var isRotationEnabled: Boolean = true
    var isTranslationEnabled: Boolean = true
    var isFlingEnabled: Boolean = true

    // local
    private val mDrawMatrix: Matrix = Matrix()
    private val mTouchMatrix: Matrix = Matrix()
    private val mPointerMap: HashMap<Int, Position> = HashMap()
    private val mTouchPoint: FloatArray = floatArrayOf(0f, 0f)
    private val mPivotPoint: FloatArray = floatArrayOf(0f, 0f)

    // transformations
    private var mTranslationX: Float = 0f
    private var mTranslationY: Float = 0f
    private var mScaling: Float = 1f
    private var mPivotX: Float = 0f
    private var mPivotY: Float = 0f
    private var mRotation: Float = 0f

    // previous values
    private var mPreviousFocusX: Float = 0f
    private var mPreviousFocusY: Float = 0f
    private var mPreviousTouchSpan: Float = 1f

    // fling related
    private var mVelocityTracker: VelocityTracker? = null
    private var mFlingAnimX: FlingAnimation? = null
    private var mFlingAnimY: FlingAnimation? = null

    fun updateTouchLocation(event: MotionEvent) {
        mTouchPoint[0] = event.x
        mTouchPoint[1] = event.y
        mTouchMatrix.mapPoints(mTouchPoint)
        event.setLocation(mTouchPoint[0], mTouchPoint[1])
    }

    fun updateCanvasMatrix(canvas: Canvas) {
        canvas.setMatrix(mDrawMatrix)
    }

    fun onTouchEvent(event: MotionEvent): Boolean {
        if (isZoomEnabled) {
            // update velocity tracker
            if (isFlingEnabled) {
                if (mVelocityTracker == null) {
                    mVelocityTracker = VelocityTracker.obtain()
                }
                mVelocityTracker?.addMovement(event)
            }
            // handle touch events
            when (event.actionMasked) {
                MotionEvent.ACTION_DOWN -> {
                    // update focus point
                    mPreviousFocusX = event.x
                    mPreviousFocusY = event.y
                    event.savePointers()
                    // cancel ongoing fling animations
                    if (isFlingEnabled) {
                        mFlingAnimX?.cancel()
                        mFlingAnimY?.cancel()
                    }
                }
                MotionEvent.ACTION_POINTER_DOWN -> {
                    updateTouchParameters(event)
                }
                MotionEvent.ACTION_POINTER_UP -> {
                    // Check the dot product of current velocities.
                    // If the pointer that left was opposing another velocity vector, clear.
                    if (isFlingEnabled) {
                        mVelocityTracker?.let { tracker ->
                            tracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY)
                            val upIndex: Int = event.actionIndex
                            val id1: Int = event.getPointerId(upIndex)
                            val x1 = tracker.getXVelocity(id1)
                            val y1 = tracker.getYVelocity(id1)
                            for (i in 0 until event.pointerCount) {
                                if (i == upIndex) continue
                                val id2: Int = event.getPointerId(i)
                                val x = x1 * tracker.getXVelocity(id2)
                                val y = y1 * tracker.getYVelocity(id2)
                                val dot = x + y
                                if (dot < 0) {
                                    tracker.clear()
                                    break
                                }
                            }
                        }
                    }
                    updateTouchParameters(event)
                }
                MotionEvent.ACTION_UP -> {
                    // do fling animation
                    if (isFlingEnabled) {
                        mVelocityTracker?.let { tracker ->
                            val pointerId: Int = event.getPointerId(0)
                            tracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY)
                            val velocityY: Float = tracker.getYVelocity(pointerId)
                            val velocityX: Float = tracker.getXVelocity(pointerId)
                            if (abs(velocityY) > MIN_FLING_VELOCITY || abs(velocityX) > MIN_FLING_VELOCITY) {
                                val translateX = mTranslationX
                                val translateY = mTranslationY
                                val valueHolder = FloatValueHolder()
                                mFlingAnimX = FlingAnimation(valueHolder).apply {
                                    setStartVelocity(velocityX)
                                    setStartValue(0f)
                                    addUpdateListener { _, value, _ ->
                                        mTranslationX = translateX + value
                                        updateDrawMatrix()
                                        listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
                                    }
                                    addEndListener { _, _, _, _ ->
                                        updateTouchMatrix()
                                    }
                                    start()
                                }
                                mFlingAnimY = FlingAnimation(valueHolder).apply {
                                    setStartVelocity(velocityY)
                                    setStartValue(0f)
                                    addUpdateListener { _, value, _ ->
                                        mTranslationY = translateY + value
                                        updateDrawMatrix()
                                        listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
                                    }
                                    addEndListener { _, _, _, _ ->
                                        updateTouchMatrix()
                                    }
                                    start()
                                }
                            }
                            tracker.recycle()
                            mVelocityTracker = null
                        }
                    }
                }
                MotionEvent.ACTION_MOVE -> {
                    val (focusX, focusY) = event.focalPoint()
                    if (event.pointerCount > 1) {
                        if (isScaleEnabled) {
                            val touchSpan = event.touchSpan(focusX, focusY)
                            mScaling *= scaling(touchSpan)
                            mScaling = MathUtils.clamp(mScaling, MIN_SCALE, MAX_SCALE)
                            mPreviousTouchSpan = touchSpan
                        }
                        if (isRotationEnabled) {
                            mRotation += event.rotation(focusX, focusY)
                        }
                        if (isTranslationEnabled) {
                            val (translationX, translationY) = translation(focusX, focusY)
                            mTranslationX += translationX
                            mTranslationY += translationY
                        }
                    } else {
                        if (isTranslationEnabled) {
                            val (translationX, translationY) = translation(focusX, focusY)
                            mTranslationX += translationX
                            mTranslationY += translationY
                        }
                    }
                    mPreviousFocusX = focusX
                    mPreviousFocusY = focusY
                    updateTouchMatrix()
                    updateDrawMatrix()
                    event.savePointers()
                    listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
                }
            }
            return true
        }
        return false
    }

    // update focus point, touch span and pivot point
    private fun updateTouchParameters(event: MotionEvent) {
        val (focusX, focusY) = event.focalPoint()
        mPreviousFocusX = focusX
        mPreviousFocusY = focusY
        mPreviousTouchSpan = event.touchSpan(focusX, focusY)
        updatePivotPoint(focusX, focusY)
        updateTouchMatrix()
        updateDrawMatrix()
        event.savePointers()
        listener.onZoom(mScaling, mRotation, mTranslationX to mTranslationY, mPivotX to mPivotY)
    }

    // touch matrix is used to transform touch points
    // on the child view and to find pivot point
    private fun updateTouchMatrix() {
        mTouchMatrix.reset()
        mTouchMatrix.preTranslate(-mTranslationX, -mTranslationY)
        mTouchMatrix.postRotate(-mRotation, mPivotX, mPivotY)
        mTouchMatrix.postScale(1f / mScaling, 1f / mScaling, mPivotX, mPivotY)
    }

    // draw matrix is used to transform child view when drawing on the canvas
    private fun updateDrawMatrix() {
        mDrawMatrix.reset()
        mDrawMatrix.preScale(mScaling, mScaling, mPivotX, mPivotY)
        mDrawMatrix.preRotate(mRotation, mPivotX, mPivotY)
        mDrawMatrix.postTranslate(mTranslationX, mTranslationY)
    }

    // this updates the pivot point and translation error caused by changing the pivot point
    private fun updatePivotPoint(focusX: Float, focusY: Float) {
        // update point
        mPivotPoint[0] = focusX
        mPivotPoint[1] = focusY
        mTouchMatrix.mapPoints(mPivotPoint)
        mPivotX = mPivotPoint[0]
        mPivotY = mPivotPoint[1]
        // correct pivot error
        mDrawMatrix.mapPoints(mPivotPoint)
        mTranslationX -= mTranslationX + mPivotX - mPivotPoint[0]
        mTranslationY -= mTranslationY + mPivotY - mPivotPoint[1]
    }

    private fun MotionEvent.focalPoint(): Pair<Float, Float> {
        val upIndex = if (actionMasked == MotionEvent.ACTION_POINTER_UP) actionIndex else -1
        var sumX = 0f
        var sumY = 0f
        var sumCount = 0
        for (pointerIndex in 0 until pointerCount) {
            if (pointerIndex == upIndex) continue
            sumX += getX(pointerIndex)
            sumY += getY(pointerIndex)
            sumCount++
        }
        val focusX = sumX / sumCount
        val focusY = sumY / sumCount
        return focusX to focusY
    }

    private fun MotionEvent.touchSpan(
        currentFocusX: Float,
        currentFocusY: Float
    ): Float {
        var spanSumX = 0f
        var spanSumY = 0f
        var sumCount = 0
        val ignoreIndex = if (actionMasked == MotionEvent.ACTION_POINTER_UP) actionIndex else -1
        for (pointerIndex in 0 until pointerCount) {
            if (pointerIndex == ignoreIndex) continue
            spanSumX += abs(currentFocusX - getX(pointerIndex))
            spanSumY += abs(currentFocusY - getY(pointerIndex))
            sumCount++
        }
        if (sumCount > 1) {
            val spanX = spanSumX / sumCount
            val spanY = spanSumY / sumCount
            return spanX + spanY
        }
        return mPreviousTouchSpan
    }

    private fun scaling(currentTouchSpan: Float): Float {
        return currentTouchSpan / mPreviousTouchSpan
    }

    private fun MotionEvent.rotation(
        currentFocusX: Float,
        currentFocusY: Float
    ): Float {
        var rotationSum = 0f
        var weightSum = 0f
        for (pointerIndex in 0 until pointerCount) {
            val pointerId = getPointerId(pointerIndex)
            val x1 = getX(pointerIndex)
            val y1 = getY(pointerIndex)
            val (x2, y2) = mPointerMap[pointerId] ?: continue
            val dx1 = x1 - currentFocusX
            val dy1 = y1 - currentFocusY
            val dx2 = x2 - currentFocusX
            val dy2 = y2 - currentFocusY
            // dot product is proportional to the cosine of the angle
            // the determinant is proportional to its sine
            // sign of the rotation tells if it is clockwise or counter-clockwise
            val dot = dx1 * dx2 + dy1 * dy2
            val det = dy1 * dx2 - dx1 * dy2
            val rotation = atan2(det, dot)
            val weight = abs(dx1) + abs(dy1)
            rotationSum += rotation * weight
            weightSum += weight
        }
        if (weightSum > 0f) {
            val rotation = rotationSum / weightSum
            return rotation * 180f / PI.toFloat()
        }
        return 0f
    }

    private fun translation(
        currentFocusX: Float,
        currentFocusY: Float
    ): Pair<Float, Float> {
        return (currentFocusX - mPreviousFocusX) to (currentFocusY - mPreviousFocusY)
    }

    private fun MotionEvent.savePointers() {
        mPointerMap.clear()
        for (pointerIndex in 0 until pointerCount) {
            val id = getPointerId(pointerIndex)
            val x = getX(pointerIndex)
            val y = getY(pointerIndex)
            mPointerMap[id] = x to y
        }
    }

    interface Listener {
        fun onZoom(scaling: Float, rotation: Float, translation: Position, pivot: Position)
    }

}

typealias Position = Pair<Float, Float>

I used ZoomGestureDetector in a FrameLayout as below.

import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout

class ZoomLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
    ZoomGestureDetector.Listener {

    private val gestureDetector = ZoomGestureDetector(this)

    var isZoomEnabled
        get() = gestureDetector.isZoomEnabled
        set(value) {
            gestureDetector.isZoomEnabled = value
        }

    var isScaleEnabled
        get() = gestureDetector.isScaleEnabled
        set(value) {
            gestureDetector.isScaleEnabled = value
        }

    var isRotationEnabled
        get() = gestureDetector.isRotationEnabled
        set(value) {
            gestureDetector.isRotationEnabled = value
        }

    var isTranslationEnabled
        get() = gestureDetector.isTranslationEnabled
        set(value) {
            gestureDetector.isTranslationEnabled = value
        }

    var isFlingEnabled
        get() = gestureDetector.isFlingEnabled
        set(value) {
            gestureDetector.isFlingEnabled = value
        }

    override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
        if (isZoomEnabled) return true
        gestureDetector.updateTouchLocation(event)
        return super.onInterceptTouchEvent(event)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return gestureDetector.onTouchEvent(event)
    }

    override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
        gestureDetector.updateCanvasMatrix(canvas)
        return super.drawChild(canvas, child, drawingTime)
    }

    override fun onZoom(scaling: Float, rotation: Float, translation: Position, pivot: Position) {
        invalidate()
    }

}

Solution 6 - Android

Custom zoom view in Kotlin

 import android.content.Context
 import android.graphics.Matrix
 import android.graphics.PointF
 import android.util.AttributeSet
 import android.util.Log
 import android.view.MotionEvent
 import android.view.ScaleGestureDetector
 import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener
 import androidx.appcompat.widget.AppCompatImageView

 class ZoomImageview : AppCompatImageView {
var matri: Matrix? = null
var mode = NONE

// Remember some things for zooming
var last = PointF()
var start = PointF()
var minScale = 1f
var maxScale = 3f
lateinit var m: FloatArray
var viewWidth = 0
var viewHeight = 0
var saveScale = 1f
protected var origWidth = 0f
protected var origHeight = 0f
var oldMeasuredWidth = 0
var oldMeasuredHeight = 0
var mScaleDetector: ScaleGestureDetector? = null
var contex: Context? = null

constructor(context: Context) : super(context) {
    sharedConstructing(context)
}

constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
    sharedConstructing(context)
}

private fun sharedConstructing(context: Context) {
    super.setClickable(true)
    this.contex= context
    mScaleDetector = ScaleGestureDetector(context, ScaleListener())
    matri = Matrix()
    m = FloatArray(9)
    imageMatrix = matri
    scaleType = ScaleType.MATRIX
    setOnTouchListener { v, event ->
        mScaleDetector!!.onTouchEvent(event)
        val curr = PointF(event.x, event.y)
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                last.set(curr)
                start.set(last)
                mode = DRAG
            }
            MotionEvent.ACTION_MOVE -> if (mode == DRAG) {
                val deltaX = curr.x - last.x
                val deltaY = curr.y - last.y
                val fixTransX = getFixDragTrans(deltaX, viewWidth.toFloat(), origWidth * saveScale)
                val fixTransY = getFixDragTrans(deltaY, viewHeight.toFloat(), origHeight * saveScale)
                matri!!.postTranslate(fixTransX, fixTransY)
                fixTrans()
                last[curr.x] = curr.y
            }
            MotionEvent.ACTION_UP -> {
                mode = NONE
                val xDiff = Math.abs(curr.x - start.x).toInt()
                val yDiff = Math.abs(curr.y - start.y).toInt()
                if (xDiff < CLICK && yDiff < CLICK) performClick()
            }
            MotionEvent.ACTION_POINTER_UP -> mode = NONE
        }
        imageMatrix = matri
        invalidate()
        true // indicate event was handled
    }
}

fun setMaxZoom(x: Float) {
    maxScale = x
}

private inner class ScaleListener : SimpleOnScaleGestureListener() {
    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
        mode = ZOOM
        return true
    }

    override fun onScale(detector: ScaleGestureDetector): Boolean {
        var mScaleFactor = detector.scaleFactor
        val origScale = saveScale
        saveScale *= mScaleFactor
        if (saveScale > maxScale) {
            saveScale = maxScale
            mScaleFactor = maxScale / origScale
        } else if (saveScale < minScale) {
            saveScale = minScale
            mScaleFactor = minScale / origScale
        }
        if (origWidth * saveScale <= viewWidth || origHeight * saveScale <= viewHeight) matri!!.postScale(mScaleFactor, mScaleFactor, viewWidth / 2.toFloat(), viewHeight / 2.toFloat()) else matri!!.postScale(mScaleFactor, mScaleFactor, detector.focusX, detector.focusY)
        fixTrans()
        return true
    }
}

fun fixTrans() {
    matri!!.getValues(m)
    val transX = m[Matrix.MTRANS_X]
    val transY = m[Matrix.MTRANS_Y]
    val fixTransX = getFixTrans(transX, viewWidth.toFloat(), origWidth * saveScale)
    val fixTransY = getFixTrans(transY, viewHeight.toFloat(), origHeight * saveScale)
    if (fixTransX != 0f || fixTransY != 0f) matri!!.postTranslate(fixTransX, fixTransY)
}

fun getFixTrans(trans: Float, viewSize: Float, contentSize: Float): Float {
    val minTrans: Float
    val maxTrans: Float
    if (contentSize <= viewSize) {
        minTrans = 0f
        maxTrans = viewSize - contentSize
    } else {
        minTrans = viewSize - contentSize
        maxTrans = 0f
    }
    if (trans < minTrans) return -trans + minTrans
    if (trans > maxTrans) return -trans + maxTrans
    return 0f
}

fun getFixDragTrans(delta: Float, viewSize: Float, contentSize: Float): Float {
    if (contentSize <= viewSize) {
        return 0f
    } else {
        return delta
    }
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    viewWidth = MeasureSpec.getSize(widthMeasureSpec)
    viewHeight = MeasureSpec.getSize(heightMeasureSpec)
    //
    // Rescales image on rotation
    //
    if (oldMeasuredHeight == viewWidth && oldMeasuredHeight == viewHeight || viewWidth == 0 || viewHeight == 0) return
    oldMeasuredHeight = viewHeight
    oldMeasuredWidth = viewWidth
    if (saveScale == 1f) {
        //Fit to screen.
        val scale: Float
        val drawable = drawable
        if (drawable == null || drawable.intrinsicWidth == 0 || drawable.intrinsicHeight == 0) return
        val bmWidth = drawable.intrinsicWidth
        val bmHeight = drawable.intrinsicHeight
        Log.d("bmSize", "bmWidth: $bmWidth bmHeight : $bmHeight")
        val scaleX = viewWidth.toFloat() / bmWidth.toFloat()
        val scaleY = viewHeight.toFloat() / bmHeight.toFloat()
        scale = Math.min(scaleX, scaleY)
        matri!!.setScale(scale, scale)
        // Center the image
        var redundantYSpace = viewHeight.toFloat() - scale * bmHeight.toFloat()
        var redundantXSpace = viewWidth.toFloat() - scale * bmWidth.toFloat()
        redundantYSpace /= 2.toFloat()
        redundantXSpace /= 2.toFloat()
        matri!!.postTranslate(redundantXSpace, redundantYSpace)
        origWidth = viewWidth - 2 * redundantXSpace
        origHeight = viewHeight - 2 * redundantYSpace
        imageMatrix = matri
    }
    fixTrans()
}

companion object {
    // We can be in one of these 3 states
    const val NONE = 0
    const val DRAG = 1
    const val ZOOM = 2
    const val CLICK = 3
}
 }

Solution 7 - Android

I made code for imageview with pinch to zoom using zoomageview. so user can drag the image off the screen and zoom-In , zoom-out the image.

You can follow this link to get the Step By Step Code and also given Output Screenshot.

https://stackoverflow.com/a/58074642/11613683

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionMobDevView Question on Stackoverflow
Solution 1 - AndroidAnjulaView Answer on Stackoverflow
Solution 2 - AndroidSuragchView Answer on Stackoverflow
Solution 3 - Androiduser1888162View Answer on Stackoverflow
Solution 4 - AndroidAleem MominView Answer on Stackoverflow
Solution 5 - AndroidUdaraWanasingheView Answer on Stackoverflow
Solution 6 - AndroidSenthilView Answer on Stackoverflow
Solution 7 - AndroidPramesh BhalalaView Answer on Stackoverflow