Share Your Code

Pinch Zoom Rotate

Posted by horacebury, Posted on November 27, 2011, Last updated November 28, 2011

For some time I have been wanting to write a usable pinch zoom with rotate algorithm. Finally, I think I have a serviceable piece.

So, first off, usage:

Give the code an image (I'm using yoda.jpg)
Run the code
Tap once to create the first or two touch points
Tap somewhere else to create the second of the two touch points
Drag one of the two touch points around to scale, rotate and move the image
Tap once anywhere to remove the touch points and begin again

The ultimate effect:

What happens is that the two blue/red circles represent a pair of points used to pinch/zoom an image, just like in Apple's "Photo" app. The touch points remain where they are on the image when moved. The means that the image must be moved and scaled appropriately. Therefore, the rotation is based on the angle of the first point from the second. The scaling is based on the change of distance between the two points. The translation of the image happens around the point precisely halfway between the two touch points. That is also where all of the rotation and scaling of the image is applied.

Simulator:

I have only implemented single touch tracking, so if you want proper multitouch you will have to implement this yourself, but that is a pretty good exercise for any Corona developer. Obviously, if you are doing this in the simulator you need to double tap the red/blue touch points to move them and you can only move one at a time.

Blobs:

The blue circles with red outlines represent the touch points of, say, fingers on the device.
The white circle shows the mid-point between the touch points and scales with the change of distance between the touch points.
The green dot is the centre of the image and moves relative to the white mid-point, with scaling and rotation affected.

What is really happening:

A display group is added to the global display group stack. The white circle and green dot are added to this. The touch points are added to the global stack. In your code these would be handled elsewhere and probably not rendered. Using the mathlib library, the display group is moved, rotated and scaling and the image is translated to keep centred on the display group's centre. This is the real trick: The display group is moved, rotated and scaled and those are then mapped onto the img. At no point is the image transposed into the display group - it remains where you put it in the display hierarchy at all times. Finally, the positions in the system are calculated using localToContent and contentToLocal, so the img and display group can be added to their own parent display groups and the result should be the same. The reason here is that two fingers on the screen basically only ever work in content coordinates.

Anyway, have fun - here is all the code (provide your own image)...

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
local mathlibapi = require("mathlib")
 
display.setStatusBar( display.HiddenStatusBar )
 
local panel = nil
local stage = display.getCurrentStage()
 
local img = display.newImage( "yoda.png" ) -- you need to provide your own image here!!!
img.x, img.y = display.contentCenterX, display.contentCenterY
 
-- x_local, y_local = object:contentToLocal(x_content, y_content)
-- x_content, y_content = object:localToContent(x, y)
 
-- create a display object which will be directly rotated and scaled around it's own centre
function beginPinch(img, grip)
        panel = display.newGroup()
        panel.img = img
        panel.grip = grip
        panel.initAngle = mathlibapi.angleBetweenPoints( panel.grip[1], panel.grip[2] )
        panel.initImgAngle = img.rotation
        panel.initImgXScale, panel.initImgYScale = img.xScale, img.yScale
        panel.initScale = panel.xScale
        panel.x, panel.y = grip[1].x + (grip[2].x-grip[1].x) / 2, grip[1].y + (grip[2].y-grip[1].y) / 2
        
        panel.initLength = mathlibapi.lengthOf( grip[1], grip[2] )
        print('initlen',panel.initLength)
        
        -- get x,y of img in world
        local x, y = img:localToContent(0,0)
        -- get x,y of img in panel
        x, y = panel:contentToLocal(x,y)
        
        panel.centre = display.newCircle( panel, 0, 0, 50 )
        
        panel.target = display.newCircle( panel, x, y, 10 )
        panel.target:setFillColor( 0,255,0 )
        
        panel.alpha = .7
end
 
-- manipulate the display object and apply the changes to the target img
function doPinch()
        -- rotation
        local a = mathlibapi.angleBetweenPoints( panel.grip[1], panel.grip[2] )
        print( 'angle', a - panel.initAngle )
        panel.rotation = a - panel.initAngle
        panel.img.rotation = panel.initImgAngle + (a - panel.initAngle)
        panel.x, panel.y = (panel.grip[1].x + panel.grip[2].x) / 2, (panel.grip[1].y + panel.grip[2].y) / 2
        
        -- scaling
        local len = mathlibapi.lengthOf( panel.grip[1], panel.grip[2] )
        panel.xScale = panel.initScale * (len / panel.initLength)
        panel.yScale = panel.initScale * (len / panel.initLength)
        panel.img.xScale = panel.initImgXScale * (len / panel.initLength)
        panel.img.yScale = panel.initImgYScale * (len / panel.initLength)
        print('scale',panel.xScale,panel.yScale)
        
        -- location
        -- convert target to world, then to panel.img.parent coords
        local x, y = panel.target:localToContent(0,0)
        x, y = panel.img.parent:contentToLocal(x, y)
        -- set img x,y
        panel.img.x, panel.img.y = x, y
        
        return true
end
 
-- remove the manipulation object
function endPinch()
        panel:removeSelf()
        panel = nil
        
        stage[2]:removeSelf()
        stage[2]:removeSelf()
        
        Runtime:removeEventListener( "tap", endPinch )
        timer.performWithDelay( 1, function() Runtime:addEventListener("tap",tap); end )
end
 
function pinch( event )
        if (event.phase == "began") then
                stage:setFocus( event.target )
        elseif (event.phase == "moved") then
                event.target.x, event.target.y = event.x, event.y
                doPinch()
        else
                stage:setFocus( nil )
        end
        return true
end
 
function tap(event)
        local circle = display.newCircle( event.x, event.y, 25 )
        circle:setStrokeColor( 255,0,0 )
        circle:setFillColor( 0,0,255 )
        circle.strokeWidth = 4
        circle.alpha = .7
        
        print(stage.numChildren)
        if (stage.numChildren == 3) then
                Runtime:removeEventListener( "tap", tap )
                stage[2]:addEventListener( "touch", pinch )
                stage[3]:addEventListener( "touch", pinch )
                timer.performWithDelay( 1, function() Runtime:addEventListener( "tap", endPinch ); end )
                beginPinch(img, { stage[2], stage[3] } )
        end
        return true
end
Runtime:addEventListener("tap",tap)

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
99
100
101
102
module(..., package.seeall)
 
 
function lengthOf( a, b )
    local width, height = b.x-a.x, b.y-a.y
    return math.sqrt(width*width + height*height)
end
 
function convertDegreesToRadians( degrees )
        return (math.pi * degrees) / 180
end
 
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
 
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
 
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
 
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
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
 
-- is clockwise is false this returns the shortest angle between the points
function AngleDiff( pointA, pointB, clockwise )
        local angleA, angleB = AngleOfPoint( pointA ), AngleOfPoint( pointB )
        
        if angleA == angleB then
                return 0
        end
        
        if clockwise then
                if angleA > angleB then
                        return angleA - angleB
                        else
                        return 360 - (angleB - angleA)
                end
        else
                if angleA > angleB then
                        return angleB + (360 - angleA)
                        else
                        return angleB - angleA
                end
        end
        
end
 
--[[
local pointA = { x=10, y=-10 } -- anticlockwise 45 deg from east
local pointB = { x=-10, y=-10 } -- clockwise 45 deg from east
 
print('Angle of point A: '..tostring(AngleOfPoint( pointA )))
print('Angle of point B: '..tostring(AngleOfPoint( pointB )))
 
print('Clockwise: '..tostring(AngleDiff(pointA,pointB,true)))
print('Anti-Clockwise: '..tostring(AngleDiff(pointA,pointB,false)))
]]--


Replies

MBD
User offline. Last seen 16 hours 42 min ago. Offline
Joined: 14 Sep 2010

How would you completely reset all of the touch events and start over from the same stage? I've tried clearing the table but that didn't work.

horacebury
User is online Online
Joined: 17 Aug 2010

Take a look at my latest pinch zoom effort:
http://developer.coronalabs.com/code/pinchzoom-made-real-easy