Kael Rowan

Foundations of Elegant Code

ZoomableApplication3: When not to ApplyTransform

ZoomableApplication3: When not to ApplyTransform

Rate This
  • Comments 12

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.

Attachment: ZoomableApplication3.zip
  • Kael, this is freaking amazing!

    I've spent a few hours trying to make this kind of zoom (without ApplyTransform) but my approaches wasn't successful, because I'm looking for an event to handle the Zoomable Canvas Scale Changes.

    Now, I'll spike on this using the ItemContainerStyle properties, and trying to get the Semantic Zoom!

    Regards,

    Ignacio Raffa

  • Kael,

    I echo Ignacio's comment - wonderful work, thank you.

  • Kael,

    Have you considered hosting your source at CodePlex or one of the Microsoft hosts?

  • Hi Kael,

    Fantastic work! I've tried to do something similar in Silverlight but ran into troubles relying exclusively on a RenderTransform to pan and zoom the Canvas. As I understand it Silverlight uses single precision floating point math to do it's rendering. The points, widths and heights are all in double but when it comes time to render the visual tree, single precision math is used which meant that my canvas experienced random flickering behaviour when my UIElements were over positioned over 32767 pixels off the canvas.

    Have you tried porting your ZoomableCanvas to Silverlight?

  • Hi Kael,

    I'd like to second Simon Brangwin's question?

    Has anyone ported this to Silverlight... and even more interestingly WP7!

    Thanks,

    Jason

  • I just noticed that Microsoft killed XBAP support in Internet Explorer 9 by default (you have to manually do some steps to re-enable it), so apparently if I want my inline blog examples to continue to work then I will have to port it to Silverlight after all.

  • Silverlight version would be great...

  • Hi, did anyone ever get round to doing a Silverlight version of this control, it would be very useful, it's excellent. And I can't find anything that does its job in Silverlight...

    Thanks

  • Thanks for providing this great control!

    I've tried making use of the ZoomableCanvas.dll assembly in my application, but whatever I try I'm not able to reference the file. In the example source code there's no reference to the assembly in the xaml at all, and I cannot see how it works without it.

  • Very good work this, Kael. I understand that it extends VirtualizingPanel -> Panel but for some reason I can't add touch manipulations to it. Any ideas?

  • When I set the Viewbox to e.g. "0 0 400 400" zooming does not zoom about the mouse position. More specifically, this line does not work since the Offset is replaced by CoerceOffset():

    // Adjust the offset to make the point under the mouse stay still.

    var position = (Vector)e.GetPosition(MyListBox);

    MyCanvas.Offset = (Point)((Vector)(MyCanvas.Offset + position) * x - position);

    How do I accomplish the above when the Viewbox is set?

  • You can set either the Viewbox or the Scale/Offset, but not both.  That is because one directly controls the other.  If you change the Offset, the Viewbox will automatically change (since the Viewbox reflects what you see, and if you change the Offset, you're obviously changing what you see).   Conversely, if you explicitly set the Viewbox to a particular value then the Offset will be automatically changed to get you as close to that Viewbox as possible.  Of course this all depends on your settings for Stretch, StretchDirection, MinScale, MaxScale, etc.  Try playing with all of the different settings to get the effect you're looking for.  

Page 1 of 1 (12 items)
Leave a Comment
  • Please add 2 and 2 and type the answer here:
  • Post