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 theSilverlight 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.
02.
Source
=
"sample.jpg"
03.
Stretch
=
"UniformToFill"
04.
RenderTransformOrigin
=
"0.5,0.5"
>
05.
<
toolkit:GestureService.GestureListener
>
06.
<
toolkit:GestureListener
07.
PinchStarted
=
"OnPinchStarted"
08.
PinchDelta
=
"OnPinchDelta"
/>
09.
</
toolkit:GestureService.GestureListener
>
10.
<
Image.RenderTransform
>
11.
<
CompositeTransform
12.
ScaleX
=
"1"
ScaleY
=
"1"
13.
TranslateX
=
"0"
TranslateY
=
"0"
/>
14.
</
Image.RenderTransform
>
15.
</
Image
>
The wrong way
I’ve seen this example several times around, I suppose you’ve seen it too somewhere on The Interwebs™:
02.
03.
private
void
OnPinchStarted(
object
s, PinchStartedGestureEventArgs e)
04.
{
05.
initialScale = ((CompositeTransform)ImgZoom.RenderTransform).ScaleX;
06.
}
07.
08.
private
void
OnPinchDelta(
object
s, PinchGestureEventArgs e)
09.
{
10.
var transform = (CompositeTransform)ImgZoom.RenderTransform;
11.
transform.ScaleX = initialScale * e.DistanceRatio;
12.
transform.ScaleY = transform.ScaleX;
13.
}
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:
02.
03.
private
void
OnPinchStarted(
object
s, PinchStartedGestureEventArgs e)
04.
{
05.
initialScale = ((CompositeTransform)ImgZoom.RenderTransform).ScaleX;
06.
}
07.
08.
private
void
OnPinchDelta(
object
s, PinchGestureEventArgs e)
09.
{
10.
var finger1 = e.GetPosition(ImgZoom, 0);
11.
var finger2 = e.GetPosition(ImgZoom, 1);
12.
13.
var center =
new
Point(
14.
(finger2.X + finger1.X) / 2 / ImgZoom.ActualWidth,
15.
(finger2.Y + finger1.Y) / 2 / ImgZoom.ActualHeight);
16.
17.
ImgZoom.RenderTransformOrigin = center;
18.
19.
var transform = (CompositeTransform)ImgZoom.RenderTransform;
20.
transform.ScaleX = initialScale * e.DistanceRatio;
21.
transform.ScaleY = transform.ScaleX;
22.
}
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 isthe 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 thealready 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.
02.
private
double
TotalImageScale = 1d;
03.
private
Point ImagePosition =
new
Point(0, 0);
04.
05.
private
Point _oldFinger1;
06.
private
Point _oldFinger2;
07.
private
double
_oldScaleFactor;
08.
09.
private
void
OnPinchStarted(
object
s, PinchStartedGestureEventArgs e)
10.
{
11.
_oldFinger1 = e.GetPosition(ImgZoom, 0);
12.
_oldFinger2 = e.GetPosition(ImgZoom, 1);
13.
_oldScaleFactor = 1;
14.
}
15.
16.
private
void
OnPinchDelta(
object
s, PinchGestureEventArgs e)
17.
{
18.
var scaleFactor = e.DistanceRatio / _oldScaleFactor;
19.
20.
var currentFinger1 = e.GetPosition(ImgZoom, 0);
21.
var currentFinger2 = e.GetPosition(ImgZoom, 1);
22.
23.
var translationDelta = GetTranslationDelta(
24.
currentFinger1,
25.
currentFinger2,
26.
_oldFinger1,
27.
_oldFinger2,
28.
ImagePosition,
29.
scaleFactor);
30.
31.
_oldFinger1 = currentFinger1;
32.
_oldFinger2 = currentFinger2;
33.
_oldScaleFactor = e.DistanceRatio;
34.
35.
UpdateImage(scaleFactor, translationDelta);
36.
}
37.
38.
private
void
UpdateImage(
double
scaleFactor, Point delta)
39.
{
40.
TotalImageScale *= scaleFactor;
41.
ImagePosition =
new
Point(ImagePosition.X + delta.X, ImagePosition.Y + delta.Y);
42.
43.
var transform = (CompositeTransform)ImgZoom.RenderTransform;
44.
transform.ScaleX = TotalImageScale;
45.
transform.ScaleY = TotalImageScale;
46.
transform.TranslateX = ImagePosition.X;
47.
transform.TranslateY = ImagePosition.Y;
48.
}
49.
50.
private
Point GetTranslationDelta(
51.
Point currentFinger1, Point currentFinger2,
52.
Point oldFinger1, Point oldFinger2,
53.
Point currentPosition,
double
scaleFactor)
54.
{
55.
var newPos1 =
new
Point(
56.
currentFinger1.X + (currentPosition.X - oldFinger1.X) * scaleFactor,
57.
currentFinger1.Y + (currentPosition.Y - oldFinger1.Y) * scaleFactor);
58.
59.
var newPos2 =
new
Point(
60.
currentFinger2.X + (currentPosition.X - oldFinger2.X) * scaleFactor,
61.
currentFinger2.Y + (currentPosition.Y - oldFinger2.Y) * scaleFactor);
62.
63.
var newPos =
new
Point(
64.
(newPos1.X + newPos2.X) / 2,
65.
(newPos1.Y + newPos2.Y) / 2);
66.
67.
return
new
Point(
68.
newPos.X - currentPosition.X,
69.
newPos.Y - currentPosition.Y);
70.
}
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!