Loading...
[-]

Java Pan / Zoom listener for Android

I’ve been doing some Android programming recently, and I wrote a pan / zoom listener that can be used on a VideoView, or an ImageView. This is an extension on some code I found here http://stackoverflow.com/questions/3813119/scale-limits-on-pinch-zoom-of-android which it turns out was from a touch / pinch example in a book called Hello Android http://pragprog.com/book/eband3/hello-android

The advantage my code has over other implementations is that the image will zoom in on the exact spot between your fingers, and it is limited to stop the image from going offscreen.

Here’s some example code of using it with an ImageView:

FrameLayout view = new FrameLayout(context);

FrameLayout.LayoutParams fp = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, Gravity.TOP | Gravity.LEFT);

ImageView imageView = new ImageView(this);
//Use line below for large images if you have hardware rendering turned on
//imageView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
// Line below is optional, depending on what scaling method you want
imageView.setScaleType(ImageView.ScaleType.MATRIX);
view.addView(imageView, fp);
view.setOnTouchListener(new PanAndZoomListener(view, imageView, Anchor.TOPLEFT));

Here’s some example code of using it with a VideoView:

FrameLayout view = new FrameLayout(context);
FrameLayout.LayoutParams fp = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT, Gravity.CENTER);
VideoView videoView = new VideoView(this);
view.addView(videoView, fp);
view.setOnTouchListener(new PanAndZoomListener(view, videoView, Anchor.CENTER));

Here’s the source

import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.FloatMath;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnLayoutChangeListener;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams;
import android.widget.FrameLayout;
import android.widget.ImageView;

public class PanAndZoomListener implements OnTouchListener {

  public static class Anchor {

    public static final int CENTER = 0;
    public static final int TOPLEFT = 1;
  }
  private static final String TAG = "PanAndZoomListener";
  // We can be in one of these 3 states
  static final int NONE = 0;
  static final int DRAG = 1;
  static final int ZOOM = 2;
  int mode = NONE;
  // Remember some things for zooming
  PointF start = new PointF();
  PointF mid = new PointF();
  float oldDist = 1f;
  PanZoomCalculator panZoomCalculator;

  public PanAndZoomListener(FrameLayout containter, View view, int anchor) {
    panZoomCalculator = new PanZoomCalculator(containter, view, anchor);
  }

  public boolean onTouch(View view, MotionEvent event) {

    // Handle touch events here...
    switch (event.getAction() & MotionEvent.ACTION_MASK) {
      case MotionEvent.ACTION_DOWN:
        start.set(event.getX(), event.getY());
        Log.d(TAG, "mode=DRAG");
        mode = DRAG;
        break;
      case MotionEvent.ACTION_POINTER_DOWN:
        oldDist = spacing(event);
        Log.d(TAG, "oldDist=" + oldDist);
        if (oldDist > 10f) {
          midPoint(mid, event);
          mode = ZOOM;
          Log.d(TAG, "mode=ZOOM");
        }
        break;
      case MotionEvent.ACTION_UP:
      case MotionEvent.ACTION_POINTER_UP:
        mode = NONE;
        Log.d(TAG, "mode=NONE");
        break;
      case MotionEvent.ACTION_MOVE:
        if (mode == DRAG) {
          panZoomCalculator.doPan(event.getX() - start.x, event.getY() - start.y);
          start.set(event.getX(), event.getY());
        } else if (mode == ZOOM) {
          float newDist = spacing(event);
          Log.d(TAG, "newDist=" + newDist);
          if (newDist > 10f) {
            float scale = newDist / oldDist;
            oldDist = newDist;
            panZoomCalculator.doZoom(scale, mid);
          }
        }
        break;
    }
    return true; // indicate event was handled
  }

  // 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 FloatMath.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);
  }

  class PanZoomCalculator {

    /// The current pan position
    PointF currentPan;
    /// The current zoom position
    float currentZoom;
    /// The windows dimensions that we are zooming/panning in
    View window;
    View child;
    Matrix matrix;
    // Pan jitter is a workaround to get the video view to update it's layout properly when zoom is changed
    int panJitter = 0;
    int anchor;

    PanZoomCalculator(View container, View child, int anchor) {
      // Initialize class fields
      currentPan = new PointF(0, 0);
      currentZoom = 1f;;
      this.window = container;
      this.child = child;
      matrix = new Matrix();
      this.anchor = anchor;
      onPanZoomChanged();
      this.child.addOnLayoutChangeListener(new OnLayoutChangeListener() {
        // This catches when the image bitmap changes, for some reason it doesn't recurse

        public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
          onPanZoomChanged();
        }
      });
    }

    public void doZoom(float scale, PointF zoomCenter) {

      float oldZoom = currentZoom;

      // multiply in the zoom change
      currentZoom *= scale;

      // this limits the zoom
      currentZoom = Math.max(getMinimumZoom(), currentZoom);
      currentZoom = Math.min(8f, currentZoom);

      // Adjust the pan accordingly
      // Need to make it such that the point under the zoomCenter remains under the zoom center after the zoom

      // calculate in fractions of the image so:

      float width = window.getWidth();
      float height = window.getHeight();
      float oldScaledWidth = width * oldZoom;
      float oldScaledHeight = height * oldZoom;
      float newScaledWidth = width * currentZoom;
      float newScaledHeight = height * currentZoom;

      if (anchor == Anchor.CENTER) {

        float reqXPos = ((oldScaledWidth - width) * 0.5f + zoomCenter.x - currentPan.x) / oldScaledWidth;
        float reqYPos = ((oldScaledHeight - height) * 0.5f + zoomCenter.y - currentPan.y) / oldScaledHeight;
        float actualXPos = ((newScaledWidth - width) * 0.5f + zoomCenter.x - currentPan.x) / newScaledWidth;
        float actualYPos = ((newScaledHeight - height) * 0.5f + zoomCenter.y - currentPan.y) / newScaledHeight;

        currentPan.x += (actualXPos - reqXPos) * newScaledWidth;
        currentPan.y += (actualYPos - reqYPos) * newScaledHeight;
      } else {
        // assuming top left
        float reqXPos = (zoomCenter.x - currentPan.x) / oldScaledWidth;
        float reqYPos = (zoomCenter.y - currentPan.y) / oldScaledHeight;
        float actualXPos = (zoomCenter.x - currentPan.x) / newScaledWidth;
        float actualYPos = (zoomCenter.y - currentPan.y) / newScaledHeight;
        currentPan.x += (actualXPos - reqXPos) * newScaledWidth;
        currentPan.y += (actualYPos - reqYPos) * newScaledHeight;
      }

      onPanZoomChanged();
    }

    public void doPan(float panX, float panY) {
      currentPan.x += panX;
      currentPan.y += panY;
      onPanZoomChanged();
    }

    private float getMinimumZoom() {
      return 1f;
    }

    /// Call this to reset the Pan/Zoom state machine
    public void reset() {
      // Reset zoom and pan
      currentZoom = getMinimumZoom();
      currentPan = new PointF(0f, 0f);
      onPanZoomChanged();
    }

    public void onPanZoomChanged() {

      // Things to try: use a scroll view and set the pan from the scrollview
      // when panning, and set the pan of the scroll view when zooming

      float winWidth = window.getWidth();
      float winHeight = window.getHeight();

      if (currentZoom <= 1f) {
        currentPan.x = 0;
        currentPan.y = 0;
      } else if (anchor == Anchor.CENTER) {

        float maxPanX = (currentZoom - 1f) * window.getWidth() * 0.5f;
        float maxPanY = (currentZoom - 1f) * window.getHeight() * 0.5f;
        currentPan.x = Math.max(-maxPanX, Math.min(maxPanX, currentPan.x));
        currentPan.y = Math.max(-maxPanY, Math.min(maxPanY, currentPan.y));
      } else {
        // assume top left
        
        float maxPanX = (currentZoom - 1f) * window.getWidth();
        float maxPanY = (currentZoom - 1f) * window.getHeight();
        currentPan.x = Math.max(-maxPanX, Math.min(0, currentPan.x));
        currentPan.y = Math.max(-maxPanY, Math.min(0, currentPan.y));
      }

      if (child instanceof ImageView && ((ImageView) child).getScaleType()== ImageView.ScaleType.MATRIX) {
        ImageView view = (ImageView) child;
        Drawable drawable = view.getDrawable();
        if (drawable != null) {
          Bitmap bm = ((BitmapDrawable) drawable).getBitmap();
          if (bm != null) {
            // Limit Pan

            float bmWidth = bm.getWidth();
            float bmHeight = bm.getHeight();

            float fitToWindow = Math.min(winWidth / bmWidth, winHeight / bmHeight);
            float xOffset = (winWidth - bmWidth * fitToWindow) * 0.5f * currentZoom;
            float yOffset = (winHeight - bmHeight * fitToWindow) * 0.5f * currentZoom;

            matrix.reset();
            matrix.postScale(currentZoom * fitToWindow, currentZoom * fitToWindow);
            matrix.postTranslate(currentPan.x + xOffset, currentPan.y + yOffset);
            ((ImageView) child).setImageMatrix(matrix);
          }
        }
      } else {
        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        lp.leftMargin = (int) currentPan.x + panJitter;
        lp.topMargin = (int) currentPan.y;
        lp.width = (int) (window.getWidth() * currentZoom);
        lp.height = (int) (window.getHeight() * currentZoom);
        panJitter ^= 1;

        child.setLayoutParams(lp);
      }
    }
  }
}

14 Comments

  1. Mr jakselsson
    Posted August 21, 2012 at 00:50 | Permalink

    Hi!
    Thanks a ton for the code. It works perfect on android 4.0.4 and so, but I am having problems with 2.3.3. It zooms keeping the top left corner always in screen, not between the fingers. Also panning doesn’t work. Any ideas what might go wrong?

  2. Posted August 21, 2012 at 08:04 | Permalink

    I’ve only tested this on Android 4 upwards. I think in the older versions you had to do an extra step to get the x,y coordinates of the touch event.

  3. Sachin Jain
    Posted September 2, 2012 at 19:09 | Permalink

    I have used your code for canvas view zoom/pinch and it’s working. Thank’s

  4. Posted September 2, 2012 at 19:29 | Permalink

    Your welcome.

  5. Mr jakselsson
    Posted September 5, 2012 at 20:09 | Permalink

    I believe android 2.* don’t like negative values in MarginLayoutParams margins. I am not using matrix scaletype as it seems to misbehave for some reason.

  6. Mr jakselsson
    Posted September 6, 2012 at 15:29 | Permalink

    Just wanted to tell you I got the matrix scaletype working. And it works on all versions now. Thanks for sharing this code!

  7. Posted September 6, 2012 at 17:58 | Permalink

    Glad you got it working. My requirements were simpler, I developed an app for a customer who wanted it to run on an ASUS Transformer Prime and nothing else and this code was just something generic that I was able to souvenir for my blog.

  8. Dilip
    Posted November 23, 2012 at 20:22 | Permalink

    Getting error class classDefNot Found……please suggest me

  9. George Baker
    Posted December 28, 2012 at 13:27 | Permalink

    Hi,

    Thanks for all the hard work on the code. I am having a little trouble getting it going though. I have it zooming vertically but not horizontally. Any ideas?

    Thanks,
    George

  10. Dilip
    Posted December 28, 2012 at 15:13 | Permalink

    This is not working on 2.2 classdefnot found exception.

  11. Posted December 28, 2012 at 17:45 | Permalink

    Unfortunately this was written specifically for Android 4.0 (as per customer requirements) and no other version. The codebase this was part of was delivered, and support is being handled by someone else. Since I pushlished this I haven’t done any other Android development, so I’m not in a position to help out with back or forward porting.

  12. Posted December 28, 2012 at 17:56 | Permalink

    I haven’t picked up any Android jobs since I wrote this code 7 months ago as part of a job for an Android 4.0 app, so I’m probably not going to be much help at this present time. The only thing I can think of is that there is something else interacting with the image/video that is keeping it centered or sized horizontally.

    If you are panning/zooming an image I have 2 different modes, 1 acts directly on the image itself, the other acts on the layout of the image container. You could try switching between the 2 by including or not including this line as seen at in the example at the top of the blog post:
    imageView.setScaleType(ImageView.ScaleType.MATRIX);

  13. Gab Ornales
    Posted March 21, 2014 at 07:47 | Permalink

    When I use this with a RelativeLayout inside the FrameLayout, the initial view is not the same as when i didn’t use this. How can I revert it back to normal but still use zoom and pan?

  14. Gabriel
    Posted June 12, 2014 at 12:10 | Permalink

    Thanks, it really helped a lot, I’m currently trying to also implement rotation on your code.

2 Trackbacks

  1. [...] an interesting example. It comes from Java Pan / Zoom listener for Android (reference 2 below). Rather than doing a custom view every time you want to have panning and [...]

  2. [...] Java Pan / Zoom listener for Android [...]

Post a Comment

Your email is never shared. Required fields are marked *

*
*