Multi-Point Pinch Zoom Rotate

Posted by horacebury, Posted on December 2, 2011, Last updated January 15, 2012

2 votes

Previous example: https://developer.anscamobile.com/code/pinch-zoom-rotate

[EDIT: If you take a closer look at the main.lua, I had recently added code to pinch-zoom a display group, instead of an image. This means that the group can have a lower quality image at smaller scales and a larger image (lazily loaded) at larger scales. While this could be handled in the pinch library, I thought it better - for now - to handle it in a more client-controlled manner.]

The previous pinch-zoom I developed was my first fully successful attempt to provide 2 finger pinch zooming. It works fine, but is limited by requiring the use of a display group (which effectively uses Corona's behind-the-scenes code to perform the tougher calculations) and can only handle two touch points.

This code is not limited in that way...

- It will handle any number of touch points
- Using it is simply a matter of calling one function (requires no setup)
- One touch point simply moves the image around
- Does not require you to store any separately stored state information (though it does store some state info on the image object itself, called __pinchzoomdata)
- Does not require any support display objects (all calculations are handle internally)
- Each pinch iteration is atomic and so does not require a stored value to be built, it only requires the previous and current touch point locations to work (this is all handled internally)

Other than this, the supporting math library is not tied in function to the pinch library, though the pinch lib does require the math lib.

The operations it performs are translation (moving relative to the touch points), scaling (pinch zooming relative to the touch points) and rotation (turning the image relative to the touch points.)

Basically, it works how you'd expect it to.

Currently, it does not provide the stretchy bounding limits which apps like Photos do. Also, you need to manage your own touch points and pass them in as a table. This is not difficult and often it is most easily managed by having a touch listener on the image to be manipulated and creating tracking display objects within a specially reserved display group.

Oh, as always you need to enable multitouch as each touch event needs an ID and you should provide an image where mine is titled "yoda.png" at the top of main.lua

So, here's the three files - main.lua, pinchlib.lua and mathlib.lua:

Please note: Multiple points may not work as expected if you have iOS Gestures or Accessibility switched on.

main.lua:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
local pinchlibapi = require("pinchlib")
 
display.setStatusBar( display.HiddenStatusBar )
system.activate( "multitouch" )
 
--[[ There is no reason that the device environment could use display objects and stage:setFocus to track touch events... ]]--
 
--[[ This section handles the simulator interaction which is performed by display objects representing touches. ]]--
 
local stage = display.getCurrentStage()
 
-- use display group to allow image switching during scaling
local img = display.newGroup()
 
-- load smaller version of image
img.x1 = display.newImage( img, "yoda.png" )
img.x1.x, img.x1.y = 0, 0 -- place image in centre of pinched group
 
-- load larger version of image (this would normally be done lazily, to save memory)
img.x2 = display.newImage( img, "scene.png" )
img.x2.x, img.x2.y = 0, 0 -- place image in centre of pinched group
img.x2.xScale, img.x2.yScale = .5, .5 -- scale large version so it doesn't jump when coming into view
img.x2.alpha = 0 -- hide the larger version
 
img.x, img.y = display.contentCenterX, display.contentCenterY
 
-- handles calling the pinch for simulator
function simPinch()
        local points = {}
        for i=1, stage.numChildren do
                if (stage[i].name == "touchpoint") then
                        points[#points+1] = stage[i]
                end
        end
        pinchlibapi.doPinchZoom( img, points )
        
        -- handle the image switching
        if (img.xScale < 2) then
                img.x2.alpha = 0 -- hide the larger scale image if below 2x scale
        else
                img.x2.alpha = 1 -- show the higher quality image if scaled large enough
        end
        
        -- for simulator, print the scaling info
        print(img.xScale, img.yScale)
end
 
-- handles the simulator
function tap(event)
        local circle = display.newCircle(event.x, event.y, 25)
        circle.name = "touchpoint"
        circle.id = system.getTimer()
        circle.strokeWidth = 2
        circle:setStrokeColor(255,0,0)
        circle:setFillColor(0,0,255)
        circle.alpha = .6
        circle:addEventListener("tap", circle)
        circle:addEventListener("touch", circle)
        
        function circle:tap(event)
                circle:removeEventListener("tap",self)
                circle:removeEventListener("touch",self)
                circle:removeSelf()
                -- reset pinch data to avoid jerking the image when the average centre suddenly moves
                simPinch()
                return true
        end
        
        function circle:touch(event)
                if (event.phase == "began") then
                        stage:setFocus(circle)
                elseif (event.phase == "moved") then
                        circle.x, circle.y = event.x, event.y
                elseif (event.phase == "ended" or event.phase == "cancelled") then
                        circle.x, circle.y = event.x, event.y
                        stage:setFocus(nil)
                end
                
                simPinch()
                return true
        end
        
        simPinch()
        return true
end
 
--[[ This section handles device interaction which simply holds a list of the current touch events. ]]--
 
local touches = {}
 
-- handles calling the pinch for device
function devPinch( event, remove )
        -- look for event to update or remove
        for i=1, #touches do
                if (touches[i].id == event.id) then
                        -- update the list of tracked touch events
                        if (remove) then
                                table.remove( touches, i )
                        else
                                touches[i] = event
                        end
                        -- update the pinch
                        pinchlibapi.doPinchZoom( img, touches )
                        return
                end
        end
        -- add unknown event to list
        touches[#touches+1] = event
        pinchlibapi.doPinchZoom( img, touches )
        
        -- handle the image switching
        if (img.xScale < 2) then
                img.x2.alpha = 0 -- hide the larger scale image if below 2x scale
        else
                img.x2.alpha = 1 -- show the higher quality image if scaled large enough
        end
end
 
-- handles the device
function touch(event)
        -- handle the touch
        if (event.phase == "began") then
                pinchlibapi.doPinchZoom( img,{} )
                devPinch( event )
        elseif (event.phase == "moved") then
                devPinch( event )
        else
                pinchlibapi.doPinchZoom( img,{} )
                devPinch( event, true )
        end
end
 
--[[ This section attaches the appropriate touch/tap handler for the environment (simulator or device). ]]--
-- Please note that the XCode simulator will be handled as 'device' although it has no way to provide multitouch events.
 
if (system.getInfo( "environment" ) == "simulator") then
        Runtime:addEventListener("tap",tap) -- mouse being used to create moveable touch avatars
elseif (system.getInfo( "environment" ) == "device") then
        Runtime:addEventListener("touch",touch) -- fingers being used to create real touch events
end

pinchlib.lua:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
module(..., package.seeall)
 
local mathlibapi = require("mathlib")
 
-- requires a collection of touch points
-- each point must have '.id' to be tracked otherwise it will be ignored
-- each point must be in world coordinates (default state of touch event coordinates)
function doPinchZoom( img, points )
        -- must have an image to manipulate
        if (not img) then
                return
        end
        
        -- is this the end of the pinch?
        if (not points or not img.__pinchzoomdata or #points ~= #img.__pinchzoomdata.points) then
                -- reset data (when #points changes)
                img.__pinchzoomdata = nil
                
                -- exit if there are no calculations to do
                if (not points or #points == 0) then
                        return -- nothing to do
                end
        end
        
        -- get local ref to zoom data
        local olddata = img.__pinchzoomdata
        
        -- create newdata table
        local newdata = {}
        
        -- store img x,y in world coordinates
        newdata.imgpos = getImgPos( img )
        
        -- calc centre (build list of points for later - avoids storing actual event objects passed in)
        newdata.centre, newdata.points = getCentrePoints( points )
        
        -- calc distances and angles from centre point
        calcDistancesAndAngles( newdata )
        
        -- does pinching need to be performed?
        if (olddata) then
                -- translation of centre
                newdata.imgpos.x = newdata.imgpos.x + newdata.centre.x - olddata.centre.x
                newdata.imgpos.y = newdata.imgpos.y + newdata.centre.y - olddata.centre.y
                
                -- get scaling factor and rotation difference
                if (#newdata.points > 1) then
                        newdata.scalefactor, newdata.rotation = calcScaleAndRotation( olddata, newdata )
                else
                        newdata.scalefactor, newdata.rotation = 1, 0
                end
                
                -- scale around pinch centre (translation)
                newdata.imgpos.x = newdata.centre.x + ((newdata.imgpos.x - newdata.centre.x) * newdata.scalefactor)
                newdata.imgpos.y = newdata.centre.y + ((newdata.imgpos.y - newdata.centre.y) * newdata.scalefactor)
                
                -- rotate around pinch centre
                newdata.imgpos = mathlibapi.rotateAboutPoint( newdata.imgpos, newdata.centre, newdata.rotation, false )
                
                -- convert to local coordinates
                local x, y = img.parent:contentToLocal( newdata.imgpos.x, newdata.imgpos.y )
                
                -- apply pinch...
                img.x, img.y = x, y
                img.rotation = img.rotation + newdata.rotation
                img.xScale, img.yScale = img.xScale * newdata.scalefactor, img.yScale * newdata.scalefactor
        end
        
        -- store new data
        img.__pinchzoomdata = newdata
end
 
-- simply converts the display object's centre x,y into world coordinates
function getImgPos( img )
        local x, y = img:localToContent( 0, 0 )
        return { x=x, y=y }
end
 
-- calculates the centre of the points
-- generates a new list of points so we are not storing the list of events from calling code
function getCentrePoints( points )
        local x, y = 0, 0
        local newpoints = {}
        
        for i=1, #points do
                -- accumulate the centre values
                x = x + points[i].x
                y = y + points[i].y
                
                -- record the point with it's associated data
                newpoints[#newpoints+1] = { x=points[i].x, y=points[i].y, id=points[i].id }
        end
        
        -- return the list of points for next time and the centre point of this list
        return
                { x = x / #points, y = y / #points }, -- centre
                newpoints -- list of points
end
 
-- calculates the distance from the centre to each point and their angle if the centre is assumed to be 0,0
function calcDistancesAndAngles( data )
        for i=1, #data.points do
                data.points[i].length = mathlibapi.lengthOf( data.centre, data.points[i] )
                data.points[i].angle = mathlibapi.angleBetweenPoints( data.centre, data.points[i] )
        end
end
 
-- calculates the change in scale between the old and new points
-- also calculates the change in rotation around the centre point
-- uses their average change
function calcScaleAndRotation( olddata, newdata )
        local scalediff, anglediff = 0, 0
        
        for i=1, #newdata.points do
                local oldpoint = getPointById( newdata.points[i], olddata.points )
                
                scalediff = scalediff + newdata.points[i].length / oldpoint.length
                anglediff = anglediff + mathlibapi.smallestAngleDiff(newdata.points[i].angle, oldpoint.angle)
        end
        
        return
                scalediff / #newdata.points, -- scale factor
                anglediff / #newdata.points -- rotation average
end
 
-- returns the newpoint if it does not have a previous version, or the old point if it has simply moved
function getPointById( newpoint, points )
        for i=1, #points do
                if (points[i].id == newpoint.id) then
                        return points[i]
                end
        end
        return newpoint
end

mathlib.lua:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
module(..., package.seeall)
 
 
-- returns the distance between points a and b
function lengthOf( a, b )
    local width, height = b.x-a.x, b.y-a.y
    return math.sqrt(width*width + height*height)
end
 
-- converts degree value to radian value, useful for angle calculations
function convertDegreesToRadians( degrees )
--      return (math.pi * degrees) / 180
        return math.rad(degrees)
end
 
function convertRadiansToDegrees( radians )
        return math.deg(radians)
end
 
-- rotates a point around the (0,0) point by degrees
-- returns new point object
function rotatePoint( point, degrees )
        local x, y = point.x, point.y
        
        local theta = convertDegreesToRadians( degrees )
        
        local pt = {
                x = x * math.cos(theta) - y * math.sin(theta),
                y = x * math.sin(theta) + y * math.cos(theta)
        }
 
        return pt
end
 
-- rotates point around the centre by degrees
-- rounds the returned coordinates using math.round() if round == true
-- returns new coordinates object
function rotateAboutPoint( point, centre, degrees, round )
        local pt = { x=point.x - centre.x, y=point.y - centre.y }
        pt = rotatePoint( pt, degrees )
        pt.x, pt.y = pt.x + centre.x, pt.y + centre.y
        if (round) then
                pt.x = math.round(pt.x)
                pt.y = math.round(pt.y)
        end
        return pt
end
 
-- returns the degrees between (0,0) and pt
-- note: 0 degrees is 'east'
function angleOfPoint( pt )
        local x, y = pt.x, pt.y
        local radian = math.atan2(y,x)
        --print('radian: '..radian)
        local angle = radian*180/math.pi
        --print('angle: '..angle)
        if angle < 0 then angle = 360 + angle end
        --print('final angle: '..angle)
        return angle
end
 
-- returns the degrees between two points
-- note: 0 degrees is 'east'
function angleBetweenPoints( a, b )
        local x, y = b.x - a.x, b.y - a.y
        return angleOfPoint( { x=x, y=y } )
end
 
-- Takes a centre point, internal point and radius of a circle and returns the location of the extruded point on the circumference
-- In other words: Gives you the intersection between a line and a circle, if the line starts from the centre of the circle
function calcCirclePoint( centre, point, radius )
        local distance = lengthOf( centre, point )
        local fraction = distance / radius
        
        local remainder = 1 - fraction
        
        local width, height = point.x - centre.x, point.y - centre.y
        
        local x, y = centre.x + width / fraction, centre.y + height / fraction
        
        local px, py = x - point.x, y - point.y
        
        return px, py
end
 
-- returns the smallest angle between the two angles
-- ie: the difference between the two angles via the shortest distance
function smallestAngleDiff( target, source )
        local a = target - source
        
        if (a > 180) then
                a = a - 360
        elseif (a < -180) then
                a = a + 360
        end
        
        return a
end

I apologise for not having proper access to github right now!


Replies

flyingaudio
User offline. Last seen 23 hours 24 sec ago. Offline
Joined: 24 Mar 2011

Awesome work. I don't have a need for this right now, but I was curious. So, I loaded up the 3 lua files and put a graphic in. It worked like a charm. Thanks for sharing.

mroberti's picture
mroberti
User offline. Last seen 1 day 1 hour ago. Offline
Joined: 20 Nov 2010

HOLY CRAPMONKEY!!!

This is so fricking COOOL and what a nice, elegant way to just drop in the actual functionality while the libraries take care of the heavy lifting behind the scenes!!!!!! I cannot tell you how awesome this is!!!!

Horace send me a paypal addy, I want to buy you a cup of coffee or a beer! No, seriously!! This is frickin' tits on a ritz!

Incredibly stoked,
Mario

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

Hey thanks - but really, just use it for good :)

Like a lot of people here, I enjoy writing useful, elegant pieces of code. If you can use it and it works well, then I'm happy and I'll write more.

I'm currently working on some physics code to help trajectory plotting etc, so keep your fingers crossed I can get this out - it's kicking my ass right now.

M

Ps: If you still want to buy me a coffee, check out "Tiltopolis" on the iOS App Store :)

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

Well, I finally got it - trajectory plotting code for an object fired using applyForce:

http://developer.anscamobile.com/code/trajectory-plotting

me250
User offline. Last seen 17 weeks 4 days ago. Offline
Joined: 19 Dec 2011

Works Great! Thanks

nta84
User offline. Last seen 5 days 2 hours ago. Offline
Joined: 13 Dec 2011

How to use it with director class? It work fine until you change screen with director.

In simulator:
Before changing screen : one tap = one point --no problem
After changing screen: one tap = 2 points --the problem
changing screen: one tap = 3 points
.....................and so on...........

Device:
Before changing screen: -- no problem
After changing screen: devPinch( event, true ) --not removing touches

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

I'm afraid I don't know - I've not used Director. I assume it would be something to do with the display groups used.

ec2
User offline. Last seen 1 day 19 hours ago. Offline
Joined: 27 Jan 2011

This is great. Works really well. Thanks for sharing!

Satheesh's picture
Satheesh
User offline. Last seen 11 hours 53 min ago. Offline
Joined: 25 May 2011

You code libraries are super-awesome!!!!
Hats off!

info583
User offline. Last seen 3 hours 48 min ago. Offline
Joined: 23 Feb 2012

Great job! I've found this very useful in the project I'm working on, I rewrote it so that the camera pans only when two fingers touch the screen and zooming is disabled when the fingers are within a certain range of each other. (disabled the rotation)

However, I've found that the game don't register finger presses that are too close together, in my case I can't keep my index and middle finger together when trying to pan the camera, it only works if I keep them separated (only a small amount is needed though). This is feels awkward though.

I had the same problem with the official multitouch template, have you encountered this or might it be specific to the phone I'm trying it on?

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

IMHO, If touches are too close, they will be registered as one touch. Just how close are your fingers in this situation?

I tried to build the code so that utilising devs could strip out parts they wanted to change, like you've removed the scaling for certain scenarios.

info583
User offline. Last seen 3 hours 48 min ago. Offline
Joined: 23 Feb 2012

I see, that would definitely explain it. As close as they can be, like you would point towards something with two fingers or just the way it's most comfortable to swipe with two fingers.

It's great in that regard, being able to simulate multitouch is a lifesaver as well. Once again, thanks for a really great job!

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

Yep, I've noticed other apps registering close touches as one. Of course, from a logic point of view, you have to wonder that if someone has their fingers that close do they really intend to use them as one point effectively, or not...

tmapps's picture
tmapps
User offline. Last seen 1 day 13 hours ago. Offline
Joined: 9 Feb 2011

Thanks a lot for your work!!! It's a very useful library.

Only comment a problem that I have. It works in the simulator, but when I generate an apk to check it in my device, it doesn't work properly. It makes some strange rotations and changes in the image. Do anyone this problem too??

Best regards and thanks again for your work! It's amazing!!

mroberti's picture
mroberti
User offline. Last seen 1 day 1 hour ago. Offline
Joined: 20 Nov 2010

If you're using Director then it acts all whacky, which made me abandon this awesome, AWESOME piece of code. Something to do with the displaygroups that Director uses....now if you're not using Director, then.....Horace? You're up!

Satheesh's picture
Satheesh
User offline. Last seen 11 hours 53 min ago. Offline
Joined: 25 May 2011

I use director.. And tweaked the code a bit to suit the needs.

It works pretty awesome. I even have an app in the store using a modified version of this code.

I just removed the

1
2
3
local img = display.newGroup()
img.x1  blah blah
img.x2  blah blah

stuff and just assigned
1
img = object
where object is the display object which I want to transform.

Only drawback is you cannot have 2 different images. Like the images used here; one for smaller zoom and one for higher zoom.

mroberti's picture
mroberti
User offline. Last seen 1 day 1 hour ago. Offline
Joined: 20 Nov 2010

Dang, I tried but applied it to a displaygroup and it didn't register the "release" event and couldn't get around it, just like another user posted above. Ah well, I'm still doing fine with the other pinch zoom lib...I believe it was written by the same person!

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

Thanks for the support guys. I have not used the Director class as the Storyboard API came out just as I needed that type of functionality, however...

This touch API basically assumes that the image or display group you're working with is using the same coordinates that the touch events are working in, ie: world coordinates. This will cause some issues if you're using a display group manager which is playing by it's own rules. What you might want to do is translate the input touch values to content values before passing them to the doPinchZoom function.

Like I said, though, I can't guarantee that will work. Also, I've not tested this on Android, only iPhone, iPad and sim.

jch_apple's picture
jch_apple
User offline. Last seen 2 days 8 hours ago. Offline
Joined: 4 Apr 2010

It's certainly for me the most usefull piece of code, thank you Horace.

Does anybody made the "double tap" zooming to a given scale factor ?

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

I believe the double tap zoom is context sensitive. What I mean is that when you double tap an image, the page is centred and zoomed to fit the image full screen.
A different application would have to use it's own logic there.

jch_apple's picture
jch_apple
User offline. Last seen 2 days 8 hours ago. Offline
Joined: 4 Apr 2010

Thank you for your reply. I agree, but did you already create a function to "track" the double tap ? If yes is it available somewhere ?

Thank you in advance !

horacebury's picture
horacebury
User offline. Last seen 1 hour 30 min ago. Offline
Joined: 17 Aug 2010

Oh, no, sorry. It would not be hard to do because the tap event contains a property 'numTaps'.

jch_apple's picture
jch_apple
User offline. Last seen 2 days 8 hours ago. Offline
Joined: 4 Apr 2010

Thank you. The idea was to avoid re-develop something that already exists but you're right, with numTaps property we have just to adjust xScale and yScale to desired zoom and position the image correctly using tap coordinates.
One again thank you for you library.