Pinchzoom Image with Adobe Air

24. December 2012 10:06 by admin in actionscript, air, pinchzoom, image
For a recent project I needed to create an image view with pinchzoom similar to the gallery application on iOS. To my surprise this was not very trivial and online resources were limited. Hence this sample project. I use the touchbegin,touchmove,touchmove events to keep track of the users fingers. Because touchend is not always fired I also require a touch to be 'seen' every X milliseconds. Otherwise I remove it from my touchpoints administration. 

The code is based on the idea the location in 'image coordinates' below a users finger should remain the same while moving. If the user moves a finger we need to move/scale the image so that it snaps to the users finger. You can think of it like this

Ps=Pi*S + Poffset

This means that the screen position Ps is equal to image postion Pmultiplied by the scale S and added to an offset. We determine the new scale by looking at the distance between the two fingers. The new scale is newDistance/oldDistance. If the distance between the fingers increases the scale increases. Now that we know Ps,Pi and S we can calculate Poffset.

The core of the algorithm is in the touchmove event. This is the starting point for understanding the code. You can also just use the component like a normal image and set the MaxZoom to specifiy a maximal zoom level. Like this:
<components:PinchZoomImage width="100%" height="100%" source="@Embed('/assets/high.jpg')" MaxZoom="5" />

I added a sample project in zip file pinchzoom.zip (1,73 MB) as well.
The pinchzoomimage code:
<?xml version="1.0" encoding="utf-8"?>
<s:Group xmlns:fx="http://ns.adobe.com/mxml/2009" 
        xmlns:s="library://ns.adobe.com/flex/spark" clipAndEnableScrolling="true" 
creationComplete="creationCompleteHandler(event)" touchBegin="container_touchBeginHandler(event)"
 touchEnd="container_touchEndHandler(event)" touchMove="container_touchMoveHandler(event)" >
    
    <fx:Script>
        <![CDATA[
            import mx.collections.ArrayCollection;
            import mx.events.FlexEvent;
            
            import components.TouchPoint;
            
            private var startImageX : Number;
            private var startImageY : Number;
            private var touchIsDown : Boolean;
            
            private var startX : Number;
            private var startY : Number;
            
            //dont' update on every move            
            private var SkipCount : Number = 0;            
            
            [Bindable]
            public var source: Object;
            
            private const Margin : Number =10; 
            private var lastX : Number;
            private var lastY : Number;
            private var ImageIsWide : Boolean;
            
            
            public var MaxZoom : Number = 1.5;
            private var maxScale : Number;
            
            private var TouchPoints : ArrayCollection = new ArrayCollection();
            
            protected function creationCompleteHandler(event:FlexEvent):void
            {
                flash.ui.Multitouch.inputMode = MultitouchInputMode.TOUCH_POINT;        
                
                var scale: Number =Math.min(width / image.width,height / image.height);
                image.scaleX = image.scaleY = scale;
                
                ImageIsWide = (image.width / image.height) > (width / height);
                
                if (ImageIsWide)
                {
                    image.y = (height -image.height*image.scaleY ) /2 ;
                }
                else
                {
                    image.x = (width -image.width*image.scaleX ) /2 ;
                }
                
                maxScale = scale*MaxZoom;
            }        
            
            protected function container_touchBeginHandler(event:TouchEvent):void
            {
                trace("down");
                // TODO Auto-generated method stub                
                TouchPoints.addItem(new TouchPoint(event.touchPointID,event.stageX,event.stageY));
            }    
            
            protected function container_touchEndHandler(event:TouchEvent):void
            {
                trace("up");
                // TODO Auto-generated method stub                
                var p = GetTouchPointById(event.touchPointID);
                if (p != null)
                {
                    var idx : int = TouchPoints.getItemIndex(p);
                    TouchPoints.removeItemAt(idx);    
                }
                
            }
            private function GetTouchPointById(id : int) : TouchPoint
            {
                for each (var p:TouchPoint in TouchPoints)
                {
                    if (p.Id == id)
                    {
                        return p;
                    }
                }
                return null;
            }
            
            private function SetImageWithinBounds(minX : Number,maxX : Number,minY : Number,maxY : Number)
            {
                image.x = Math.min(Math.max(image.x,minX),maxX);
                image.y = Math.min(Math.max(image.y,minY),maxY);
            }
            
            private function FitImageToScreen():void 
            {
                if (ImageIsWide)
                {
                    //make sure the image is not smaller  than te container
                    if (image.width*image.scaleX < width)
                    {
                        image.scaleX = image.scaleY =  width / image.width;
                        image.x = 0;
                    }
                    
                    var maxX : Number = 0;
                    var minX : Number = width - (image.width * image.scaleX);
                    var minY = (height -image.height*image.scaleY ) ;
                    var maxY = Math.max(0,(height -image.height*image.scaleY ) /2 );
                    
                    SetImageWithinBounds(minX,maxX,minY,maxY);
                    
                }
                else //high image
                {
                    //make sure the image is not smaller  than te container
                    if (image.height*image.scaleY < height)
                    {
                        image.scaleX = image.scaleY =  height/ image.height;
                        image.y = 0;
                    }
                    
                    var maxY : Number = 0;
                    var minY  : Number = height- (image.height* image.scaleY);
                    
                    var minX = (width -image.width*image.scaleX ) ;
                    var maxX = Math.max(0,(width-image.width*image.scaleX ) /2 );
                    
                    SetImageWithinBounds(minX,maxX,minY,maxY);
                }
                
            }
            
            private function UpdateTouchPoints(event:TouchEvent):void
            {
                //update point                
                var tp : TouchPoint = GetTouchPointById(event.touchPointID);
                if (tp == null)
                {
                    return; 
                }
                tp.CurrentX = event.stageX;
                tp.CurrentY = event.stageY;
                var now : Date = new Date();
                tp.LastTouchTime = now;
                
                //remove old points 
                for each (var tp : TouchPointin TouchPoints) 
                {
                    if ((now.getTime() - tp.LastTouchTime.getTime()) > 1000) 
                    {
                        var idx : int = TouchPoints.getItemIndex(tp);
                        TouchPoints.removeItemAt(idx);    
                    }
                }    
                
            }
            protected function container_touchMoveHandler(event:TouchEvent):void
            {
                SkipCount++;
                
                if (SkipCount < 3)
                {
                    return ;
                }
                SkipCount = 0;
                
                trace("move");
                
                UpdateTouchPoints(event);
                
                
                if (TouchPoints.length == 2) //zoom + move
                {
                    var p1  : TouchPoint = TouchPoints[0];
                    var p2  : TouchPoint = TouchPoints[1];
                    var scale :Number = p1.CurrentDistance(p2)  / p1.PreviousDistance(p2);
                    
                    //no more than max
                    scale = Math.min(maxScale/image.scaleX ,scale);
                    
                    //find image coordinate based on previous situation
var imageX : Number = (p1.PreviousX - image.x) / image.scaleX;
var imageY : Number = (p1.PreviousY - image.y) / image.scaleY;
//update scale
image.scaleX *= scale;
image.scaleY *= scale;
//update offset
var offsetX : Number = p1.CurrentX - image.scaleX*imageX;
var offsetY : Number = p1.CurrentY - image.scaleY*imageY;
image.x = offsetX;
image.y = offsetY;                   
                    
                    p1.UpdatePreviousPosition();
                    p2.UpdatePreviousPosition();
                }
                else if (TouchPoints.length == 1) //just move
                {
                    var p1  : TouchPoint = TouchPoints[0];
                    
                    var offsetX : Number = p1.CurrentX - p1.PreviousX;
                    var offsetY : Number = p1.CurrentY - p1.PreviousY;
                    image.x += offsetX;
                    image.y += offsetY;
                    
                    p1.UpdatePreviousPosition();
                }
                
                FitImageToScreen();                     
            }
            
        ]]>
    </fx:Script>
    
    <fx:Declarations>
        <!-- Place non-visual elements (e.g., services, value objects) here -->
    </fx:Declarations>
        <s:Rect width="100%" height="100%">
            <s:fill>
                <s:SolidColor color="#000000" />
            </s:fill>
        </s:Rect>
        <s:Image id="image" source="{source}" fillMode="clip" >
            
        </s:Image>    
    
    
</s:Group>


pinchzoom.zip (1,73 MB)