Android one finger zoom tutorial – Part 1

Welcome to this first Android tutorial on how to make your own one finger zoom control like the one found in the Camera and Album applications in the Sony Ericsson X10 Mini. The Tutorial is divided into four parts, each part adding new features. Below is a link to download the source code for part 1 of the tutorial, prepared for you to set up your own project in e.g. Eclipse.

[Download] One Finger Zoom sample project (211kb)

Don’t miss to download the Sony Ericsson Tutorials app on Android Market where all applications in this and other Sony Ericsson tutorials are available. With the SonyEricsson tutorials app you can easily try out the different parts of the tutorial and see what the end result will be.

Below is a video showing what you will be able to do after seeing all steps of the tutorial. There following parts of the turoital will be added over the next few weeks.

There will be some math in this tutorial, but there is no need to get discouraged if it is too much for you. Please feel free to skip the math part and accept that they work.

In this first part of the tutorial we are going to make a basic, interactive, zoom control that allows us to zoom into an image. This will be achieved by implementing three classes representing the following: 1) our own View class for drawing images with zoom applied to it, 2) a class holding the state of the zoom, and 3) a class implementing the OnTouchListener interface allowing the user to interact with the zoom. As we progress in the tutorial we will see the benefits of dividing our application into different classes this way, as it makes it easy to change specific parts of the behavior.

Basic zoom listener

Defining a zoom state

The first thing we need to do is to define how the zoom state should work. The goal for our definition should be to make it easy to understand, simple to interact with and do math on. There is probably other decent ways to do this, but I’ll show you one that worked for me.

We’ll define the zoom state using three parameters.

  • The amount of zoom, that is how much the content has been scaled. We let a zoom value of 1 define the state where the content fits the view perfectly in at least one dimension.
  • The amount of pan in dimension-x (pan-x), that is how much the content is moved in respect to the width of the screen. We let the pan value define where the center position of the zoom-view (the view defining which part of the content we see) is in relation to the content. For example, a pan-x value of 0.5 would mean the center of the content would be visible in the center of the view, while a value of 0 would mean the left hand side of the content would be visible in the center of the screen.
  • The amount of pan in dimension-y (pan-y), like the pan-x except how the content is moved in relation to the height of the screen.

Images illustrating how the zoom state works, the dashed gray area represents what is shown in the view and the patterned area represents the content. On the left: Zoom is 1, pan-x and pan-y are both 0.5, in this state the image fits the screen perfectly. In the middle: Zoom is 2, pan-x and pan-y are still both 0.5, less content is now shown on the screen but will be scaled up. To the right: Zoom is 3, pan-x is 0.7 and pan-y is 0.833, we now see less of the image, only the top right corner, scaled up.

Implementation

Now that we’ve got the basics laid down, let’s move on to coding and let’s start by implementing the state. Since we know the view will want to determine when the state has been modified we can implement the state as an instance of Observable, allowing us to easily notify other objects of state changes.

public class ZoomState extends Observable {

    private float mZoom;
    private float mPanX;
    private float mPanY;

    public float getPanX() {
        return mPanX;
    }

    public float getPanY() {
        return mPanY;
    }

    public float getZoom() {
        return mZoom;
    }

    public void setPanX(float panX) {
        if (panX != mPanX) {
            mPanX = panX;
            setChanged();
        }
    }

    public void setPanY(float panY) {
        if (panY != mPanY) {
            mPanY = panY;
            setChanged();
        }
    }

    public void setZoom(float zoom) {
        if (zoom != mZoom) {
            mZoom = zoom;
            setChanged();
        }
    }
}

As simple as that, get/set methods for the available parameters and a call to setChanged() every time a parameter is changed. The client changing the values is responsible for calling notifyObservers() after modifying the state. Now that we have a state class, let’s move on to the view, we will extend the basic View class and implement the Observer interface in order to be able to observe and get callbacks when the state changes.

public class ImageZoomView extends View implements Observer {

    private Bitmap mBitmap;
    private ZoomState mState;

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

    public void setImage(Bitmap bitmap) {
        mBitmap = bitmap;

        invalidate();
    }

    public void setZoomState(ZoomState state) {
        if (mState != null) {
            mState.deleteObserver(this);
        }

        mState = state;
        mState.addObserver(this);

        invalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
    }

    public void update(Observable observable, Object data) {
        invalidate();
    }

}

Now we have created the basic structure of the view, and as this will be a zoom view for images we will be using a Bitmap object as the zoom content. We can set a bitmap to zoom in, we can set a zoom state to observe and we call invalidate when the content or the state changes. Next step we want to draw the bitmap, for this we need a Paint object and two Rect objects representing rectangles; one for the part of the content we wish to draw and one for the area of the view we wish to draw on.

    private final Paint mPaint = new Paint(Paint.FILTER_BITMAP_FLAG);
    private final Rect mRectSrc = new Rect();
    private final Rect mRectDst = new Rect();

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap != null && mState != null) {
            canvas.drawBitmap(mBitmap, mRectSrc, mRectDst, mPaint);
        }
    }

As you can see there is a drawBitmap method on the Canvas object that takes two rectangle objects as input parameters just the way we need it to, and since we will be scaling the bitmap we should enable the bitmap filtering on the paint object. Finally we need to set up the rectangles correctly, and then we’re done.

To set up the rectangles so that the visible part of the source image is cropped correctly and drawn at the correct location on the view we need to introduce a new variable. The reason for this is that if the content does not have the same aspect ratio (width / height) as the view, then a zoom value of 1 will mean that only one of the dimensions fit the view. In the other dimension the content will only partially cover the view, which means it will correspond to a zoom value lower than 1 in that particular dimension. The solution for this is to introduce a variable we will call the aspect quotient, that is the quotient between the aspect ratio of the content and the aspect ratio of the view. By using this value we can separate the zoom value on the x-axis and on the y-axis, and to do so we will add help methods to the ZoomState class to calculate them.

    public float getZoomX(float aspectQuotient) {
        return Math.min(mZoom, mZoom * aspectQuotient);
    }

    public float getZoomY(float aspectQuotient) {
        return Math.min(mZoom, mZoom / aspectQuotient);
    }

As the aspect quotient is defined by the view characteristics and content we will store this value in the ZoomView, and update its value whenever changes are made to the view or content (the content in our case being a bitmap). Such changes are for example if the view changes in size due to the user changing the phone orientation, or if the content changes because of the user switching between zoomable images in an album application.

    private float mAspectQuotient;

    private void calculateAspectQuotient() {
        if (mBitmap != null) {
            mAspectQuotient =
                (((float)mBitmap.getWidth()) / mBitmap.getHeight()) /
                (((float)getWidth()) / getHeight());
        }
    }

    public void setImage(Bitmap bitmap) {
        mBitmap = bitmap;

        calculateAspectQuotient();

        invalidate();
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);

        calculateAspectQuotient();
    }

With the help of these methods we can now implement the onDraw method correctly. We’ll do this by first calculating how much scaling to apply in relation to the full size of the content (zoomX, zoomY). Then we calculate the source rectangle to crop the portion of the content that should be visible, allowing the calculations to crop outside of the content boundaries. Finally we need to recalculate the source rectangle as the drawBitmap function does not support cropping outside of our bitmap content, and then we recalculate the destination rectangle to compensate for this.

Images showing how the zooming is done by setting up source and destination rectangles. The leftmost image shows the content with the crop area shown with a dashed square, this is how the source rectangle is initially calculated. To its right is the result shown in the zoom view, the dashed square here represents the destination rectangle that now covers the entire zoom view.


The next step is to recalculate the rectangles so that the source rectangle fits inside of the content without changing the result shown on the screen. The left most image now shows the content with the zoom window completely inside the content. To its right is the result shown in the zoom view, the dashed square represents the destination rectangle that has now been recalculated so that it doesn’t cover the parts of the view where nothing should be drawn.

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap != null && mState != null) {
            final int viewWidth = getWidth();
            final int viewHeight = getHeight();
            final int bitmapWidth = mBitmap.getWidth();
            final int bitmapHeight = mBitmap.getHeight();

            final float panX = mState.getPanX();
            final float panY = mState.getPanY();
            final float zoomX = mState.getZoomX(mAspectQuotient) * viewWidth / bitmapWidth;
            final float zoomY = mState.getZoomY(mAspectQuotient) * viewHeight / bitmapHeight;

            // Setup source and destination rectangles
            mRectSrc.left = (int)(panX * bitmapWidth - viewWidth / (zoomX * 2));
            mRectSrc.top = (int)(panY * bitmapHeight - viewHeight / (zoomY * 2));
            mRectSrc.right = (int)(mRectSrc.left + viewWidth / zoomX);
            mRectSrc.bottom = (int)(mRectSrc.top + viewHeight / zoomY);
            mRectDst.left = getLeft();
            mRectDst.top = getTop();
            mRectDst.right = getRight();
            mRectDst.bottom = getBottom();

            // Adjust source rectangle so that it fits within the source image.
            if (mRectSrc.left < 0) {
                mRectDst.left += -mRectSrc.left * zoomX;
                mRectSrc.left = 0;
            }
            if (mRectSrc.right > bitmapWidth) {
                mRectDst.right -= (mRectSrc.right - bitmapWidth) * zoomX;
                mRectSrc.right = bitmapWidth;
            }
            if (mRectSrc.top < 0) {
                mRectDst.top += -mRectSrc.top * zoomY;
                mRectSrc.top = 0;
            }
            if (mRectSrc.bottom > bitmapHeight) {
                mRectDst.bottom -= (mRectSrc.bottom - bitmapHeight) * zoomY;
                mRectSrc.bottom = bitmapHeight;
            }

            canvas.drawBitmap(mBitmap, mRectSrc, mRectDst, mPaint);
        }
    }

So great, we have our view and state classes working, now let’s make a simple OnTouchListener so we can get to play around with the zoom as soon as possible! The listener will change the zoom or pan values of the state when the user touches the screen. For simplicity we’ll either zoom or pan when touch starts, which to do will be determined by an attribute we’ll call control type. Other than that we need to store the coordinates of the previous touch event so we know how much the touch has moved between each move event we receive.

For the zoom value we use the distance moved in the y-dimension, using this as the exponent when calculating the exponentiation of of a well chosen number, and then multiply the result with the current zoom level. Multiplying with an exponentiation is practical since if we where to for example move 10 pixels in the y-dimension, doing so gives the same result if it’s obtained by one or several move events. This is because, where we get the same result when moving a + b pixels as we get if we first move a pixels and then b pixels

For the pan we will simply add the distance moved in to their respective pan values, making the pan move relative to the touch movement. Finally we call notifyObservers() on the state in order to make the view redraw it’s content.

public class SimpleZoomListener implements View.OnTouchListener {

    public enum ControlType {
        PAN, ZOOM
    }

    private ControlType mControlType = ControlType.ZOOM;

    private ZoomState mState;

    private float mX;
    private float mY;

    public void setZoomState(ZoomState state) {
        mState = state;
    }

    public void setControlType(ControlType controlType) {
        mControlType = controlType;
    }

    public boolean onTouch(View v, MotionEvent event) {
        final int action = event.getAction();
        final float x = event.getX();
        final float y = event.getY();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mX = x;
                mY = y;
                break;

            case MotionEvent.ACTION_MOVE: {
                final float dx = (x - mX) / v.getWidth();
                final float dy = (y - mY) / v.getHeight();

                if (mControlType == ControlType.ZOOM) {
                    mState.setZoom(mState.getZoom() * (float)Math.pow(20, -dy));
                    mState.notifyObservers();
                } else {
                    mState.setPanX(mState.getPanX() - dx);
                    mState.setPanY(mState.getPanY() - dy);
                    mState.notifyObservers();
                }

                mX = x;
                mY = y;
                break;
            }

        }

        return true;
    }

}

Alright, so we’re almost done. Now we just need an Activity and a layout to go with this and we’re done, the layout is quite straight forward, we simply add our new ZoomView and tell it to fill it’s parent.

<?xml version="1.0" encoding="utf-8"?>
<com.sonyericsson.zoom.ImageZoomView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/zoomview"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    />

In the activity we set up our zoom components in the onCreate() method, creating a new zoom state, decoding the bitmap we want to zoom in, creating the listener and connecting it to the state, and finally setting up the view. We also implement the onDestroy method and make sure to recycle bitmaps and remove listeners.

Finally we implement onCreateOptionsMenu() and onOptionsItemSelected() respectively enabling the user to change input method using the options menu and to reset the zoom to its original state.

public class TutorialZoomActivity1 extends Activity {

    private static final int MENU_ID_ZOOM = 0;
    private static final int MENU_ID_PAN = 1;
    private static final int MENU_ID_RESET = 2;

    private ImageZoomView mZoomView;
    private ZoomState mZoomState;
    private Bitmap mBitmap;
    private SimpleZoomListener mZoomListener;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.main);

        mZoomState = new ZoomState();

        mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.image800x600);

        mZoomListener = new SimpleZoomListener();
        mZoomListener.setZoomState(mZoomState);

        mZoomView = (ImageZoomView)findViewById(R.id.zoomview);
        mZoomView.setZoomState(mZoomState);
        mZoomView.setImage(mBitmap);
        mZoomView.setOnTouchListener(mZoomListener);

        resetZoomState();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();

        mBitmap.recycle();
        mZoomView.setOnTouchListener(null);
        mZoomState.deleteObservers();
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        menu.add(Menu.NONE, MENU_ID_ZOOM, 0, R.string.menu_zoom);
        menu.add(Menu.NONE, MENU_ID_PAN, 1, R.string.menu_pan);
        menu.add(Menu.NONE, MENU_ID_RESET, 2, R.string.menu_reset);
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case MENU_ID_ZOOM:
                mZoomListener.setControlType(ControlType.ZOOM);
                break;

            case MENU_ID_PAN:
                mZoomListener.setControlType(ControlType.PAN);
                break;

            case MENU_ID_RESET:
                resetZoomState();
                break;
        }

        return super.onOptionsItemSelected(item);
    }

    private void resetZoomState() {
        mZoomState.setPanX(0.5f);
        mZoomState.setPanY(0.5f);
        mZoomState.setZoom(1f);
        mZoomState.notifyObservers();
    }
}

And voila, we’re done! You can now start to play around with the zoom control on your own. It’s basic but works perfectly well for zooming around in images. A few things will probably seem to be lacking at the moment: first panning around doesn’t follow the finger correctly, and there is no limits or snap to functionality to keep us within reasonable bounds. These things, and others, will be the topic of the next parts of this zoom tutorial.

Class diagram showing how the zoom components interact

If you want to learn more about the zoom I suggest playing around with the code a bit, try changing how to zoom is controlled by the listener and try zooming into your own images.

Good luck!

[Download] One Finger Zoom sample project (211kb)

Comments 33

Sort by: Newest | Oldest | Most popular

  1. By ankit goyal

    #1

    I want to detect fling and based on that change image is it possible? can you just put some light where I have to change?

  2. Pingback #2

    [...] link do przykładu, wydaje mi się że dobrze wytłumaczony (autor nie używa GestureDetector): Android one finger zoom tutorial – Part 1 — Developer World Wszystkie funkcje opisane. Do gry możnaby zdefiniować własne gesty (ale standardowe powinny [...]

  3. By Trying It

    #4

    Can’t get pan to work in emulator. Nothing works properly after tutorial 3.

  4. By Android Phone Tips

    #5

    complete tutorial, i will to try on my android soon..is there any friends who have tried and succeeded ..?

  5. By tamilmaran

    #6

    hai this tutorial very nice for zooming of one images

    how to use this in gallery widget in android

  6. By Tijuana Demas

    #7

    Sweet web site , super style and design , really clean and utilize genial .

  7. By suman

    #8

    Can i use a cirlce instead of rectangle?

  8. By Susan

    #9

    Hello! great stuff once again. I enjoyed reading your site because you usually write great articles. Very nice article..I plan to bookmark this website. I think I shall subscribe to this feed also. Got a brand new cellphone too. Anyone own a htc evo? It’s sooo good…

  9. By Rochelle Jund

    #10

    hello everyone, I was just checkin’ out this site and I really admire the basis of the article, and have nothing to do, so if anyone wants to have an engrossing convo about it, please contact me on skype, my name is adam naeire

  10. By Adiga

    #11

    Of great help..Thank yo!

  11. By salah

    #12

    I am totaly new with x10 please tell me how can i implement this future in my x10.

  12. By moe

    #13

    i am not a delevolper, but im very much interested in the one finger zoom.. but is there an app out there so i do not have to program it myself.. im pretty much clueless when it comes to programming / developing.. thanks.. and great tut, if only i could wrap my mind around it. ;)

  13. By Blyabtroi

    #14

    Sorry, you’re right. It was my mistake.

  14. By Andreas Agvard

    #15

    @Blyabtroi
    I don’t see this problem, can you elaborate? The View is smaller than the bitmap in the reference application (the bitmap is 800×600) and to me it looks fine.

Show more comments

1-15 of 33 comments. Show all comments