Game Development Community

Pinch to Zoom and Swipe to Pan - iTGB 1.2

by Justin Mosiman · 05/28/2009 (7:27 pm) · 29 comments

Below is my resource to enable pinch to zoom and swipe to pan in iTGB 1.2. The pinch and swipe behave as you'd expect on any normal iPhone/iPod Touch application. The limitations to that are that the pinch cannot zoom in/out more than its maximum value, and the swipe does not have any momentum attached to it. While it is easy to come up with a formula to determine the momentum, there was a delay between lifting your finger and the camera moving (using setTargetCameraPosition). Because of that, I have decided not to include that code here. But if anybody gets a working fluid transition, please post it in the comments.

These additions assume you've completed Dave Calabrese's excellent multitouch resource.

Ok, lets get started.

Additions:
iPhoneOGLVideo.h (around line 75):
CGFloat currentAngle;//for knowing our current oriantion
    
    NSMutableArray *activeTouches; // stores the latest position of all of the touches

iPhoneOGLVideo.mm (around line 290):
@property (nonatomic, retain) EAGLContext *context;
@property (nonatomic, retain) NSMutableArray *activeTouches;

iPhoneOGLVideo.mm (around line 304):
@synthesize context;
@synthesize activeTouches;

iPhoneOGLVideo.mm (around line 354):
activeTouches = [[NSMutableArray alloc] init];
    
    return self;

iPhoneOGLVideo.mm (around line 456):
- (void)dealloc {
        
    if ([EAGLContext currentContext] == context) {
        [EAGLContext setCurrentContext:nil];
    }
    
    [activeTouches release];
    [context release];    
    [super dealloc];
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSUInteger touchCount = [activeTouches count];
    // Enumerates through all touch objects
    for (UITouch *touch in touches){
        CGPoint point = [touch locationInView:self];
        // PUAP -Mat platform::init sets the window to 480x480 for easy rotation, which means this needs to be done to
        //keep the curser at the right point, if we are in landscape
        if( platState.portrait == false ) {
            point.y -= (480 - 320);    
        }
        createMouseDownEvent( touchCount, point.x, point.y );
        [activeTouches addObject:[NSValue valueWithCGPoint:point]];
        touchCount++;
    }
}



extern Vector<Event*> TouchMoveEvents;



- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    
    NSEnumerator *touchEnum = [activeTouches objectEnumerator];
    NSValue *touchPointValue;

    NSUInteger touchCount = 0;
    // Enumerates through all touch objects
    for (UITouch *touch in touches){
        CGPoint point = [touch locationInView:self];
        // PUAP -Mat platform::init sets the window to 480x480 for easy rotation, which means this needs to be done to
        //keep the curser at the right point, if we are in landscape
        if( platState.portrait == false ) {
            point.y -= (480 - 320);    
        }
        
        // Figure out the correct touch count
        touchCount = 0;
        while(touchPointValue = [touchEnum nextObject] ){
            if([touchPointValue CGPointValue].x == [touch previousLocationInView:self].x &&
                [touchPointValue CGPointValue].y == ([touch previousLocationInView:self].y - (platState.portrait == false ? 160 : 0))){
                [activeTouches replaceObjectAtIndex:touchCount withObject:[NSValue valueWithCGPoint:point]];
                break;
            }
            touchCount++;
        }
        createMouseMoveEvent( touchCount, point.x, point.y );
        touchEnum = [activeTouches objectEnumerator];
    }
    
 }

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    
    NSEnumerator *touchEnum = [activeTouches objectEnumerator];
    NSValue *touchPointValue;
    
    NSUInteger touchCount = 0;
    // Enumerates through all touch objects
    for (UITouch *touch in touches){
        CGPoint point = [touch locationInView:self];
        // PUAP -Mat platform::init sets the window to 480x480 for easy rotation, which means this needs to be done to
        //keep the curser at the right point, if we are in landscape
        if( platState.portrait == false ) {
            point.y -= (480 - 320);    
        }
        
        // Figure out the correct touch count
        touchCount = 0;
        while(touchPointValue = [touchEnum nextObject] ){
            if([touchPointValue CGPointValue].x == ([touch locationInView:self].x) &&
               [touchPointValue CGPointValue].y == ([touch locationInView:self].y - (platState.portrait == false ? 160 : 0))){
                [activeTouches removeObjectAtIndex:touchCount];
                break;
            }
            touchCount++;
        }
        
        createMouseUpEvent( touchCount, point.x, point.y );
        touchEnum = [activeTouches objectEnumerator];
    }
}

Modifications to Dave's resource:

iPhoneInput.mm (around line 966, 981, 995. MAKE SURE YOU MAKE THREE ADDITIONS. Adding the touchNumber to mouse move,down, and up)
event.yPos = y;
   event.action = SI_MAKE;
   event.touchNumber = touchNumber;

event.h (around line 150):
struct ScreenTouchEvent : public Event  
{  
   S32 xPos, yPos;  
   U8    action;
   S32   touchNumber;

The following are some changes from Dave's resource. While Dave's resource would work well for more than two touches, we only want two, so I'm calling the first touch onMouseDown and the second onRightMouseDown.

EDIT: Forgot to add mMouseRightButtonDown's and mMouseRightButtonEvent's declaration, thank you Nathan.

guiCanvas.h (around line 101):
bool                       mMouseButtonDown;       ///< Flag to determine if the button is depressed
   bool                       mMouseRightButtonDown;  ///< Flag to determine if the right button is depressed
   bool                       mMouseMiddleButtonDown; ///< Middle button flag
   GuiEvent                   mLastEvent;
   GuiEvent                   mMouseRightButtonEvent;

Still guiCanvas.h, I kept the method Dave created, processScreenTouchEvent, but I removed rootScreenTouchUp and rootScreenTouchDown.

Edit: another piece of the code that I forgot to add
guiCanvas.cc (around line 297):
mMouseButtonDown = false;
   mMouseRightButtonDown = false;
   mMouseMiddleButtonDown = false;

guiCanvas.cc (around line 425):
void GuiCanvas::processScreenTouchEvent(const ScreenTouchEvent *event)
{
   //copy the cursor point into the event
   mLastEvent.mousePoint.x = S32(event->xPos);
   mLastEvent.mousePoint.y = S32(event->yPos);
	
   //see if button was pressed
   if (event->action == SI_MAKE)
   {
      //  printf("Touch number %d ",event->touchNumber);
      U32 curTime = Platform::getVirtualMilliseconds();
	  mNextMouseTime = curTime + mInitialMouseDelay;
		
	  mLastMouseDownTime = curTime;
	  mLastEvent.mouseClickCount = mLastMouseClickCount;
	   
      if(event->touchNumber == 0){
         if(mMouseButtonDown)
            rootMouseDragged(mLastEvent);
         else
            rootMouseDown(mLastEvent);
	  }else if(event->touchNumber == 1){
         if(mMouseRightButtonDown)
            rootRightMouseDragged(mLastEvent);
         else
            rootRightMouseDown(mLastEvent);
	  }
   }
   //else button was released
   else
   {
      if(event->touchNumber == 0){
         rootMouseUp(mLastEvent);
		 
		 if(mMouseRightButtonDown){
			// If the right mouse button is down, it now becomes the mouse button.  Therefore, we need to let the right mouse method know that
			// we are leaving it and instead going to the first mouse button.
			rootRightMouseUp(mMouseRightButtonEvent);
		    rootMouseDown(mMouseRightButtonEvent);
            mMouseRightButtonDown = false;
		 }
      }else if(event->touchNumber == 1){
         rootRightMouseUp(mLastEvent);
      }
      if(!mMouseButtonDown && !mMouseRightButtonDown)
         mNextMouseTime = 0xFFFFFFFF;
   }
}

guiCanvas.cc (around line 892):
void GuiCanvas::rootRightMouseDown(const GuiEvent &event)
{
   mPrevMouseTime = Platform::getVirtualMilliseconds();
   mMouseRightButtonDown = true;
   mMouseRightButtonEvent = event;

guiCanvas.cc (around line 926):
void GuiCanvas::rootRightMouseDragged(const GuiEvent &event)
{
   mPrevMouseTime = Platform::getVirtualMilliseconds();
   mMouseRightButtonEvent = event;

The last part of my additions are a complete new class, which I called t2dPlaySceneWindow. Create a new file called t2dPlaySceneWindow.h and t2dPlaySceneWindow.cc in your project under the folder T2D. Then add the following:

t2dPlaySceneWindow.h:
//-----------------------------------------------------------------------------
// 2D Play Scene Window - the window used to actually play the game. 
// Players can pan the camera using the swipe motion and zoom by pinching the window.
//-----------------------------------------------------------------------------

#ifndef _T2DPLAYSCENEWINDOW_H_
#define _T2DPLAYSCENEWINDOW_H_

#ifndef _T2DSCENEWINDOW_H_
#include "./t2dSceneWindow.h"
#endif


class t2dPlaySceneWindow : public t2dSceneWindow
{
    typedef t2dSceneWindow Parent;

private:
	// Although technically we aren't dealing with mouse clicks, I am treating the first finger to touch the screen as mMousePoint
	// and the second mRightMousePoint
	Point2I mMousePoint;
	Point2I mRightMousePoint;
	bool mRightMouseDown;
	t2dVector mScenePrevMousePoint; // The scene coordinates are used for object selection and panning
	
	// Camera swipe/pan
	Point2I mMinCameraViewLimit;
	Point2I mMaxCameraViewLimit;
	
	// Camera pinch/zoom
	F32 mPreviousPinchDistance;
	F32 mMinCameraZoomLimit;
	F32 mMaxCameraZoomLimit;

public:

	t2dPlaySceneWindow();

	static void initPersistFields();

	virtual bool onAdd();

    void onMouseDown( const GuiEvent& event );
    void onMouseDragged( const GuiEvent& event );
	void onMouseUp( const GuiEvent& event );
    void onRightMouseDown( const GuiEvent& event );
	void onRightMouseDragged( const GuiEvent& event );
	void onRightMouseUp( const GuiEvent& event );
	
	void pinchZoom();

    /// Declare Console Object.
    DECLARE_CONOBJECT(t2dPlaySceneWindow);
};

#endif // _T2DPLAYSCENEWINDOW_H_

t2dPlaySceneWindow.cc:
#include "./t2dPlaySceneWindow.h"

IMPLEMENT_CONOBJECT(t2dPlaySceneWindow);

// declare the functions first, a simple 2D distance function
F32 distance(Point2I mousePoint, Point2I rightMousePoint)
{	
	return sqrt(pow(mousePoint.x - rightMousePoint.x,2)+pow(mousePoint.y - rightMousePoint.y,2));
}

t2dPlaySceneWindow::t2dPlaySceneWindow()
{
	
	mMousePoint = Point2I(0,0);
	mRightMousePoint = Point2I(0,0);
	mRightMouseDown = false;
	
	mMinCameraViewLimit.set(0,0);
	mMaxCameraViewLimit.set(0,0);
	
	mPreviousPinchDistance = 0.0f;
	mMinCameraZoomLimit = 0.1f;
	mMaxCameraZoomLimit = 1.0f;
}

//-----------------------------------------------------------------------------
// Initialise Persistent Fields.
//-----------------------------------------------------------------------------
void t2dPlaySceneWindow::initPersistFields()
{
    // Call Parent.
   Parent::initPersistFields();

   // Add Fields.
   addField( "MinCameraViewLimit", TypePoint2I, Offset(mMinCameraViewLimit, t2dPlaySceneWindow) );
   addField( "MaxCameraViewLimit", TypePoint2I, Offset(mMaxCameraViewLimit, t2dPlaySceneWindow) );
}

//-----------------------------------------------------------------------------
// OnAdd
//-----------------------------------------------------------------------------
bool t2dPlaySceneWindow::onAdd()
{
    if(!Parent::onAdd())
        return false;
	
    // If the min camera view limit is not the same as the max, we have a camera limit
	if(mMinCameraViewLimit != mMaxCameraViewLimit){
		setViewLimitOn(t2dVector(mMinCameraViewLimit.x,mMinCameraViewLimit.y),t2dVector(mMaxCameraViewLimit.x,mMaxCameraViewLimit.y));
	}
    return true;
}

//-----------------------------------------------------------------------------
// Mouse Event Handler.
//-----------------------------------------------------------------------------

void t2dPlaySceneWindow::onMouseDown( const GuiEvent& event )
{
	Parent::onMouseDown(event);
	
	mMousePoint = event.mousePoint;		
}

void t2dPlaySceneWindow::onMouseDragged( const GuiEvent& event )
{
	Parent::onMouseDragged(event);

	if(mRightMouseDown){
		// perform a pinch
		mMousePoint = event.mousePoint;	
		pinchZoom();
	}else{		
		// perform a swipe
		t2dVector sceneMousePoint;
		windowToSceneCoord(t2dVector(event.mousePoint.x,event.mousePoint.y), sceneMousePoint);
		windowToSceneCoord(t2dVector(mMousePoint.x,mMousePoint.y), mScenePrevMousePoint);
		
		setTargetCameraPosition(t2dVector(getCurrentCameraPosition().mX+(1.1*(mScenePrevMousePoint.mX - sceneMousePoint.mX)),getCurrentCameraPosition().mY),
								getCurrentCameraWidth(),getCurrentCameraHeight());
		startCameraMove( 0.0f );
		mMousePoint = event.mousePoint;	
	}
}

void t2dPlaySceneWindow::onMouseUp( const GuiEvent& event )
{
	Parent::onMouseUp(event);
	
	// The right mouse button could still be down if you lifted your first finger up before your second.
	// The second finger now becomes the first finger (onMouseDown will be called for the new first finger)
	if(mRightMouseDown){
		mRightMouseDown = false;
	}
}

void t2dPlaySceneWindow::onRightMouseDown( const GuiEvent& event )
{
	Parent::onRightMouseDown(event);
	
	mRightMousePoint = event.mousePoint;
	mRightMouseDown = true;
	
	mPreviousPinchDistance = distance(mMousePoint, mRightMousePoint);
}

void t2dPlaySceneWindow::onRightMouseDragged( const GuiEvent& event )
{
	Parent::onRightMouseDragged(event);
	
	mRightMousePoint = event.mousePoint;
	
	// If the right mouse was dragged, we have a left mouse so definitely need to do a pinch zoom
	pinchZoom();
}

void t2dPlaySceneWindow::onRightMouseUp( const GuiEvent& event )
{
	Parent::onRightMouseUp(event);
	
	mRightMouseDown = false;
}

void t2dPlaySceneWindow::pinchZoom()
{
	F32 currentPinchDistance = distance(mMousePoint, mRightMousePoint);
	F32 targetZoom = getCurrentCameraZoom();
	
	if(currentPinchDistance < mPreviousPinchDistance){
		// Zoom out
		F32 zoomLength = getCurrentCameraZoom() - mMinCameraZoomLimit;
		targetZoom -= 0.0002 * zoomLength * currentPinchDistance;
	}else if(currentPinchDistance > mPreviousPinchDistance){
		// Zoom in
		F32 zoomLength = mMaxCameraZoomLimit - getCurrentCameraZoom();
		targetZoom += 0.0002 * zoomLength * currentPinchDistance;
	}
	
	if(targetZoom < mMinCameraZoomLimit)
		targetZoom = mMinCameraZoomLimit;
	else if(targetZoom > mMaxCameraZoomLimit)
		targetZoom = mMaxCameraZoomLimit;
	
	mPreviousPinchDistance = currentPinchDistance;
	
	setTargetCameraZoom(targetZoom);
	startCameraMove( 0.0f );
}

In your GUI file, you're going to have to change t2dSceneWindow to t2dPlaySceneWindow.

I wasn't able to figure out a perfect algorithm for the pinch, so you'll probably have to modify the constant (line 132/136) to suite your needs based off of how close and far you want to allow (mMinCameraZoomLimit and mMaxCameraZoomLimit).

If I missed anything or you see any improvements that can be made, please let me know. Otherwise, we have pinch to zoom and swipe to pan on the iPhone!!

About the author

Recent Blogs

Page «Previous 1 2
#1
05/28/2009 (9:28 pm)
Outstanding work!

You and Dave Calabrese are posting some quality resources for iTorque. Don't be surprise if some of this makes its way back into the stock engine =)
#2
05/29/2009 (7:37 am)
Awesome I'll give it a try very soon. I've been wanting to add Pinch-Zoom for a while now but haven't had a chance to get around to it. This will save me a boatload of time!
#3
05/29/2009 (3:01 pm)
Great job Justin!
#4
05/29/2009 (7:58 pm)
@Michael
Awesome! If you are going to put parts of it in the stock engine, make sure you modify processMultipleTouches within iPhoneInput.mm. For my game I just removed it since I won't need any more than two touches and it would be called every tick for no reason. I left it in though for this resource since some people might be using the TorqueScript calls and I didn't want to mess anything up on that front. But I'm sure it could be improved to fit this new method of handing input.

@Bret
Good to hear!

@Matthew
Thanks!
#5
06/24/2009 (7:15 am)
I will likely be applying this towards my next game, as the code looks promising, but can you provide sample TorqueScript code on how it works or how it should be used?
#6
06/24/2009 (7:33 pm)
The only TorqueScript code that you need to do after you have made all of the engine changes is to change your t2dSceneWindow to t2dPlaySceneWindow which is defined in your GUI file. Everything else will happen automatically, no TorqueScript additions needed.
#7
06/25/2009 (10:42 am)
rootRightMouseUp(mMouseRightButtonEvent);
rootMouseDown(mMouseRightButtonEvent);

In guiCanvas.cc around line 62 the variable mMouseRightButton is not in the scope. Is this a spelling mistake or did I just miss something?

Thanks
#8
06/25/2009 (5:07 pm)
Thank you Nathan, I forgot to copy that over. I forgot the declarations for mMouseRightButtonDown and mMouseRightButtonEvent. I've edited the resource and changed the following:

guiCanvas.h (around line 101):
bool                       mMouseButtonDown;       ///< Flag to determine if the button is depressed
   bool                       mMouseRightButtonDown;  ///< Flag to determine if the right button is depressed
   bool                       mMouseMiddleButtonDown; ///< Middle button flag
   GuiEvent                   mLastEvent;
   GuiEvent                   mMouseRightButtonEvent;

guiCanvas.cc (around line 297):
mMouseButtonDown = false;
   mMouseRightButtonDown = false;
   mMouseMiddleButtonDown = false;
#9
06/26/2009 (8:14 am)
The pan and zoom work for a little while but then the device stops taking the touch inputs. Any idea whats is happen here?
#10
06/26/2009 (9:49 am)
I've never seen that happen before. I would suggest starting from the beginning with break points or output statements and then drill your way down until you can find the problem.

For example, start with touchesBegan in iPhoneOGLVideo.mm. If everything there seems to be working move onto processScreenTouchEvent of guiCanvas.cc.

If you find a problem with it let me know and I'll try to help debug it. I'm sorry I can't be of more help, I haven't had any problems with it since publishing this resource.
#11
06/29/2009 (5:57 am)
Have you test this code on the device? The code works in iTGB but on the device it doesn't work.
#12
06/29/2009 (6:18 am)
Yes, it is working on the device.

The one thought that I had if it doesn't work on the device is do you have $pref::iPhone::EnableMultipleTouch enabled? This has to be enabled for it to work.
#13
06/30/2009 (1:27 pm)
It is enabled
#14
07/25/2009 (4:09 am)
@Nathan: I'm having similar problems with this. For me, it looks like the problem is that the touchcount is never getting properly reset, so certain numbers are always having 1 added to them, and others are incorrect. Not sure the exact cause yet... you see anything like that when working on this, Justin?

EDIT: The problem looks to be in iPhoneOGLVideo.mm, where we actually count the touches. Something here is reporting that the value never decreases. So touchCount just starts adding up forever.

There are 3 functions for this of course - up, down and move. Here is the 'down' version of the event:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {  
    NSUInteger touchCount = [activeTouches count];  
    // Enumerates through all touch objects  
    for (UITouch *touch in touches){  
        CGPoint point = [touch locationInView:self];  
        // PUAP -Mat platform::init sets the window to 480x480 for easy rotation, which means this needs to be done to  
        //keep the curser at the right point, if we are in landscape  
        if( platState.portrait == false ) {  
            point.y -= (480 - 320);      
        }  
        createMouseDownEvent( touchCount, point.x, point.y );  
        [activeTouches addObject:[NSValue valueWithCGPoint:point]];  
        touchCount++;  
    }  
}

Our for loop is based on touches. Digging into that more now... sharing information here in case anyone else is also working on this and we can compare notes.
#15
07/25/2009 (5:30 am)
Okay... I'm getting some sleep then picking this back up later. There is certainly something wrong here, however... I'm seeing that this is reporting that the first time I touch down, it is 0. Then I touch up and it is touch 1. Then touch down and it is touch 2. Then touch up and it is touch 3. So either something is wrong somewhere else, or this may be implemented wrong in iTGB... meaning that the iPhone returns the actual touch count, not the active touch. Which if that's true, then we need to change how we handle touches, or else pinch/swipe/zoom will work the first time or two you use it, then after that never again until you restart the game.

Anyone who has solved this, is able to get the pinch/swipe/zoom to work without making these changes, or anyone who has other information, please post it. I'd like to get this figured out so we can move forward. :)

Thanks!
#16
07/25/2009 (6:56 am)
I'll take a look at this when I can get back to my computer, specifically looking for if I missed posting anything with touchCount
#17
07/25/2009 (8:55 am)
I just went back over my code and the code that I posted here, and I did post everything that I had with touchCount.

Dave, you said that touchCount is incrementing with every type of touch that you do (down,move,up). Can you do a simple test for me:

Touch down with one finger, make sure touchCount is zero.
Touch up with that finger, touchCount should not be zero. If that isn't the case, it isn't hitting this break statement in touchesEnded:
while(touchPointValue = [touchEnum nextObject] ){  
             if([touchPointValue CGPointValue].x == ([touch locationInView:self].x) &&  
                [touchPointValue CGPointValue].y == ([touch locationInView:self].y - (platState.portrait == false ? 160 : 0))){  
                 [activeTouches removeObjectAtIndex:touchCount];  
                 break;  
             }  
             touchCount++;  
         }

Is that correct? It's strange that you guys are seeing this, I must not have posted everything and it is just a matter of figuring out where. This has worked for me perfectly since I posted it in May.
#18
07/25/2009 (12:35 pm)
Justin,

I just ran the test. You are correct - the break statements found in touchesMoved and touchesEnded are not getting called. So our conditions are never getting met... hrrrmmmmm....

Something about that conditional code looks odd to me. It's almost like we're checking to see if the point is identical to the location of a previous point to determine if we remove it from the stack. However, we'll almost never have the exact same point... it would have moved... or am I reading this wrong?
#19
07/25/2009 (1:06 pm)
Quote:
It's almost like we're checking to see if the point is identical to the location of a previous point to determine if we remove it from the stack.

That is almost what it is doing for touchesMoved. We are checking two previous points, not a current and previous. The reason for this is that the iPhone keeps a record of the previous location for each touch in [touch previousLocationInView:self]. I then created an enumerator to go through activeTouches (touchEnum) that will try to match to two. In touchesEnded I don't have to compare the enumerator with previousLocationInView because the position doesn't change, just the state.

Can you send me your iPhoneOGLVideo.mm file to justin@opsive.com and I will do a diff between mine and yours to be sure there is nothing that I left out?
#20
09/06/2009 (11:09 am)
Hm, cant understand with error - "error: 'mRightMouseLastEvent' was not declared in this scope"
in guiCanvas.cc:
void GuiCanvas::rootRightMouseDown(const GuiEvent &event)
{
   mPrevMouseTime = Platform::getVirtualMilliseconds();
   mMouseRightButtonDown = true;
   mRightMouseLastEvent = event;
Page «Previous 1 2