4

During the past weeks I was looking for appropriate source code showing how to enable zoom and pan functionality on a custom view. All solutions that I found had some problems. For example the movement/zoom was not smooth enough or the view jumped around when releasing one finger after a scaling (two-finger) operation. So I came up with a modified solution that I want to share with you. Suggestions and enhancements are welcomed.

What’s different?

It is bad practice to calculate the difference (distance vector) on any interaction (pan, zoom) between each single events and use it to set new values. If you do so, the action does not look smooth and the view might flicker (jump around in some pixels). A better approach is to remember values when the action starts (onScaleBegin, touch-down) and calculate distances for each event in comparison to those start values.

You could handle finger indices in onTouchEvent to better distinguish between pan/move and zoom/scale interaction.

public class CanvasView extends SurfaceView implements SurfaceHolder.Callback {
    final static String TAG = "CanvasView";
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    ScaleGestureDetector mScaleDetector;
    InteractionMode mode;
    Matrix mMatrix = new Matrix();
    float mScaleFactor = 1.f;
    float mTouchX;
    float mTouchY;
    float mTouchBackupX;
    float mTouchBackupY;
    float mTouchDownX;
    float mTouchDownY;
    Rect boundingBox = new Rect();

    public CanvasView(Context context) {
        super(context);

        // we need to get a call for onSurfaceCreated
        SurfaceHolder sh = this.getHolder();
        sh.addCallback(this);

        // for zooming (scaling) the view with two fingers
        mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());

        boundingBox.set(0, 0, 1024, 768);

        paint.setColor(Color.GREEN);
        paint.setStyle(Style.STROKE);

        setFocusable(true);

        // initial center/touch point of the view (otherwise the view would jump
        // around on first pan/move touch
        DisplayMetrics metrics = context.getResources().getDisplayMetrics();
        mTouchX = metrics.widthPixels / 2;
        mTouchY = metrics.heightPixels / 2;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mScaleDetector.onTouchEvent(event);

        if (!this.mScaleDetector.isInProgress()) {
            switch (event.getAction()) {
            case MotionEvent.ACTION_UP:
                // similar to ScaleListener.onScaleEnd (as long as we don't
                // handle indices of touch events)
                mode = InteractionMode.None;
            case MotionEvent.ACTION_DOWN:
                Log.d(TAG, "Touch down event");

                mTouchDownX = event.getX();
                mTouchDownY = event.getY();
                mTouchBackupX = mTouchX;
                mTouchBackupY = mTouchY;

                // pan/move started
                mode = InteractionMode.Pan;
                break;
            case MotionEvent.ACTION_MOVE:
                // make sure we don't handle the last move event when the first
                // finger is still down and the second finger is lifted up
                // already after a zoom/scale interaction. see
                // ScaleListener.onScaleEnd
                if (mode == InteractionMode.Pan) {
                    Log.d(TAG, "Touch move event");

                    // get current location
                    final float x = event.getX();
                    final float y = event.getY();

                    // get distance vector from where the finger touched down to
                    // current location
                    final float diffX = x - mTouchDownX;
                    final float diffY = y - mTouchDownY;

                    mTouchX = mTouchBackupX + diffX;
                    mTouchY = mTouchBackupY + diffY;

                    CalculateMatrix(true);
                }

                break;
            }
        }

        return true;
    }

    @Override
    public void onDraw(Canvas canvas) {

        int saveCount = canvas.getSaveCount();
        canvas.save();
        canvas.concat(mMatrix);

        canvas.drawColor(Color.BLACK);
        canvas.drawRect(boundingBox, paint);

        canvas.restoreToCount(saveCount);
    }

    @Override
    public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) {
    }

    @Override
    public void surfaceCreated(SurfaceHolder arg0) {
        // otherwise onDraw(Canvas) won't be called
        this.setWillNotDraw(false);
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder arg0) {
    }

    void CalculateMatrix(boolean invalidate) {
        float sizeX = this.getWidth() / 2;
        float sizeY = this.getHeight() / 2;

        mMatrix.reset();

        // move the view so that it's center point is located in 0,0
        mMatrix.postTranslate(-sizeX, -sizeY);

        // scale the view
        mMatrix.postScale(mScaleFactor, mScaleFactor);

        // re-move the view to it's desired location
        mMatrix.postTranslate(mTouchX, mTouchY);

        if (invalidate)
            invalidate(); // re-draw
    }

    private class ScaleListener extends
            ScaleGestureDetector.SimpleOnScaleGestureListener {

        float mFocusStartX;
        float mFocusStartY;
        float mZoomBackupX;
        float mZoomBackupY;

        public ScaleListener() {
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {

            mode = InteractionMode.Zoom;

            mFocusStartX = detector.getFocusX();
            mFocusStartY = detector.getFocusY();
            mZoomBackupX = mTouchX;
            mZoomBackupY = mTouchY;

            return super.onScaleBegin(detector);
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {

            mode = InteractionMode.None;

            super.onScaleEnd(detector);
        }

        @Override
        public boolean onScale(ScaleGestureDetector detector) {

            if (mode != InteractionMode.Zoom)
                return true;

            Log.d(TAG, "Touch scale event");

            // get current scale and fix its value
            float scale = detector.getScaleFactor();
            mScaleFactor *= scale;
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));

            // get current focal point between both fingers (changes due to
            // movement)
            float focusX = detector.getFocusX();
            float focusY = detector.getFocusY();

            // get distance vector from initial event (onScaleBegin) to current
            float diffX = focusX - mFocusStartX;
            float diffY = focusY - mFocusStartY;

            // scale the distance vector accordingly
            diffX *= scale;
            diffY *= scale;

            // set new touch position
            mTouchX = mZoomBackupX + diffX;
            mTouchY = mZoomBackupY + diffY;

            CalculateMatrix(true);

            return true;
        }

    }
}
Matthias
  • 5,574
  • 8
  • 61
  • 121
  • 1
    This draws green rectangle on black background with nice panning. Is there any chance I can change this to pan the image rendered on SurfaceView (from MediaPlayer in case) instead of green rectangle? I assume I have something to do with onDraw, but I have no clue. – ernazm Nov 27 '14 at 10:37
  • 1
    Sure you can. The only important thing is that the matrix is set onto the canvas within onDraw. So you can remove the two lines canvas.drawColor and canvas.drawRect and call canvas.drawBitmap instead. There are some examples [here](http://stackoverflow.com/questions/2172523/draw-object-image-on-canvas) and [here](http://developer.android.com/guide/topics/graphics/2d-graphics.html#draw-with-canvas). But make sure you manage the bitmap memory correctly. – Matthias Nov 28 '14 at 13:48
  • Thanks ! But what is interactionMde? – KYHSGeekCode May 14 '18 at 12:37
  • 1
    That the mode in which the user intends to interact with your app. Either move (drag) an object, resize an object or zoom in/out. You have to chose how you want to design your app. Usually the mode is set when the touch gesture starts and reset when the gesture has ended. While the initial touch event is active (movement in progress) any further touch event is dismissed. Have a look into `onTouchEvent(MotionEvent event)` in the code above. Also refer to Android's `MotionEvent` – Matthias May 14 '18 at 12:40
  • Thanks again, you mean that I need to create an enum, right? – KYHSGeekCode May 14 '18 at 13:58
  • That’s correct. – Matthias May 14 '18 at 13:59

0 Answers0