Deep Zoom Viewer (Pinch, Stretch, & Double Tap Gestures)

0
287

Deep Zoom is a slick technology for creating, viewing, and manipulating huge images or collections of images. It can be used to create experiences much like Bing Maps or Google Maps, but applied to any domain. With the samples available from this app, you can explore large panoramic photographs, scanned-in artwork, a computer-generated data visualization, an example of what a deep zoom advertisement might look like, and, yes, Earth.

To maximize performance, Deep Zoom images are multiresolution; the image file format includes many separate subimages—called tiles—at multiple zoom levels. Tiles are downloaded on-demand and rendered in a fairly seamless fashion with smooth transitions. For end users, the result is a huge image that can be loaded, zoomed, and panned extremely quickly.

Deep Zoom Viewer enables viewing and interacting with any online Deep Zoom image right on your Windows phone. You can enter a URL that points to any Deep Zoom image (or image collection), or you can browse any of the seven interesting samples that are already provided.

To render a Deep Zoom image, this app leverages Silverlight’s MultiScaleImage control, which does all the hard work. To view a file, you just need to place a MultiScaleImage on a page and then set its Source property to an appropriate URL. However, the control does not provide any built-in gestures for manipulating the image. Therefore, this app provides a perfect opportunity to demonstrate how to implement pinch-&-stretch zooming and double-tap gestures—practically a requirement for any respectable Deep Zoom viewer.

Pinching is the standard zoom-out gesture that involves placing two fingers on the screen and then sliding them toward each other. Stretching is the standard zoom-in gesture that involves placing two fingers on the screen and then sliding them away from each other. In this app, double tapping is used to quickly zoom in, centered on the point that was tapped.

Windows Phone style guidelines dictate that touch gestures should only be used for their intended purposes. As in Deep Zoom Viewer, a pinch should always zoom out, a stretch should always zoom in, and a double tap should always perform some kind of zoom in and/or zoom out.

How do I create my own Deep Zoom images?

Currently, the quickest and easiest way is to use Microsoft’s free Zoom.it service (http://zoom.it).This turns any JPG, PNG, or TIFF image into a Deep Zoom image.The service also supports SVG files, PDF files, and even web pages as input! You just enter an appropriate URL, and it does the conversion. It even hosts the file for you! Alternatively, the most powerful option is to use Microsoft’s Deep Zoom Composer, a free program that can be downloaded at http://bit.ly/deepzoomdownload.

Deep Zoom versus Seadragon

Although Deep Zoom refers to a Silverlight-specific feature, the underlying Seadragon technology (which Microsoft originally acquired from a company called Seadragon Software) has been exposed in other forms. For example,Microsoft has released an open-source JavaScript version called “Seadragon Ajax” in its Ajax Control Toolkit. It can view the same file types as Deep Zoom.

The User Interface

Deep Zoom Viewer is a single-page app (except for an instructions page) that dedicates all of its screen real estate to the MultiScaleImage control. On top of this, it layers a translucent application bar and a dialog that enables the user to type arbitrary Deep Zoom image URLs. Figure 41.1 shows the main page with its application bar menu expanded, and Figure 41.2 shows the main page with its dialog showing. The XAML for this page is in Listing 41.1.

The application bar menu is expanded on top of the Carina Nebula.
FIGURE 41.1 The application bar menu is expanded on top of the Carina Nebula.
Entering a custom URL is done via a dialog that appears on top of the current Deep Zoom image.
FIGURE 41.2 Entering a custom URL is done via a dialog that appears on top of the current Deep Zoom image.

LISTING 41.1 MainPage.xaml—The User Interface for Deep Zoom Viewers’Main Page

[code]

<phone:PhoneApplicationPage x:Class=”WindowsPhoneApp.MainPage”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:phone=”clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone”
xmlns:shell=”clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone”
xmlns:toolkit=”clr-namespace:Microsoft.Phone.Controls;
➥assembly=Microsoft.Phone.Controls.Toolkit”
xmlns:local=”clr-namespace:WindowsPhoneApp”
FontFamily=”{StaticResource PhoneFontFamilyNormal}”
FontSize=”{StaticResource PhoneFontSizeNormal}”
Foreground=”{StaticResource PhoneForegroundBrush}”
SupportedOrientations=”PortraitOrLandscape”>
<!– The application bar –>
<phone:PhoneApplicationPage.ApplicationBar>
<shell:ApplicationBar Opacity=”.5”>
<shell:ApplicationBarIconButton Text=”fit to screen”
IconUri=”/Images/appbar.fitToScreen.png”
Click=”FitToScreenButton_Click”/>
<shell:ApplicationBarIconButton Text=”zoom in”
IconUri=”/Shared/Images/appbar.plus.png”
Click=”ZoomInButton_Click”/>
<shell:ApplicationBarIconButton Text=”zoom out”
IconUri=”/Shared/Images/appbar.minus.png”
Click=”ZoomOutButton_Click”/>
<shell:ApplicationBarIconButton Text=”instructions”
IconUri=”/Shared/Images/appbar.instructions.png”
Click=”InstructionsButton_Click”/>
<shell:ApplicationBar.MenuItems>
<shell:ApplicationBarMenuItem Text=”[enter url]”
Click=”CustomUrlMenuItem_Click”/>
</shell:ApplicationBar.MenuItems>
</shell:ApplicationBar>
</phone:PhoneApplicationPage.ApplicationBar>
<Grid>
<!– The Deep Zoom image –>
<MultiScaleImage x:Name=”DeepZoomImage”>
<!– Attach the gesture listener to this element –>
<toolkit:GestureService.GestureListener>
<toolkit:GestureListener DoubleTap=”GestureListener_DoubleTap”
PinchStarted=”GestureListener_PinchStarted”
PinchDelta=”GestureListener_PinchDelta”/>
</toolkit:GestureService.GestureListener>
</MultiScaleImage>
<!– Show a progress bar while loading an image –>
<ProgressBar x:Name=”ProgressBar” Visibility=”Collapsed”/>
<!– A dialog for entering a URL –>
<local:Dialog x:Name=”CustomFileDialog” Closed=”CustomFileDialog_Closed”>
<local:Dialog.InnerContent>
<StackPanel>
<TextBlock Text=”Enter the URL of a Deep Zoom file” Margin=”11,5,0,-5”
Foreground=”{StaticResource PhoneSubtleBrush}”/>
<TextBox InputScope=”Url” Text=”{Binding Result, Mode=TwoWay}”/>
</StackPanel>
</local:Dialog.InnerContent>
</local:Dialog>
</Grid>
</phone:PhoneApplicationPage>

[/code]

  • A gesture listener from the Silverlight for Windows Phone Toolkit is attached to the MultiScaleImage control, so we can very easily detect double taps and pinch/stretch gestures.
  • The MultiScaleImage control has a lot of automatic functionality to make the viewing experience as smooth as possible. For example, as tiles are downloaded, they are smoothly blended in with a blurry-to-crisp transition, captured in Figure 41.3.
You can occasionally catch pieces of the view starting out blurry and then seamlessly becoming crisp.
FIGURE 41.3 You can occasionally catch pieces of the view starting out blurry and then seamlessly becoming crisp.

The Code-Behind

Listing 41.2 contains the code-behind for the main page.

LISTING 41.2 MainPage.xaml.cs—The Code-Behind for Deep Zoom Viewers’Main Page

[code]

using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Navigation;
using Microsoft.Phone.Controls;
using Microsoft.Phone.Shell;
namespace WindowsPhoneApp
{
public partial class MainPage : PhoneApplicationPage
{
// Persistent settings
Setting<Uri> savedImageUri = new Setting<Uri>(“ImageUri”,
new Uri(Data.BaseUri, “last-fm.dzi”));
Setting<Point> savedViewportOrigin = new Setting<Point>(“ViewportOrigin”,
new Point(0, -.2));
Setting<double> savedZoom = new Setting<double>(“Zoom”, 1);
// Used by pinch and stretch
double zoomWhenPinchStarted;
// Used by panning and double-tapping
Point mouseDownPoint = new Point();
Point mouseDownViewportOrigin = new Point();
public MainPage()
{
InitializeComponent();
// Fill the application bar menu with the sample images
foreach (File f in Data.Files)
{
ApplicationBarMenuItem item = new ApplicationBarMenuItem(f.Title);
// This assignment is needed so each anonymous method gets the right value
string filename = f.Filename;
item.Click += delegate(object sender, EventArgs e)
{
OpenFile(new Uri(Data.BaseUri, filename), true);
};
this.ApplicationBar.MenuItems.Add(item);
}
// Handle success for any attempt to open a Deep Zoom image
this.DeepZoomImage.ImageOpenSucceeded +=
delegate(object sender, RoutedEventArgs e)
{
// Hide the progress bar
this.ProgressBar.Visibility = Visibility.Collapsed;
this.ProgressBar.IsIndeterminate = false; // Avoid a perf issue
// Initialize the view
this.DeepZoomImage.ViewportWidth = this.savedZoom.Value;
this.DeepZoomImage.ViewportOrigin = this.savedViewportOrigin.Value;
};
// Handle failure for any attempt to open a Deep Zoom image
this.DeepZoomImage.ImageOpenFailed +=
delegate(object sender, ExceptionRoutedEventArgs e)
{
// Hide the progress bar
this.ProgressBar.Visibility = Visibility.Collapsed;
this.ProgressBar.IsIndeterminate = false; // Avoid a perf issue
MessageBox.Show(“Unable to open “ + this.savedImageUri.Value + “.”,
“Error”, MessageBoxButton.OK);
};
// Load the previously-viewed (or default) image
OpenFile(this.savedImageUri.Value, false);
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
base.OnNavigatedFrom(e);
// Remember settings for next time
this.savedViewportOrigin.Value = this.DeepZoomImage.ViewportOrigin;
this.savedZoom.Value = this.DeepZoomImage.ViewportWidth;
}
// Attempt to open the Deep Zoom image at the specified URI
void OpenFile(Uri uri, bool resetPosition)
{
if (resetPosition)
{
// Restore these settings to their default values
this.savedZoom.Value = this.savedZoom.DefaultValue;
this.savedViewportOrigin.Value = this.savedViewportOrigin.DefaultValue;
}
this.savedImageUri.Value = uri;
// Assign the image
this.DeepZoomImage.Source = new DeepZoomImageTileSource(uri);
// Show a temporary progress bar
this.ProgressBar.IsIndeterminate = true;
this.ProgressBar.Visibility = Visibility.Visible;
}
// Three handlers (mouse down/move/up) to implement panning
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
// Ignore if the dialog is visible
if (this.CustomFileDialog.Visibility == Visibility.Visible)
return;
this.mouseDownPoint = e.GetPosition(this.DeepZoomImage);
this.mouseDownViewportOrigin = this.DeepZoomImage.ViewportOrigin;
this.DeepZoomImage.CaptureMouse();
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
// Ignore if the dialog is visible
if (this.CustomFileDialog.Visibility == Visibility.Visible)
return;
Point p = e.GetPosition(this.DeepZoomImage);
// ViewportWidth is the absolute zoom (2 == half size, .5 == double size)
double scale = this.DeepZoomImage.ActualWidth /
this.DeepZoomImage.ViewportWidth;
// Pan the image by setting a new viewport origin based on the mouse-down
// location and the distance the primary finger has moved
this.DeepZoomImage.ViewportOrigin = new Point(
this.mouseDownViewportOrigin.X + (this.mouseDownPoint.X – p.X) / scale,
this.mouseDownViewportOrigin.Y + (this.mouseDownPoint.Y – p.Y) / scale);
}
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonUp(e);
// Stop panning
this.DeepZoomImage.ReleaseMouseCapture();
}
// The three gesture handlers for double tap, pinch, and stretch
void GestureListener_DoubleTap(object sender, GestureEventArgs e)
{
// Ignore if the dialog is visible
if (this.CustomFileDialog.Visibility == Visibility.Visible)
return;
// Zoom in by a factor of 2 centered at the place where the double tap
// occurred (the same place as the most recent MouseLeftButtonDown event)
ZoomBy(2, this.mouseDownPoint);
}
// Raised when two fingers touch the screen (likely to begin a pinch/stretch)
void GestureListener_PinchStarted(object sender,
PinchStartedGestureEventArgs e)
{
this.zoomWhenPinchStarted = this.DeepZoomImage.ViewportWidth;
}
// Raised continually as either or both fingers move
void GestureListener_PinchDelta(object sender, PinchGestureEventArgs e)
{
// Ignore if the dialog is visible
if (this.CustomFileDialog.Visibility == Visibility.Visible)
return;
// The distance ratio is always relative to when the pinch/stretch started,
// so be sure to apply it to the ORIGINAL zoom level, not the CURRENT
double zoom = this.zoomWhenPinchStarted / e.DistanceRatio;
this.DeepZoomImage.ViewportWidth = zoom;
}
void ZoomBy(double zoomFactor, Point centerPoint)
{
// Restrict how small the image can get (don’t get smaller than half size)
if (this.DeepZoomImage.ViewportWidth >= 2 && zoomFactor < 1)
return;
// Convert the on-screen point to the image’s coordinate system, which
// is (0,0) in the top-left corner and (1,1) in the bottom right corner
Point logicalCenterPoint =
this.DeepZoomImage.ElementToLogicalPoint(centerPoint);
// Perform the zoom
this.DeepZoomImage.ZoomAboutLogicalPoint(
zoomFactor, logicalCenterPoint.X, logicalCenterPoint.Y);
}
// Code for the custom file dialog
protected override void OnBackKeyPress(CancelEventArgs e)
{
base.OnBackKeyPress(e);
// If the dialog is open, close it instead of leaving the page
if (this.CustomFileDialog.Visibility == Visibility.Visible)
{
e.Cancel = true;
this.CustomFileDialog.Hide(MessageBoxResult.Cancel);
}
}
void CustomFileDialog_Closed(object sender, MessageBoxResultEventArgs e)
{
// Try to open the typed-in URL
if (e.Result == MessageBoxResult.OK && this.CustomFileDialog.Result != null)
OpenFile(new Uri(this.CustomFileDialog.Result.ToString()), true);
}
// Application bar handlers
void FitToScreenButton_Click(object sender, EventArgs e)
{
this.DeepZoomImage.ViewportWidth = 1; // Un-zoom
this.DeepZoomImage.ViewportOrigin = new Point(0, -.4); // Give a top margin
}
void ZoomInButton_Click(object sender, EventArgs e)
{
// Zoom in by 50%, keeping the current center point
ZoomBy(1.5, new Point(this.DeepZoomImage.ActualWidth / 2,
this.DeepZoomImage.ActualHeight / 2));
}
void ZoomOutButton_Click(object sender, EventArgs e)
{
// Zoom out by 50%, keeping the current center point
ZoomBy(1 / 1.5, new Point(this.DeepZoomImage.ActualWidth / 2,
this.DeepZoomImage.ActualHeight / 2));
}
void InstructionsButton_Click(object sender, EventArgs e)
{
this.NavigationService.Navigate(
new Uri(“/InstructionsPage.xaml”, UriKind.Relative));
}
void CustomUrlMenuItem_Click(object sender, EventArgs e)
{
// Show the custom file dialog, initialized with the current URI
if (this.savedImageUri.Value != null)
this.CustomFileDialog.Result = this.savedImageUri.Value;
this.CustomFileDialog.Show();
}
}
}

[/code]

  • The application bar menu is filled with a list of sample files based on the following two classes defined in a separate Data.cs file:

    [code]
    public struct File
    {
    public string Title { get; set; }
    public string Filename { get; set; }
    }
    public static class Data
    {
    public static readonly Uri BaseUri =
    new Uri(“http://static.seadragon.com/content/misc/”);
    public static File[] Files = {
    new File { Title = “World-Wide Music Scene”, Filename = “last-fm.dzi” },
    new File { Title = “Carina Nebula”, Filename = “carina-nebula.dzi” },
    new File { Title = “Blue Marble”, Filename = “blue-marble.dzi” },
    new File { Title = “Contoso Fixster”, Filename = “contoso-fixster.dzi” },
    new File { Title = “Milwaukee, 1898”, Filename = “milwaukee.dzi” },
    new File { Title = “Yosemite Panorama”, Filename=“yosemite-panorama.dzi” },
    new File { Title = “Angkor Wat Temple”, Filename = “angkor-wat.dzi” }
    };
    }
    [/code]

  • When constructing the URI for each filename, BaseUri is prepended to the filename using an overloaded constructor of Uri that accepts two arguments.
  • Much like the Image element, the MultiScaleImage element is told what to render by setting its Source property. This is done inside OpenFile. Note that the type of Source is MultiScaleTileSource, an abstract class with one concrete subclass: DeepZoomImageTileSource.
  • After setting Source, the image download is asynchronous and either results in an ImageOpenSucceeded or ImageOpenFailed event being raised. This listing leverages this fact to temporarily show an indeterminate progress bar while the initial download is occurring, although this is usually extremely fast.

Can MultiScaleImage work with a local image included with the app?

Surprisingly, no! Only online files are supported.

  • The current zoom level and visible region of the image are represented by two properties: ViewportWidth and ViewportOrigin.
    • ViewportWidth is actually the inverse of the zoom level. A value of .5 means that half the width is visible. (So the zoom level is 2.) A value of 2 means that the width of the viewport is double that of the image, so the image width occupies half of the visible area.
    • ViewportOrigin is the point in the image that is currently at the top-left corner of the visible area. The point is expressed in what Deep Zoom calls logical coordinates. In this system, (0,0) is the top-left corner of the image, and (1,1) is the bottom-right corner of the image.
  • This app’s panning functionality is supported with traditional MouseLeftButtonDown, MouseMove, and MouseLeftButtonUp handlers that implement a typical drag-and-drop scheme. In MouseMove, the amount that the finger has moved since MouseLeftButtonDown is applied to the ViewportOrigin, but this value is scaled appropriately based on the control’s width (480 or 800, depending on the phone orientation) and the zoom level. This is necessary because ViewportOrigin must be set to a logical point, and it also ensures that the panning gesture doesn’t get magnified as the user zooms in.

Be sure to use a logical point when setting ViewportOrigin!

Otherwise, the image will likely pan far offscreen. Luckily, MultiScaleImage provides two handy methods—ElementToLogicalPoint and LogicalToElementPoint—for converting between logical points and element-relative points. (When the MultiScaleImage control fills the screen and has no transforms applied, as in this app, element-relative points are equivalent to points on the screen.)

  • After the three handlers that implement panning, this listing contains the three handlers for gesture listener events. The first handler (GestureListener_ DoubleTap) performs a 2x zoom each time a double tap is detected.

MultiScaleImage has built-in inertia effects whenever you change the zoom level or viewport origin, so the panning and zooming done by this app exhibit smooth and inertial transitions without any extra work. If you do not want these effects, simply set MultiScaleImage’s UseSprings property to false.

  • The next two handlers (GestureListener_PinchStarted and GestureListener_PinchDelta) handle pinching and stretching gestures. The DistanceRatio property reveals how much further apart (>1) or closer together (<1) the two fingers are, compared to when they made contact with the screen. The key to getting the appropriate effect is to apply this ratio to the original zoom level captured in the PinchStarted event handler. Normally, as with a ScaleTransform or CompositeTransform, you would multiply the original value by the ratio. Because ViewportWidth is the inverse of the zoom level, however, this listing instead divides its value by the ratio.
  • GestureListener_PinchDelta directly updates ViewportWidth rather than calling the ZoomBy method used elsewhere. ZoomBy centers the zoom around a passedin point, but MultiScaleImage doesn’t work well when the viewport is continually and rapidly moved.

The same three gesture listener events—PinchStarted, PinchDelta, and PinchCompleted—can be used to detect both pinching and stretching.The key piece of data is the DistanceRatio property on PinchGestureEventArgs, which indicates how far apart or close together the two fingers are compared to when they first touched the screen. Be careful how you use this value, however. A value greater than 1 does not necessarily mean stretching is occurring, and a value less than one does not necessarily mean pinching is occurring. For example, users could stretch their fingers until the ratio is 5 and then pinch them until the ratio goes back down to 2. As long as the ratio is continually applied to the zooming element’s original zoom level when the pinch/stretch started rather than the current zoom level, pinching and stretching will work as intended.

  • ZoomBy, used by the double-tap handler and the zooming application bar button handlers, zooms the viewport by an amount relative to the current zoom level with MultiScaleImage’s ZoomAboutLogicalPoint method.

How do I determine the center point of a pinch or stretch gesture, so I can center my zoom on that point?

Although it’s not done by this app (due to flakiness in constantly recentering the viewport), it’s common practice to center the zoom of a pinch or stretch gesture based on the midpoint between the two fingers. Although this point is not directly exposed by the gesture listener, you can calculate it as follows:

[code]

void GestureListener_PinchDelta(object sender, PinchGestureEventArgs e)
{
Point firstPoint = e.GetPosition(this, 0); // Finger #1
Point secondPoint = e.GetPosition(this, 1); // Finger #2
// Calculate the midpoint
Point pinchOrigin = new Point(
(firstPoint.X + secondPoint.X) / 2,
(firstPoint.Y + secondPoint.Y) / 2);

}

[/code]

Both PinchGestureEventArgs and PinchStartedGestureEventArgs expose an overload of GetPosition that enables passing 0 or 1 to get the point for either of the two relevant fingers. (The regular GetPosition overload always gives the data for the first, primary finger.) By continually calculating the midpoint in a PinchDelta event handler rather than once in a PinchStarted event handler, the center is continually updated as the two fingers move,which gives the best experience.

The Finished Product

Deep Zoom Viewer (Pinch, Stretch, & Double Tap Gestures)