Translate/scale bitmap within boundaries?
<p>When I research approaches for touch pan/zoom on an image, I generally find effective, simple code--but nothing that does quite what I want. The image needs to never show a blank space between the edge of the actual image (bitmap) and its View. If the bitmap is 200x100 and the View is 50x50, the user should only be able to zoom out to 100x50, allowing them to slide the image horizontally, but not vertically. </p>

<p>My code does this well when moving (translating) the image--until the image is zoomed. Then something is thrown off; I can move the bitmap far enough to see gaps around it. It's probably something simple and obvious related to factoring pixel measurements by the current scale factor, but I can't find it. I suspect it has to do with the calculations of maxX and maxY in onDraw() below. Any ideas?</p>

<pre><code>import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.widget.ImageView;

/**
 * Most code from
 * http://android-developers.blogspot.com/2010/06/making-sense-of-multitouch.html
 * 
 * @author Chad Schultz
 * 
 */
public class PanZoomImageView extends ImageView {

    public static final String TAG = PanZoomImageView.class.getName();

    private static final int INVALID_POINTER_ID = -1;

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

    private Bitmap bitmap;

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

    private float mPosX;
    private float mPosY;

    private float mLastTouchX, mLastTouchY;

    private boolean firstDraw = true;

    private boolean panEnabled = true;
    private boolean zoomEnabled = true;

    public PanZoomImageView(Context context) {
        super(context);
        setup();
    }

    public PanZoomImageView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        setup();
    }

    public PanZoomImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setup();
    }

    private void setup() {
        mScaleDetector = new ScaleGestureDetector(getContext(), new ScaleListener());
    }

    @Override
    public void setImageBitmap(Bitmap bmp) {
        super.setImageBitmap(bmp);
        bitmap = bmp;
        firstDraw = true;
    }

    @Override
    public void setImageDrawable(Drawable drawable) {
        super.setImageDrawable(drawable);
        bitmap = ((BitmapDrawable) drawable).getBitmap();
        firstDraw = true;
    }

    public void onDraw(Canvas canvas) {
        Log.v(TAG, "onDraw()");
        if (bitmap == null) {
            Log.w(TAG, "nothing to draw - bitmap is null");
            super.onDraw(canvas);
            return;
        }

        if (firstDraw 
                &amp;&amp; (bitmap.getHeight() &gt; 0) 
                &amp;&amp; (bitmap.getWidth() &gt; 0) 
                &amp;&amp; (canvas.getHeight() &gt; 0) 
                &amp;&amp; (canvas.getWidth() &gt; 0)) {
            //Don't let the user zoom out so much that the image is smaller
            //than its containing frame
            float minXScaleFactor = (float) canvas.getWidth() / (float) bitmap.getWidth();
            float minYScaleFactor = (float) canvas.getHeight() / (float) bitmap.getHeight();
            minScaleFactor = Math.max(minXScaleFactor, minYScaleFactor);
            Log.d(TAG, "minScaleFactor: " + minScaleFactor);
            firstDraw = false;
        }
        mScaleFactor = Math.max(mScaleFactor, minScaleFactor);
        Log.d(TAG, "mScaleFactor: " + mScaleFactor);

        //Save the canvas without translating (panning) or scaling (zooming)
        //After each change, restore to this state, instead of compounding
        //changes upon changes
        canvas.save();

        int maxX, minX, maxY, minY;
        //How far can we move the image horizontally without having a gap between image and frame?
        maxX = (int) (mScaleFactor * (bitmap.getWidth() / 2) - (canvas.getWidth() / 2));
        minX = -1 * maxX;
        //How far can we move the image vertically without having a gap between image and frame?
        maxY = (int) (mScaleFactor * (bitmap.getHeight() / 2) - (canvas.getWidth() / 2));
        minY = -1 * maxY;
        //Do not go beyond the boundaries of the image
        if (mPosX &gt; maxX) {
            mPosX = maxX;
        }
        if (mPosX &lt; minX) {
            mPosX = minX;
        }
        if (mPosY &gt; maxY) {
            mPosY = maxY;
        }
        if (mPosY &lt; minY) {
            mPosY = minY;
        }

        Log.d(TAG, "canvas width: " + canvas.getWidth() + " canvas height: "
                + canvas.getHeight());
        Log.d(TAG, "bitmap width: " + bitmap.getWidth() + " height: " + bitmap.getHeight());
        Log.d(TAG, "translating mPosX: " + mPosX + " mPosY: " + mPosY);

        if (zoomEnabled) {
            Log.d(TAG, "zooming to scale factor of " + mScaleFactor);
            canvas.scale(mScaleFactor, mScaleFactor);
        } else {
            Log.d(TAG, "zooming disabled");
        }

        if (panEnabled) {
            Log.d(TAG, "panning to " + mPosX + "," + mPosY); 
            canvas.translate(mPosX, mPosY);
        } else {
            Log.d(TAG, "panning disabled");
        }

        super.onDraw(canvas);
        canvas.restore(); //clear translation/scaling
    }

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

        final int action = ev.getAction();
        switch (action &amp; 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()) {
                    float dx = x - mLastTouchX;
                    float dy = y - mLastTouchY;

                    //Adjust for zoom factor. Otherwise, the user's finger moving 10 pixels
                    //at 200% zoom causes the image to slide 20 pixels instead of perfectly
                    //following the user's touch
                    dx /= mScaleFactor;
                    dy /= mScaleFactor;

                    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() &amp; MotionEvent.ACTION_POINTER_INDEX_MASK) &gt;&gt; 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;
    }

    private class ScaleListener extends
            ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            mScaleFactor *= detector.getScaleFactor();
            // Don't let the object get too small or too large.
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));
            Log.d(TAG, "detector scale factor: " + detector.getScaleFactor() + " mscalefactor: " + mScaleFactor);

            invalidate();
            return true;
        }
    }

    //Currently zoomEnabled/panEnabled can only be set programmatically, not in XML

    public boolean isPanEnabled() {
        return panEnabled;
    }

    public void setPanEnabled(boolean panEnabled) {
        this.panEnabled = panEnabled;
    }

    public boolean isZoomEnabled() {
        return zoomEnabled;
    }

    public void setZoomEnabled(boolean zoomEnabled) {
        this.zoomEnabled = zoomEnabled;
    }

}
</code></pre>
