How to create zoomable & pannable view with many draggable views on top of it?

spes picture spes · Feb 7, 2013 · Viewed 8k times · Source

I am trying to create a board game in Android which includes a board with many tiles on top of it, which can be dragged around the board, as well as from and to a player's rack. This is quite similar to the Wordfeud game.

The board has a fixed size. I want the user to be able to pinch to zoom, and pan around the board, and dragging the tiles around the board. The tiles must be scaled along with the board when zooming in/out.

I am struggling to find the right way to set it up. I have thought of and tried two ways:

  1. Using a HorizontalScrollView combined with a ScrollView with a RelativeLayout as a child. This RelativeLayout then contains all tiles. This works OK, but how would I then implement the pinch to zoom?
  2. Using this example to zoom and pan a view: http://android-developers.blogspot.nl/2010/06/making-sense-of-multitouch.html . But how would I then add tiles on top of this view that zoom & pan along with this view?

Both options don't seem to be the right solution. I'm interested to learn how other Android developers would set this up and hope they provide me a right direction.

Answer

PopGorn picture PopGorn · Feb 7, 2013

OK, first, I would recommend to forget the first solution, which is not very straightforward. The second one is a good start.

Here is my solution :

Activity class

import android.os.Bundle;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Point;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.RelativeLayout;

public class MainActivity extends Activity {

    private RelativeLayout mMainLayout;
    private InteractiveView mInteractiveView;

    private int mScreenWidth;
    private int mScreenHeight;

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

        // Set fullscreen mode
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);

        setContentView(R.layout.activity_main);  

        // Retrieve the device dimensions to adapt interface
        mScreenWidth = getApplicationContext().getResources()
                .getDisplayMetrics().widthPixels;
        mScreenHeight = getApplicationContext().getResources()
                .getDisplayMetrics().heightPixels;        

        mMainLayout = (RelativeLayout)findViewById(R.id.mainlayout);

        // Create the interactive view holding the elements
        mInteractiveView = new InteractiveView(this);
        mInteractiveView.setLayoutParams(new RelativeLayout.LayoutParams(-2,-2 ));
        mInteractiveView.setPosition(-mScreenWidth/2, -mScreenHeight/2);

        mMainLayout.addView(mInteractiveView);

        // Adding a background to this view
        ImageView lImageView = new ImageView(this);
        lImageView.setLayoutParams(new RelativeLayout.LayoutParams(-1,-1));
        lImageView.setImageResource(R.drawable.board);

        mInteractiveView.addView(lImageView);

        // Adding a tile we can move on the top of the board
        addElement(50, 50);     
    }

    // Creation of a smaller element
    private void addElement(int pPosX, int pPosY) {

        BoardTile lBoardTile = new BoardTile(this);
        Bitmap lSourceImage = BitmapFactory.decodeResource(getResources(), R.drawable.tile);
        Bitmap lImage = Bitmap.createScaledBitmap(lSourceImage, 100, 100, true);
        lBoardTile.setImage(lImage);        
        Point lPoint = new Point();
        lPoint.x = pPosX;
        lPoint.y = pPosY;
        lBoardTile.setPosition(lPoint);

        mInteractiveView.addView(lBoardTile);       
}

The InteractiveView class is just a simple RelativeLayout that reacts to pinch and drag and will hold further elements:

InteractiveView class

import android.content.Context;
import android.graphics.Canvas;
import android.util.FloatMath;
import android.view.MotionEvent;
import android.view.View;
import android.widget.RelativeLayout;

public class InteractiveView extends RelativeLayout{

    private float mPositionX = 0;
    private float mPositionY = 0;
    private float mScale = 1.0f;

    public InteractiveView(Context context) {
        super(context);     
        this.setWillNotDraw(false);
        this.setOnTouchListener(mTouchListener);
    }   

    public void setPosition(float lPositionX, float lPositionY){
        mPositionX = lPositionX;
        mPositionY = lPositionY;
    }

    public void setMovingPosition(float lPositionX, float lPositionY){
        mPositionX += lPositionX;
        mPositionY += lPositionY;
    }

    public void setScale(float lScale){ 
        mScale = lScale;
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {        
        canvas.save();      
        canvas.translate(getWidth() / 2, getHeight() / 2);  
        canvas.translate(mPositionX*mScale, mPositionY*mScale);     
        canvas.scale(mScale, mScale);
        super.dispatchDraw(canvas);
        canvas.restore();
    }   

    // touch events
    private final int NONE = 0;
    private final int DRAG = 1;
    private final int ZOOM = 2;
    private final int CLICK = 3;

    // pinch to zoom
    private float mOldDist;
    private float mNewDist;
    private float mScaleFactor = 0.01f;

    // position
    private float mPreviousX;
    private float mPreviousY;

    int mode = NONE;

    @SuppressWarnings("deprecation")
    public OnTouchListener mTouchListener = new  OnTouchListener(){
        public boolean onTouch(View v, MotionEvent e) {
            float x = e.getX();
            float y = e.getY();
            switch (e.getAction()) {
            case MotionEvent.ACTION_DOWN: // one touch: drag            
                mode = CLICK;
                break;
            case MotionEvent.ACTION_POINTER_2_DOWN: // two touches: zoom            
                mOldDist = spacing(e);          
                mode = ZOOM; // zoom
                break;
            case MotionEvent.ACTION_UP: // no mode          
                mode = NONE;
                break;
            case MotionEvent.ACTION_POINTER_2_UP: // no mode
                mode = NONE;            
                break;
            case MotionEvent.ACTION_MOVE: // rotation
                if (e.getPointerCount() > 1 && mode == ZOOM) {
                    mNewDist = spacing(e) - mOldDist;   

                    mScale += mNewDist*mScaleFactor;
                    invalidate();

                    mOldDist = spacing(e);  

                } else if (mode == CLICK || mode == DRAG) {
                    float dx = (x - mPreviousX)/mScale;
                    float dy = (y - mPreviousY)/mScale;

                    setMovingPosition(dx, dy);
                    invalidate();
                    mode = DRAG;                        
                }
                break;
            }
            mPreviousX = x;
            mPreviousY = y;
            return true;
        }
    };

    // finds spacing
    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);
    }
}

Then, we have an "element" class (called BoardTile) that will create tiles going on this InteractiveView. This class is more complex, because the view doesn't take the whole screen and we will have to test if the touch event is inside the bounds of the object.

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.Region;
import android.view.MotionEvent;
import android.view.View;

public class BoardTile extends View
{
    private Bitmap mCardImage;
    private final Paint mPaint = new Paint();
    private final Point mSize = new Point();
    private final Point mStartPosition = new Point();
    private Region mRegion;

    public BoardTile(Context context)
    {
        super(context);
        mRegion = new Region();
        this.setOnTouchListener(mTouchListener);
    }

    public final Bitmap getImage() { return mCardImage; }
    public final void setImage(Bitmap image)
    {
        mCardImage = image;
        setSize(mCardImage.getWidth(), mCardImage.getHeight());
    }

    @Override
    protected void onDraw(Canvas canvas)
    {
        Point position = getPosition();
        canvas.drawBitmap(mCardImage, position.x, position.y, mPaint);
    }

    public final void setPosition(final Point position)
    {
        mRegion.set(position.x, position.y, position.x + mSize.x, position.y + mSize.y);
    }

    public final Point getPosition()
    {
        Rect bounds = mRegion.getBounds();
        return new Point(bounds.left, bounds.top);
    }

    public final void setSize(int width, int height)
    {
        mSize.x = width;
        mSize.y = height;

        Rect bounds = mRegion.getBounds();
        mRegion.set(bounds.left, bounds.top, bounds.left + width, bounds.top + height);
    }

    public final Point getSize() { return mSize; }

    public OnTouchListener mTouchListener = new  OnTouchListener(){
        @Override
        public boolean onTouch(View v, MotionEvent event) {

            // Is the event inside of this view?
            if(!mRegion.contains((int)event.getX(), (int)event.getY()))
            {
                return false;
            }

            if(event.getAction() == MotionEvent.ACTION_DOWN)
            {
                mStartPosition.x = (int)event.getX();
                mStartPosition.y = (int)event.getY();
                bringToFront();
                return true;
            }
            else if(event.getAction() == MotionEvent.ACTION_MOVE)
            {
                int x = 0, y = 0;

                x = (int)event.getX() - mStartPosition.x;            
                y = (int)event.getY() - mStartPosition.y;            

                mRegion.translate(x, y);
                mStartPosition.x = (int)event.getX();
                mStartPosition.y = (int)event.getY();

                invalidate();

                return true;
            }
            else
            {   
                return false;
            }
        }
    };
}

This is not a complete solution, you would also have to dispatch the touch events on the tiles so that they will take into account the scale of the InteractiveView.

Hope it will help you start !