Windows Phone: writing a pinch and zoom image control
Mood: happy
Posted on 2013-02-10 22:20:00
Tags: windowsphone wpdev
Words: 989

When I was working on FlightPredictor and was working on showing airport maps, I was surprised there was no builtin "pinch and zoom image control" in the Windows Phone SDK. (to be fair, there wasn't one in Android either, and I'm not sure about iOS) So I had to implement my own, with some help from the Internet.

If I were doing this today, I'd just use the PanAndZoom control from Telerik's RadControls for Windows Phone. (which comes with the Nokia Premium Developer Program! Just sayin') But I did go through the trouble to implement it, so hopefully it will help someone out. Ed: another good solution is the SharpGIS ImageViewer - I haven't tried it, but it looks like it works well and you don't have to type in a bunch of code :-)

To see an example of how this works, you can download a trial version of FlightPredictor, download the airport maps and then play with them. This code supports pinching to zoom, panning, a maximum zoom level, and double-tap to zoom in or out.


XAML code
Here's the relevant part of the XAML:


<Image x:Name="MapImage" Stretch="Uniform"
RenderTransformOrigin="0,0" CacheMode="BitmapCache"
SizeChanged="MapImage_SizeChanged">
<toolkit:GestureService.GestureListener>
<toolkit:GestureListener
PinchStarted="GestureListener_PinchStarted"
PinchDelta="GestureListener_PinchDelta"
DragDelta="GestureListener_DragDelta"
DoubleTap="GestureListener_DoubleTap"/>
</toolkit:GestureService.GestureListener>
<Image.RenderTransform>
<CompositeTransform
ScaleX="1" ScaleY="1"
TranslateX="0" TranslateY="0"/>
</Image.RenderTransform>
</Image>


Note that the GestureListener is from the Windows Phone Toolkit, which is a (free!) must-have. It also requires you to have this inside the PhoneApplicationPage XML element:

xmlns:toolkit=
"clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone.Controls.Toolkit"


C# code
First, some variables to declare in your PhoneApplicationPage:

private bool _needToUpdateMaxZoom = false;
private int _imageHeight = 0;
private int _imageWidth = 0;
// Reference
// these two fields fully define the zoom state:
private double _totalImageScale = 1.0;
private Point _imagePosition = new Point(0, 0);

private double _maxImageZoom = 1;
private Point _oldFinger1;
private Point _oldFinger2;
private double _oldScaleFactor;


Now you need to get a BitmapImage containing the image to display. How you do this depends on where you're getting the image from, but here's how I do it for files stored in IsolatedStorage:

byte[] data;

// Read the entire image in one go into a byte array
using (IsolatedStorageFile isf = IsolatedStorageFile.GetUserStoreForApplication())
{
// Open the file - error handling omitted for brevity
// Note: If the image does not exist in isolated storage
// the following exception will be generated:
// System.IO.IsolatedStorage.IsolatedStorageException was unhandled
// Message=Operation not permitted on IsolatedStorageFileStream
using (IsolatedStorageFileStream isfs = isf.OpenFile("/airportMaps/" +
info.Url, FileMode.Open, FileAccess.Read))
{
// Allocate an array large enough for the entire file
data = new byte[isfs.Length];

// Read the entire file and then close it
isfs.Read(data, 0, data.Length);
isfs.Close();
}
}

// Create memory stream and bitmap
MemoryStream ms = new MemoryStream(data);
BitmapImage bi = new BitmapImage();

// Set bitmap source to memory stream
bi.SetSource(ms);


After you've set up your BitmapImage, add the following code right afterwards:

_imageHeight = bi.PixelHeight;
_imageWidth = bi.PixelWidth;
_imagePosition = new Point(0, 0);
_totalImageScale = 1;

// set max zoom in
if (MapImage.ActualWidth == 0.0 || MapImage.ActualHeight == 0.0)
{
_needToUpdateMaxZoom = true;
}
else
{
UpdateMaxZoom();
UpdateImageScale(1.0);
UpdateImagePosition(new Point(0, 0));
}

// Assign the bitmap image to the image’s source
MapImage.Source = bi;


Now, all that's left is to implement the GestureListener events, as well as a few utility methods:

private void MapImage_SizeChanged(object sender, SizeChangedEventArgs e)
{
if (_needToUpdateMaxZoom)
{
if (MapImage.ActualHeight != 0.0 && MapImage.ActualWidth != 0.0)
{
UpdateMaxZoom();
}
}
}

private void UpdateMaxZoom()
{
// this is already stretched, so this gets tricky
_maxImageZoom = Math.Min(_imageHeight / MapImage.ActualHeight,
_imageWidth / MapImage.ActualWidth);
_maxImageZoom *= Math.Max(1.0,
Math.Max(_imageHeight / MapImage.ActualHeight, _imageWidth / MapImage.ActualWidth));
const double MAX_ZOOM_FACTOR = 2;
_maxImageZoom *= MAX_ZOOM_FACTOR;
_maxImageZoom = Math.Max(1.0, _maxImageZoom);
_needToUpdateMaxZoom = false;
UpdateImageScale(1.0);
UpdateImagePosition(new Point(0, 0));
}

private void GestureListener_PinchStarted(object sender, PinchStartedGestureEventArgs e)
{
_oldFinger1 = e.GetPosition(MapImage, 0);
_oldFinger2 = e.GetPosition(MapImage, 1);
_oldScaleFactor = 1;
}

private void GestureListener_PinchDelta(object sender, PinchGestureEventArgs e)
{
var scaleFactor = e.DistanceRatio / _oldScaleFactor;
if (!IsScaleValid(scaleFactor))
return;

var currentFinger1 = e.GetPosition(MapImage, 0);
var currentFinger2 = e.GetPosition(MapImage, 1);

var translationDelta = GetTranslationDelta(currentFinger1, currentFinger2,
_oldFinger1, _oldFinger2, _imagePosition, scaleFactor);

_oldFinger1 = currentFinger1;
_oldFinger2 = currentFinger2;
_oldScaleFactor = e.DistanceRatio;

UpdateImageScale(scaleFactor);
UpdateImagePosition(translationDelta);
}

private void GestureListener_DragDelta(object sender, DragDeltaGestureEventArgs e)
{
var translationDelta = new Point(e.HorizontalChange, e.VerticalChange);

if (IsDragValid(1, translationDelta))
UpdateImagePosition(translationDelta);
}

private void GestureListener_DoubleTap(object sender, Microsoft.Phone.Controls.GestureEventArgs e)
{
if (Math.Abs(_totalImageScale - 1) < .0001)
{
const double DOUBLE_TAP_ZOOM_IN = 3;
double imageScale = Math.Min(DOUBLE_TAP_ZOOM_IN, _maxImageZoom);

Point imagePositionTapped = e.GetPosition(MapImage);
// we want this point to be centered.
double x = imagePositionTapped.X * imageScale - (MapImage.ActualWidth / 2);
double y = imagePositionTapped.Y * imageScale - (MapImage.ActualHeight / 2);
Point imageDelta = new Point(-1*x, -1*y);
// FFV - animation?
UpdateImageScale(imageScale);
UpdateImagePosition(imageDelta);
}
else
{
ResetImagePosition();
}
}

private Point GetTranslationDelta(Point currentFinger1, Point currentFinger2,
Point oldFinger1, Point oldFinger2, Point currentPosition, double scaleFactor)
{
var newPos1 = new Point(currentFinger1.X + (currentPosition.X - oldFinger1.X) * scaleFactor,
currentFinger1.Y + (currentPosition.Y - oldFinger1.Y) * scaleFactor);
var newPos2 = new Point(currentFinger2.X + (currentPosition.X - oldFinger2.X) * scaleFactor,
currentFinger2.Y + (currentPosition.Y - oldFinger2.Y) * scaleFactor);
var newPos = new Point((newPos1.X + newPos2.X) / 2, (newPos1.Y + newPos2.Y) / 2);
return new Point(newPos.X - currentPosition.X, newPos.Y - currentPosition.Y);
}

private void UpdateImageScale(double scaleFactor)
{
_totalImageScale *= scaleFactor;
ApplyScale();
}

private void ApplyScale()
{
((CompositeTransform)MapImage.RenderTransform).ScaleX = _totalImageScale;
((CompositeTransform)MapImage.RenderTransform).ScaleY = _totalImageScale;
}

private void UpdateImagePosition(Point delta)
{
var newPosition = new Point(_imagePosition.X + delta.X, _imagePosition.Y + delta.Y);
if (newPosition.X > 0) newPosition.X = 0;
if (newPosition.Y > 0) newPosition.Y = 0;

if ((MapImage.ActualWidth * _totalImageScale) + newPosition.X < MapImage.ActualWidth)
newPosition.X = MapImage.ActualWidth - (MapImage.ActualWidth * _totalImageScale);

if ((MapImage.ActualHeight * _totalImageScale) + newPosition.Y < MapImage.ActualHeight)
newPosition.Y = MapImage.ActualHeight - (MapImage.ActualHeight * _totalImageScale);

_imagePosition = newPosition;

ApplyPosition();
}

private void ApplyPosition()
{
((CompositeTransform)MapImage.RenderTransform).TranslateX = _imagePosition.X;
((CompositeTransform)MapImage.RenderTransform).TranslateY = _imagePosition.Y;
}

private void ResetImagePosition()
{
_totalImageScale = 1;
_imagePosition = new Point(0, 0);
ApplyScale();
ApplyPosition();
}

private bool IsDragValid(double scaleDelta, Point translateDelta)
{
if (_imagePosition.X + translateDelta.X > 0 || _imagePosition.Y + translateDelta.Y > 0)
return false;
if ((MapImage.ActualWidth * _totalImageScale * scaleDelta) +
(_imagePosition.X + translateDelta.X) < MapImage.ActualWidth)
return false;
if ((MapImage.ActualHeight * _totalImageScale * scaleDelta) +
(_imagePosition.Y + translateDelta.Y) < MapImage.ActualHeight)
return false;
return true;
}

private bool IsScaleValid(double scaleDelta)
{
return (_totalImageScale * scaleDelta >= 1) &&
(_totalImageScale * scaleDelta <= _maxImageZoom);
}

and that's it! Some things you can tweak:
- The maximum you can zoom in is 2x of the original image size. You can change this by modifying MAX_ZOOM_FACTOR in UpdateMaxZoom().
- When you double-tap on the image, if it's currently zoomed out it zooms in to 3x. You can change this by modifying DOUBLE_TAP_ZOOM_IN in GestureListener_DoubleTap.

I'm also not entirely sure the math is right in various places, but it works well enough on the size of images I tend to deal with. It would also be nice to add inertial scrolling...

Hope this is helpful! I took a lot of this code from this blog post.

--

See all my Windows Phone development posts.

I'm planning on writing more posts about Windows Phone development - what would you like to hear about? Reply here, on twitter at @gregstoll, or by email at ext-greg.stoll@nokia.com.

--

Interested in developing for Windows Phone? I'm the Nokia Developer Ambassador for Austin - drop me a line at ext-greg.stoll@nokia.com!


4 comments

Comment from ext_1647372:
2013-02-12T08:53:02+00:00

The method you mentioned using Gesture listener and which is deprecated in windows phone 8 toolkit. And moreover it is not the better method to adopt. We cannot make a control that simply functions like windows phone image viewer by using your code. For example how you can make a floating experience(scroll) for large images in your implementation.

Comment from gregstoll:
2013-02-12T11:20:35+00:00

Hmm - do you have some documentation saying that the GestureListener is deprecated? I can't seem to find it...

So, what is a better method, then?

Comment from ext_1647372:
2013-02-13T08:57:04+00:00

http://www.verious.com/component/silverlight-for-windows-phone-tool-kit/

Tryout the new method mentioned here.

http://stackoverflow.com/questions/13969400/pinch-to-zoom-functionality-in-windows-phone-8

Comment from gregstoll:
2013-02-13T11:40:35+00:00

Ah, thanks! I'll have to take a look at patching up a WP8-specific version of this code.

This backup was done by LJBackup.