Image scaling in Windows Store Apps #2: The zoom dilemma

Since I stumbled upon it in a current project, a follow-up to the recent blog article about image scaling in Store Apps:

The above-mentioned blog post describes how to ensure that an image is scaled down if it does not fit the current viewport, but not enlarged if already smaller than the viewport size. In addition – since Windows Store Apps are usually targeted for use on a touch-enabled tablet device – you might want to allow the user to enlarge the image after having been loaded, using the well-known two-finger zoom gesture. Well, this is easy to achieve by wrapping the Image in a ScrollViewer, and settings its ZoomMode attribute to Enabled. In addition, if only zooming in should be allowed, we can set MinZoomFactor to 1 to ensure that the image is never displayed smaller than its original size.

However, how would one combine the two requirements – resize the image downwards-only on load, while still allowing manual zooming? You might end up trying to figure out the correct order of wrapper controls – let me spoil the surprise: There is no correct way! When wrapping the Image within a Viewbox (as discussed before), and the Viewbox in a ScrollViewer, the ScrollViewer’s MinZoomFactor attribute will be applied to the Viewbox (not the Image) and have no effect, since the Viewbox is displayed in its original size anyway. On the other hand, if you wrap a Viewbox around a ScrollViewer that contains the Image, the downscaling while loading works, but when manually zooming in the image will be enlarged within the bounds of the Viewbox, instead of filling the full screen.

A simple solution is to limit the Viewbox’ size right away, so that the image can’t grow larger than this rectange; in this case the Viewbox can be enlarged using the ScrollViewer’s zooming feature:

<ScrollViewer MinZoomFactor="1">
	<Viewbox StretchDirection="DownOnly" Width="500" Height="500">
		<Image Source="{Binding ImageSource}"/>
	</Viewbox>
</ScrollViewer>

However, to do so we’d need to know the exact Viewport size at compile time, which is not realistic. Instead, we could bind the Viewbox size to the ScrollViewer’s Viewport bounds dynamically:

<ScrollViewer HorizontalScrollBarVisibility="Visible" MinZoomFactor="1" x:Name="ScrollViewer">
	<Viewbox StretchDirection="DownOnly" 
			 Width="{Binding ElementName=ScrollViewer, Path=ViewportWidth}" 
			 Height="{Binding ElementName=ScrollViewer, Path=ViewportHeight}">
		<Image Source="{Binding ImageSource}"/>
	</Viewbox>
</ScrollViewer>

This works if (and, unfortunately, only if) the image is larger than the ScrollViewer, and image and ScrollViewer share the exact same aspect ratio. In all other cases, the image will be correctly scaled down, but displayed in the upper left corner of the available viewport, and not even HorizontalAlignment="Center" VerticalAlignment="Center" can prevent that.

After a few iterations of this game of trial-and-error, I decided that the problem can be solved in a much simpler way by only a few lines of C# code. In order to be able to use the solution in data templates as well as on several different pages within the app, I created a custom control that is based on the Grid control. This class is really simple, all it needs is a property of type ImageSource that mirrors the original Image control’s Source attribute:

public class ImageViewer : Grid
{
	public static readonly DependencyProperty SourceProperty = DependencyProperty.Register(
		"Source", typeof(ImageSource), typeof(ImageViewer), 
		new PropertyMetadata(default(ImageSource), SourceChangedCallback));

	private static void SourceChangedCallback(DependencyObject sender, DependencyPropertyChangedEventArgs args)
	{
		var control = sender as ImageViewer;
		var source = args.NewValue as ImageSource;

		var scroll = new ScrollViewer();
		var img = new Image
		{
			Source = source,
			VerticalAlignment = VerticalAlignment.Center,
			HorizontalAlignment = HorizontalAlignment.Center
		};
		img.ImageOpened += (o, eventArgs) =>
		{
			//TODO
		};
		scroll.Content = img;
		control.Children.Clear();
		control.Children.Add(scroll);
	}

	public ImageSource Source
	{
		get { return (ImageSource) GetValue(SourceProperty); }
		set { SetValue(SourceProperty, value); }
	}
}

Quite simple so far: The Source property needs to be defined as dependency property in order to be bindable, and it contains a callback method that is invoked whenever the Source property is set initially or changed afterwards. Even what this method does is simple: Create a ScrollViewer as the control’s single content, add an Image control within the ScrollViewer, and set its Source.

The most interesting path is the event handler registered on the Image control. First of all, we’re about to calculate the ScrollViewer’s initial zoom level, which is dependent on the image’s original size, therefore we register to the image’s ImageOpened event. The following things are processed within the event handler method:

  • Calculate the ratio of the image’s width to the available viewport width,
  • calculate the ratio of the image’s height to the available viewport height,
  • check whether one of the two is smaller than 1 (otherwise, the image is smaller than the viewport, and the initial zoom level can simply be set to 1 as the image needs not be scaled),
  • if not, use the smaller one of the two values as minimum zoom factor.

In addition, we’re using the resulting value as both the ScrollViewer’s minimum zoom factor, and the initial zoom level to ensure that the image is scaled to this size during load (which results in a smooth scale-down animation), and cannot be scaled smaller than this size by the user. Here is what the full code looks like:

img.ImageOpened += (o, eventArgs) =>
{
	var ratioWidth = scroll.ViewportWidth / img.ActualWidth;
	var ratioHeight = scroll.ViewportHeight / img.ActualHeight;
	var zoomFactor = (ratioWidth >= 1 && ratioHeight >= 1) 
		? 1F 
		: (float) (Math.Min(ratioWidth, ratioHeight));
	scroll.MinZoomFactor = zoomFactor;
	scroll.ChangeView(null, null, zoomFactor);
};

Hope this helps – if somebody comes across a simpler solution (maybe using XAML only?), drop me a line!