Pinch zooming is one of those things that look incredibly simple until you actually try to implement them. At that point you realize it hides quite a number of intricacies that make it hard to get it right. If you tried to implement pinch zooming in Silverlight for Windows Phone 7 you probably know what I’m talking about.
What does it means getting it right?
Adrian Tsai already gave an excellent explanation, so I won’t repeat his words. The test is extremely simple: pick two points in the image (for example two eyes) and zoom with your fingers on them. If at the end of the zoom the two points are still under your fingers you got it right –otherwise you got it wrong.
Multitouch Behavior
Laurent Bugnion, Davide Zordan and David Kelly are the men behind Multitouch Behavior for SL and WPF. It’s an impressive open source project and you should check it out. In addition to pinch-zooming it gives you rotation, inertia, debug mode and much more. It’s extremely easy to work with as you just need a couple of lines of XAML. The only shortcoming is that at the time of writing it seems that there is no way to read the current zoom state, making it difficult to fully support tombstoning. If you don’t need this, go grab Multitouch Behavior and stop reading: it will probably work better and you’ll save some time.
The XAML
This is the XAML we are starting with. Notice that our DIY implementation relies on the Silverlight Toolkit’s InputGesture. If you are not yet using it, please install the toolkit and add a reference to Microsoft.Phone.Controls.Toolkit in your project.
xmlns:toolkit="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone.Controls.Toolkit"
<Image x:Name="ImgZoom"
Source="sample.jpg"
Stretch="UniformToFill"
RenderTransformOrigin="0.5,0.5">
<toolkit:GestureService.GestureListener>
<toolkit:GestureListener
PinchStarted="OnPinchStarted"
PinchDelta="OnPinchDelta"/>
</toolkit:GestureService.GestureListener>
<Image.RenderTransform>
<CompositeTransform
ScaleX="1" ScaleY="1"
TranslateX="0" TranslateY="0"/>
</Image.RenderTransform>
</Image>
The wrong way
I’ve seen this example several times around, I suppose you’ve seen it too somewhere on The Interwebs™:
double initialScale = 1d;
private void OnPinchStarted(object s, PinchStartedGestureEventArgs e)
{
initialScale = ((CompositeTransform)ImgZoom.RenderTransform).ScaleX;
}
private void OnPinchDelta(object s, PinchGestureEventArgs e)
{
var transform = (CompositeTransform)ImgZoom.RenderTransform;
transform.ScaleX = initialScale * e.DistanceRatio;
transform.ScaleY = transform.ScaleX;
}
Very simple and good looking. I love simple solutions and I bet you do too, but as someone once said “Things should be as simple as possible, but not simpler.” And unfortunately this is simpler than possible (is this even a sentence?). The problem is that the scaling is always centered in the middle of the image, so this solution won’t pass the poke-two-fingers-in-the-eyes test.
The better but still wrong way
The knee-jerk reaction is to move the scaling center between our fingers as we perform the scaling:
double initialScale = 1d;
private void OnPinchStarted(object s, PinchStartedGestureEventArgs e)
{
initialScale = ((CompositeTransform)ImgZoom.RenderTransform).ScaleX;
}
private void OnPinchDelta(object s, PinchGestureEventArgs e)
{
var finger1 = e.GetPosition(ImgZoom, 0);
var finger2 = e.GetPosition(ImgZoom, 1);
var center = new Point(
(finger2.X + finger1.X) / 2 / ImgZoom.ActualWidth,
(finger2.Y + finger1.Y) / 2 / ImgZoom.ActualHeight);
ImgZoom.RenderTransformOrigin = center;
var transform = (CompositeTransform)ImgZoom.RenderTransform;
transform.ScaleX = initialScale * e.DistanceRatio;
transform.ScaleY = transform.ScaleX;
}
This is better. The first time it actually works well too, but as soon as you pinch the image a second time you realize the image moves around. The reason: the zoom state is the sum of all the zoom operations (each one having its center) and by moving the center every time you are effectively removing information from the previous steps. To solve this problem we could replace the CompositeTransform with a TransformGroup and then add a new ScaleTransform (with a new center) at every PinchStart+PinchDelta event group. This will probably work: every scaling will keep its center and all is well. Except your phone will probably catch fire and explode because of the number of transforms you are stacking up. My team has a name for this kind of solutions, and it isn’t a nice one (fortunately there is no English translation for that).
The right way
It is clear by now that simply setting a scale factor and moving the center won’t take us far. As we are real DIYourselfers we will do it with a combination of scaling and translation. In the already mentioned article, Adrian Tsai uses this technique in XNA and we will apply the same concept in Silverlight. If an image is worth a million worth, a line of code is probably worth even more, so I’ll let the c# do the talking.
// these two fully define the zoom state:
private double TotalImageScale = 1d;
private Point ImagePosition = new Point(0, 0);
private Point _oldFinger1;
private Point _oldFinger2;
private double _oldScaleFactor;
private void OnPinchStarted(object s, PinchStartedGestureEventArgs e)
{
_oldFinger1 = e.GetPosition(ImgZoom, 0);
_oldFinger2 = e.GetPosition(ImgZoom, 1);
_oldScaleFactor = 1;
}
private void OnPinchDelta(object s, PinchGestureEventArgs e)
{
var scaleFactor = e.DistanceRatio / _oldScaleFactor;
var currentFinger1 = e.GetPosition(ImgZoom, 0);
var currentFinger2 = e.GetPosition(ImgZoom, 1);
var translationDelta = GetTranslationDelta(
currentFinger1,
currentFinger2,
_oldFinger1,
_oldFinger2,
ImagePosition,
scaleFactor);
_oldFinger1 = currentFinger1;
_oldFinger2 = currentFinger2;
_oldScaleFactor = e.DistanceRatio;
UpdateImage(scaleFactor, translationDelta);
}
private void UpdateImage(double scaleFactor, Point delta)
{
TotalImageScale *= scaleFactor;
ImagePosition = new Point(ImagePosition.X + delta.X, ImagePosition.Y + delta.Y);
var transform = (CompositeTransform)ImgZoom.RenderTransform;
transform.ScaleX = TotalImageScale;
transform.ScaleY = TotalImageScale;
transform.TranslateX = ImagePosition.X;
transform.TranslateY = ImagePosition.Y;
}
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);
}
Also note that in the XAML we must set the RenderTransformOrigin to 0,0.
This finally passes the fingers-in-the-eyes test! Now we can add some bells and whistles like handling dragging, blocking the zoom-out when the image is at full screen, and avoiding that the image is dragged outside the visible area. For those extra details please see the sample solution at the end of the article.
What about MVVM?
You are using MVVM-light for your WP7 app, aren’t you? We all agree my code is ugly and not very MVVM friendly, I’ll make no excuses. However it’s all strictly UI code, so it doesn’t feel so bad to have it in the code behind. What you will probably do is wire TotalImageScale and ImagePosition to your ViewModel. Those two values fully define the state of the zoom, so if you save and reload them in your ViewModel you will be good to go.
Download
Here is the full sample project so that you can play with the code within the comfort of your Visual Studio (my daughter is in the picture, please treat her with respect :-) ).
Feel free to use the code in your project. As always, any kind of feedback is deeply appreciated!