ZoomableApplication3: When not to ApplyTransform

By default, ZoomableCanvas works by coercing its RenderTransform to be a combination of a ScaleTransform and a TranslateTransform  representing the current Scale and Offset, respectively.  This is the most performant mode in WPF, and makes your UIElements scale up and down for free, but sometimes you can get better effects without the transform.  If you set ApplyTransform = false, then ZoomableCanvas will position your elements in the correct place as you zoom in and out, but it won’t scale or resize them.  It will be up to your UIElements to respond to the changes in Scale themselves, for example by changing their Width and Height, or changing their shape/template (e.g. semantic zoom), or by even just ignoring it altogether.

To show you an example, I’m going to start with the ZoomableApplication2 from the last post, but I’m going to modify it slightly by setting the Padding to 0 in the ItemContainerStyle and adding Stroke="Black" to the <Rectangle>s and <Ellipse>s (to give them a black outline).  If you haven’t read that post yet, you might want to do so now.

Next, I’m going to set ApplyTransform="False" and modify the Width and Height setters in the <ListBox.ItemContainerStyle> to multiply them by the Scale of the ZoomableCanvas, like this:

 <Setter Property="Width">
    <Setter.Value>
        <MultiBinding Converter="{x:Static ArithmeticConverter.Default}"
                      ConverterParameter="*">
            <Binding Path="width"/>
            <Binding Path="Scale"
                     RelativeSource="{RelativeSource
                                      AncestorType=ZoomableCanvas}"/>
        </MultiBinding>
    </Setter.Value>
</Setter>

<Setter Property="Height">
    <Setter.Value>
        <MultiBinding Converter="{x:Static ArithmeticConverter.Default}"
                      ConverterParameter="*">
            <Binding Path="height"/>
            <Binding Path="Scale"
                     RelativeSource="{RelativeSource
                                      AncestorType=ZoomableCanvas}"/>
        </MultiBinding>
    </Setter.Value>
</Setter>

Notice I’m using my trusty ArithmeticConverter from Presentation.More to do the multiplication (since XAML doesn’t support math directly).  This has the effect of simply resizing the shapes instead of scaling them, but it does nothing to the StrokeThickness or the <TextBlock> inside.  Here are the results side-by-side, with ApplyTransform="True" on the left and ApplyTransform="False" on the right:

(I’ve embedded the sample as an XBAP in an <iframe> above this line, so you should see a live sample and be able to interact with it if you’re using Internet Explorer and have .NET 4.0 installed.)

Notice as you zoom out that it becomes hard to see the black outline around the shapes on the left, but the shapes on the right always maintain the same thickness.  The same is true for the curvy paths.  Also notice how the text labels on the left quickly become too small to see, but the labels on the right are always visible until clipped.

Another situation when to set ApplyTransform="False" is when you want certain items to scale at a different rate than the rest of the canvas.  For example, say you want to be able to add flags or pushpins as landmarks on your canvas, but you don’t want them to become too small when you zoom all the way out.  In this case, you’ll still be applying a ScaleTransform to your items, but each item will have an individual transform instead of sharing one big one.  This is because the location of your landmarks will be changing at a different rate than the scale of the landmarks, so the scene as a whole does not transform uniformly.

To add an overlay with landmarks on top of the canvas, I’m just going to create a second <ZoomableCanvas> on top of the first one, but this time I’m going to use a bare-bones <ItemsControl> instead of a <ListBox>.  This is because the first <ListBox> already gives me scroll bars and keyboard navigation, and I don’t need my landmarks to be selectable.  I’ll place them on top of each other by changing my <DockPanel> into a <Grid> with two rows, and placing both controls in the same row:

 <Grid>

    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>

    <Slider x:Name="MySlider" Grid.Row="0"
            Maximum="1000000" AutoToolTipPlacement="BottomRight"
            ValueChanged="MySlider_ValueChanged"/>

    <ListBox x:Name="MyListBox" Grid.Row="1">

        . . .

    </ListBox>

    <ItemsControl x:Name="MyLandmarks" Grid.Row="1"
                  VerticalAlignment="Top"
                  HorizontalAlignment="Left"
                  Width="{Binding ActualWidth}"
                  Height="{Binding ActualHeight}"
                  Margin="2">

        . . .
        
    </ItemsControl>

</Grid>

The first thing you might notice is that I’m setting Width="{Binding ActualWidth}" and the same for Height.  But what am I binding to?  In the code behind, I’ve assigned the DataContext to the first ZoomableCanvas so that I can access it via {Binding}s.

 private void ZoomableCanvas_Loaded(object sender, RoutedEventArgs e)
{
    // Store the canvas in a local variable since x:Name doesn't work.
    MyCanvas = (ZoomableCanvas)sender;

    // Set the canvas as the DataContext so our overlays can bind to it.
    DataContext = MyCanvas;
}

My overlay <ItemsControl> needs to match the Width and Height of the base canvas explicitly because the size keeps changing as the scroll bars appear and disappear.  If I didn’t make the overlay match then it would draw on top of the scroll bars which would look pretty weird.  Another advantage of setting DataContext = MyCanvas is that I can also match the Scale and Offset in the same way:

 <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
        <ZoomableCanvas Scale="{Binding Scale}"
                        Offset="{Binding Offset}"
                        ApplyTransform="False"
                        ClipToBounds="True"/>
    </ItemsPanelTemplate>
</ItemsControl.ItemsPanel>

The ItemContainerStyle for our landmarks looks similar to the one for our main items, except this time instead of binding the Width and Height to a factor of the Scale, we’ll set the RenderTransform to a ScaleTransform instead:

 <ItemsControl.ItemContainerStyle>
    <Style TargetType="ContentPresenter">
        
        <Setter Property="Canvas.Top" Value="{Binding top}"/>
        <Setter Property="Canvas.Left" Value="{Binding left}"/>
        
        <Setter Property="RenderTransform">
            <Setter.Value>
                <ScaleTransform ScaleX="{Binding ScaleY,
                                RelativeSource={RelativeSource Self}}">
                    <ScaleTransform.ScaleY>
                        
<MultiBinding Converter="{x:Static ArithmeticConverter.Default}"
              ConverterParameter="^">
    <Binding Path="Scale"
             RelativeSource="{RelativeSource
                              AncestorType=ZoomableCanvas}"/>
    <Binding Source=".333"/>
</MultiBinding>
                        
                    </ScaleTransform.ScaleY>
                </ScaleTransform>
            </Setter.Value>
        </Setter>
    </Style>
</ItemsControl.ItemContainerStyle>

(Sorry about the weird formatting - the blog width is really small.)

We’re setting the landmark scale to the cube-root of the main scale (Math.Pow(Scale, .333)).  We are doing this by passing "^" as the ConverterParameter to our ArithmeticConverter as before.  This results in the scale of our landmarks changing much slower than the rest of the canvas, so as you zoom out they stay larger longer, and as you zoom in they stay smaller longer.  Of course you’re welcome to tweak the formula and try completely different equations instead.

For the landmark visual itself, I have a pretty red pushpin that was given to me by Lutz Gerhard.  In fact, he was the one who originally told me about “power-law scaling” in the first place, so a lot of the credit goes to him.

 <ItemsControl.ItemTemplate>
    <DataTemplate>
        <Image Source="Pushpin.png" Margin="-8,-61,0,0"/>
    </DataTemplate>
</ItemsControl.ItemTemplate>

The “tip” of the pushpin (the point at which it appears to punch through the surface) is about 8 pixels over and 61 pixels down in the image, so I’m setting the Margin to center it on that point.  Now I’ll simply add a new landmark whenever you double-click:

 protected override void OnMouseDoubleClick(MouseButtonEventArgs e)
{
    var position = MyCanvas.MousePosition;
    MyLandmarks.Items.Add(new { top = position.Y, left = position.X });
}

And this is what you get!

You can still use the slider to add and remove items, and double-click to add pushpins.  Try adding 1,000,000 items and adding pushpins at each of the 4 corners.  Then you can zoom all the way out and get a rough idea of how big a million items really is!  You’ll also notice that you can double-click when you’ve zoomed and panned around, and the pushpin still ends up at the right location.  This is because we are using MyCanvas.MousePosition instead of e.GetPosition().  If we were to use the latter, then it would always give us the distance relative to the top-left corner of the control on the screen, so if you double-clicked at the top-left corner of the <ListBox> then you’d always get a position of (0,0) even if the canvas was scrolled all the way to the bottom-right!  ZoomableCanvas.MousePosition is a handy property that will always give you the coordinates of the mouse pointer in canvas coordinates, so you don’t have to do the computation to figure it out yourself.   ZoomableCanvas also has GetCanvasPoint(screenPoint) and GetVisualPoint(canvasPoint) if you want to do the same computations but with arbitrary points.

I hope this has given you enough information on how to use the different modes of ZoomableCanvas appropriately.  I’ve attached the source code for ZoomableApplication3 to this post, so hopefully you’ll be up and running with cool effects in no time.  And by using the Visual State Manager in Expression Blend to completely switch the appearance of your items based on the Scale, you’ll be well on your way to implementing semantic zoom.

ZoomableApplication3.zip