75

I am using code sample from Making Sense of Multitouch 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);
ROMANIA_engineer
  • 54,432
  • 29
  • 203
  • 199
MobDev
  • 1,489
  • 1
  • 17
  • 26
  • you means when you try for pinch zoom your image is also change position. – Herry May 17 '12 at 07:34
  • 1
    Not exactly, In `ScaleListener.onScale()` I am setting pivotPointX, and Y and they are used as focal point in `onDraw() matrix.postScale(mScaleFactor, mScaleFactor, pivotPointX, pivotPointY);` method. On the first multitouch the pivotPointX and Y is setting to a new position, the entire image is moving(jumping) to a new position and then zooming will works fine. I need to avoid this jumping of image x y positions. – MobDev May 17 '12 at 08:01
  • What functionality from `ImageView` does this class use? It does not use ImageView's `onDraw()` and draws the bitmap directly with ` canvas.drawBitmap( ((BitmapDrawable) this.getDrawable()).getBitmap(), matrix, null);` – alexbirkett Feb 23 '16 at 13:14
  • @MobDev I don't see the class `TouchImageViewSample` in the below activity code which you shared in the last. Could you please help. – Mr. Unnormalized Posterior Jul 19 '17 at 09:53

7 Answers7

50

You can use this class : TouchImageView

Anjula
  • 1,690
  • 23
  • 29
32

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.

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.

Suragch
  • 484,302
  • 314
  • 1,365
  • 1,393
  • 1
    Hi @Suragch, this is working well for me, but the problem is there is some flickering on the image when the two fingers hold the zoom position. Can you see what's causing this? – Harsha Dec 13 '19 at 06:59
  • @Harsha, Good question. I'm not sure what is causing that. I haven't noticed it myself. – Suragch Dec 13 '19 at 08:08
  • Ok I've checked. I implemented a slightly different code. I used mImageView.setOnTouchListener instead and that is causing this issue for some reason. – Harsha Dec 13 '19 at 08:24
  • 1
    great explanation, I also prefer to get my hands dirty than just jumping to a library – Eman Oct 13 '20 at 14:16
  • @Harsha Regarding the issue you mentioned, I found this post. https://stackoverflow.com/questions/17395601/why-does-calling-setscalex-during-pinch-zoom-gesture-cause-flicker – Alireza Farahani Dec 03 '20 at 08:18
  • great answer, I am not into the canvas and coordinates stuff. This is what I was looking for. – Rainmaker Mar 23 '21 at 15:02
  • How can we reset the zoom level to the initial lets say with a click? – james04 Aug 05 '22 at 23:50
  • @james04, It's been a while since I've worked on Android, but off hand I'd say to save the initial value of `mScaleFactor` in a variable and then restore that value to `mScaleFactor` when the user clicks the reset button. – Suragch Aug 10 '22 at 19:44
16

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
Community
  • 1
  • 1
user1888162
  • 1,735
  • 21
  • 27
16

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();
Manfred Radlwimmer
  • 13,257
  • 13
  • 53
  • 62
Aleem Momin
  • 225
  • 3
  • 6
  • This will solve the problem https://stackoverflow.com/questions/6578320/how-to-apply-zoom-drag-and-rotation-to-an-image-in-android/47861501#47861501 – user2288580 Dec 18 '17 at 03:12
4

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

ZoomGestureDetector

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

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()
    }

}

Update:

I have published a library for this on github.com/UdaraWanasinghe/android-transform-layout. It uses a different algorithm based on the concatenation property of transformation matrices.

UdaraWanasinghe
  • 2,622
  • 2
  • 21
  • 27
1

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
}
 }
Senthil
  • 1,244
  • 10
  • 25
0

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

Pramesh Bhalala
  • 273
  • 1
  • 3
  • 9