Charles Petzold



Realizing a Fisheye Effect in Silverlight

May 18, 2009
New York, N.Y.

I've been experimenting recently with implementing a "fisheye" effect in Silverlight — the effect where a control grows larger as the mouse passes over it. I knew that it wouldn't be as simple as in the Windows Presentation Foundation, but I wanted something at least comparable. In the process I came upon several approaches, some involving the Visual State Manager.

Fisheye Buttons in WPF

In WPF, a fisheye effect can be implemented entirely in markup, as demonstrated by two XAML files from the 75-page chapter on animation in my book Applications = Code + Markup. Here's the FishEyeButtons1.xaml file:

<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <StackPanel.Resources>
        <Style TargetType="{x:Type Button}">
            <Setter Property="HorizontalAlignment" Value="Center" />
            <Setter Property="FontSize" Value="12" />
            <Style.Triggers>
                <EventTrigger RoutedEvent="Button.MouseEnter">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation 
                                Storyboard.TargetProperty="FontSize"
                                To="36" Duration="0:0:1" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
                <EventTrigger RoutedEvent="Button.MouseLeave">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation 
                                Storyboard.TargetProperty="FontSize"
                                To="12" Duration="0:0:0.25" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </Style.Triggers>
        </Style>
    </StackPanel.Resources>

    <Button>Button No. 1</Button>
    <Button>Button No. 2</Button>
    <Button>Button No. 3</Button>
    <Button>Button No. 4</Button>
    <Button>Button No. 5</Button>
    <Button>Button No. 6</Button>
    <Button>Button No. 7</Button>
    <Button>Button No. 8</Button>
    <Button>Button No. 9</Button>
</StackPanel>

The file uses an implicit style to set an initial FontSize property on the buttons, and then animates that property based on the MouseEnter and MouseLeave routed events. If you have .NET 3.X installed, you can run the XAML file here:

FishEyeButtons1.xaml

The FishEyeButtons2.xaml file is similar except it uses a trigger based on the IsMouseOver property rather than event triggers:

<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <StackPanel.Resources>
        <Style TargetType="{x:Type Button}">
            <Setter Property="HorizontalAlignment" Value="Center" />
            <Setter Property="FontSize" Value="12" />
            <Style.Triggers>
                <Trigger Property="IsMouseOver" Value="True">
                    <Trigger.EnterActions>
                        <BeginStoryboard>
                            <Storyboard>
                                <DoubleAnimation 
                                    Storyboard.TargetProperty="FontSize"
                                    To="36" Duration="0:0:1" />
                            </Storyboard>
                        </BeginStoryboard>
                    </Trigger.EnterActions>

                    <Trigger.ExitActions>
                        <BeginStoryboard>
                            <Storyboard>
                                <DoubleAnimation 
                                    Storyboard.TargetProperty="FontSize"
                                    To="12" Duration="0:0:0.25" />
                            </Storyboard>
                        </BeginStoryboard>
                    </Trigger.ExitActions>
                </Trigger>
            </Style.Triggers>
        </Style>
    </StackPanel.Resources>

    <Button>Button No. 1</Button>
    <Button>Button No. 2</Button>
    <Button>Button No. 3</Button>
    <Button>Button No. 4</Button>
    <Button>Button No. 5</Button>
    <Button>Button No. 6</Button>
    <Button>Button No. 7</Button>
    <Button>Button No. 8</Button>
    <Button>Button No. 9</Button>
</StackPanel>

You can run this one here:

FishEyeButtons2.xaml

The FontSize property is certainly not the best property to animate to achieve this effect, but it does the job when the Button content is text. To make the animation more efficient and workable with other forms of content besides text, it's better to animate a ScaleTransform set on the button's LayoutTransform property, like the following program (not from my book):

<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <StackPanel.Resources>
        <Style TargetType="{x:Type Button}">
            <Setter Property="HorizontalAlignment" Value="Center" />
            <Setter Property="FontSize" Value="12" />
            <Setter Property="LayoutTransform">
                <Setter.Value>
                    <ScaleTransform />
                </Setter.Value>
            </Setter>
            <Style.Triggers>
                <EventTrigger RoutedEvent="Button.MouseEnter">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation 
                                Storyboard.TargetProperty="LayoutTransform.ScaleX"
                                To="3" Duration="0:0:1" />
                            <DoubleAnimation 
                                Storyboard.TargetProperty="LayoutTransform.ScaleY"
                                To="3" Duration="0:0:1" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
                <EventTrigger RoutedEvent="Button.MouseLeave">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation 
                                Storyboard.TargetProperty="LayoutTransform.ScaleX"
                                To="1" Duration="0:0:0.25" />
                            <DoubleAnimation 
                                Storyboard.TargetProperty="LayoutTransform.ScaleY"
                                To="1" Duration="0:0:0.25" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </Style.Triggers>
        </Style>
    </StackPanel.Resources>

    <Button>Button No. 1</Button>
    <Button>Button No. 2</Button>
    <Button>Button No. 3</Button>
    <Button>Button No. 4</Button>
    <Button>Button No. 5</Button>
    <Button>Button No. 6</Button>
    <Button>Button No. 7</Button>
    <Button>Button No. 8</Button>
    <Button>Button No. 9</Button>
</StackPanel>

You can run it here:

FishEyeButtons3.xaml

The switch to animating a ScaleTransform seems to double the number of animations, but perhaps not. You can define a Binding on the ScaleTransform between the ScaleX and ScaleY properties to eliminate one of the animations:

<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <StackPanel.Resources>
        <Style TargetType="{x:Type Button}">
            <Setter Property="HorizontalAlignment" Value="Center" />
            <Setter Property="FontSize" Value="12" />
            <Setter Property="LayoutTransform">
                <Setter.Value>
                    <ScaleTransform
                        ScaleY="{Binding RelativeSource={RelativeSource self},
                                         Path=ScaleX}" />
                </Setter.Value>
            </Setter>
            <Style.Triggers>
                <EventTrigger RoutedEvent="Button.MouseEnter">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation 
                                Storyboard.TargetProperty="LayoutTransform.ScaleX"
                                To="3" Duration="0:0:1" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
                <EventTrigger RoutedEvent="Button.MouseLeave">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation 
                                Storyboard.TargetProperty="LayoutTransform.ScaleX"
                                To="1" Duration="0:0:0.25" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </Style.Triggers>
        </Style>
    </StackPanel.Resources>

    <Button>Button No. 1</Button>
    <Button>Button No. 2</Button>
    <Button>Button No. 3</Button>
    <Button>Button No. 4</Button>
    <Button>Button No. 5</Button>
    <Button>Button No. 6</Button>
    <Button>Button No. 7</Button>
    <Button>Button No. 8</Button>
    <Button>Button No. 9</Button>
</StackPanel>

That one is runnable here:

FishEyeButtons4.xaml

Silverlight Fisheye: Version 1

But enough about WPF! How do we do it Silverlight? Silverlight is missing two features that ease the job in WPF: Triggers and the LayoutTransform property. Compensating for these deficiencies is what makes the Silverlight job more challenging (and therefore fun).

One reasonable approach is to create the animations entirely in code. You might have several buttons defined in XAML with event handlers for MouseEnter and MouseLeave:

<Button Style="{StaticResource btnStyle}"
        Content="Button No. 1"
        MouseEnter="OnButtonMouseEnter"
        MouseLeave="OnButtonMouseLeave" />

...

<Button Style="{StaticResource btnStyle}"
        Content="Button No. 9"
        MouseEnter="OnButtonMouseEnter"
        MouseLeave="OnButtonMouseLeave" />

The two events are implemented in code to create and fire animations. As in the WPF version, these animations target a FontSize property initially set to 12:

void OnButtonMouseEnter(object sender, MouseEventArgs args)
{
    DoubleAnimation anima = new DoubleAnimation();
    anima.To = 36;
    anima.Duration = new Duration(TimeSpan.FromSeconds(1));
    Storyboard.SetTarget(anima, sender as Button);
    Storyboard.SetTargetProperty(anima, 
                            new PropertyPath("FontSize"));
    Storyboard storyboard = new Storyboard();
    storyboard.Children.Add(anima);
    storyboard.Begin();
}

void OnButtonMouseLeave(object sender, MouseEventArgs args)
{
    DoubleAnimation anima = new DoubleAnimation();
    anima.To = 12;
    anima.Duration = new Duration(TimeSpan.FromSeconds(0.25));
    Storyboard.SetTarget(anima, sender as Button);
    Storyboard.SetTargetProperty(anima,
                            new PropertyPath("FontSize"));
    Storyboard storyboard = new Storyboard();
    storyboard.Children.Add(anima);
    storyboard.Begin();
}

You can download the whole FisheyeButtons project. The Visual Studio solution consists of one web project (FisheyeButtons.Web) and six Silverlight projects, FisheyeButtons1 through FisheyeButtons6. You can run all six versions from this web page:

FisheyeButtons.html

Silverlight Fisheye: Version 2

Generally in Silverlight, you define an animation Storyboard in a Resources section of a XAML file. The code behind file then merely has the job of accessing the Storyboard and calling Begin on it. But that approach has a problem when implementing a fisheye effect on multiple buttons. You can see that problem in this second version.

The XAML file includes a Resources section with two animation storyboards:

<Storyboard x:Name="growAnimation">
    <DoubleAnimation Storyboard.TargetProperty="FontSize"
                     To="36" Duration="0:0:1" />
</Storyboard>
<Storyboard x:Name="shrinkAnimation">
    <DoubleAnimation Storyboard.TargetProperty="FontSize"
                     To="12" Duration="0:0:0.25" />
</Storyboard>

But keep in mind that resources are shared. There will be only one instance of the shrinkAnimation storyboard and one instance of the growAnimation storyboard, and those will be shared among all the buttons. The events handlers for the MouseEnter and MouseLeave events must stop any animations currently in progress using that storyboard and set a new target object before beginning the animation:

void OnButtonMouseEnter(object sender, MouseEventArgs args)
{
    growAnimation.Stop();
    Storyboard.SetTarget(growAnimation.Children[0],
                         sender as Button);
    growAnimation.Begin();
}

void OnButtonMouseLeave(object sender, MouseEventArgs args)
{
    shrinkAnimation.Stop();
    Storyboard.SetTarget(shrinkAnimation.Children[0],
                         sender as Button);
    shrinkAnimation.Begin();
}

It's fine to share the single growAnimation storyboard among all the buttons, but sharing shrinkAnimation means that only one button can be shrinking at any time. If you slowly move the mouse from one button to the next, buttons will grow and shrink as expected. But if you quickly move the mouse over the buttons, you'll see some buttons snap back to their original positions. That's the result of sharing the shrinkAnimation, and it's simply not a good solution.

Silverlight Fisheye: Version 3

The third version derives a new class called FisheyeButton from Button. The XAML file contains only a Resources section with two Storyboard objects:

<Button x:Class="FisheyeButtons3.FisheyeButton"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Button.Resources>
        <Storyboard x:Name="growAnimation">
            <DoubleAnimation Storyboard.TargetProperty="FontSize"
                             To="36" Duration="0:0:1" />
        </Storyboard>
        <Storyboard x:Name="shrinkAnimation">
            <DoubleAnimation Storyboard.TargetProperty="FontSize"
                             To="12" Duration="0:0:0.25" />
        </Storyboard>
    </Button.Resources>
</Button>

Unfortunately, there's not a good way to set the TargetName in the Storyboard when that name must refer to the object in which the storyboards are defined. Instead, the code-behind for the OnMouseEnter and OnMouseLeave methods manually set the Storyboard target:

protected override void OnMouseEnter(MouseEventArgs args)
{
    DoubleAnimation anima = 
                growAnimation.Children[0] as DoubleAnimation;

    growAnimation.Stop();
    Storyboard.SetTarget(anima, this);
    growAnimation.Begin();
    base.OnMouseEnter(args);
}

protected override void OnMouseLeave(MouseEventArgs args)
{
    DoubleAnimation anima =
                shrinkAnimation.Children[0] as DoubleAnimation;

    shrinkAnimation.Stop();
    Storyboard.SetTarget(anima, this);
    shrinkAnimation.Begin();
    base.OnMouseLeave(args);
}

Even though these animations are not shared among multiple objects, they still must be manually stopped before being begun again. Without that Stop call, it'll work right the first time, but not subsequently.

Moreover, deriving from Button for this job is not really the way to go. For a decent fisheye button that works with content other than text, you'll want to add more markup to the visual tree, and that's more conveniently done by deriving from UserControl

Silverlight Fisheye: Version 4

This fourth version creates a new class again named FisheyeButton, but this time derived from UserControl:

<UserControl x:Class="FisheyeButtons4.FisheyeButton"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <UserControl.Resources>
        <Storyboard x:Name="growAnimation">
            <DoubleAnimation Storyboard.TargetName="btn"
                             Storyboard.TargetProperty="FontSize"
                             To="36" Duration="0:0:1" />
        </Storyboard>
        <Storyboard x:Name="shrinkAnimation">
            <DoubleAnimation Storyboard.TargetName="btn"
                             Storyboard.TargetProperty="FontSize"
                             To="12" Duration="0:0:0.25" />
        </Storyboard>
    </UserControl.Resources>

    <Grid x:Name="LayoutRoot" Background="White">
        <Button Name="btn"
                Click="OnButtonClick" />
    </Grid>
</UserControl>

Now there's a Button in the visual tree, and the two animations can explicitly target that Button object. The code-behind for the OnMouseEnter and OnMouseLeave overrides becomes trivial:

protected override void OnMouseEnter(MouseEventArgs args)
{
    growAnimation.Begin();
    base.OnMouseEnter(args);
}

protected override void OnMouseLeave(MouseEventArgs args)
{
    shrinkAnimation.Begin();
    base.OnMouseLeave(args);
}

However, the code-behind file becomes rather larger because FisheyeButton has to implement some kind of substitute for the button's Content property — a property I called ButtonContent — and also duplicate the Click event of the button. Those requirements apply to subsequent versions as well.

Silverlight Fisheye: Version 5

Although the Visual State Manager classes are normally found in templates, they can also be used in UserControl derivatives, and that's demonstrated by this fifth version. The <vsm:VisualStateManager.VisualStateGroups> tag must be a child of the outermost element of the visual tree set to the Content property of the UserControl:

<UserControl x:Class="FisheyeButtons5.FisheyeButton"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vsm="clr-namespace:System.Windows;assembly=System.Windows">
    <Grid x:Name="LayoutRoot" Background="White">
        <vsm:VisualStateManager.VisualStateGroups>
            <vsm:VisualStateGroup x:Name="CommonStates">
                <vsm:VisualState x:Name="Normal">
                    <Storyboard>
                        <DoubleAnimation Storyboard.TargetName="btn"
                                         Storyboard.TargetProperty="FontSize"
                                         To="12" Duration="0:0:0.25" />
                    </Storyboard>
                </vsm:VisualState>

                <vsm:VisualState x:Name="MouseOver">
                    <Storyboard>
                        <DoubleAnimation Storyboard.TargetName="btn"
                                         Storyboard.TargetProperty="FontSize"
                                         To="36" Duration="0:0:1" />
                    </Storyboard>
                </vsm:VisualState>
            </vsm:VisualStateGroup>
        </vsm:VisualStateManager.VisualStateGroups>

        <Button Name="btn" />
    </Grid>
</UserControl>

The code-behind file is pretty much the same as in the previous version, except the OnMouseEnter and OnMouseLeave overrides call the state GoToState method:

protected override void OnMouseEnter(MouseEventArgs e)
{
    VisualStateManager.GoToState(this, "MouseOver", false);
    base.OnMouseEnter(e);
}

protected override void OnMouseLeave(MouseEventArgs e)
{
    VisualStateManager.GoToState(this, "Normal", false);
    base.OnMouseLeave(e);
}

Silverlight Fisheye: Version 6

Finally it's time to stop animating the FontSize property and switch to animating something more generalized that can also be used with content other than text!

But this is where Silverlight's lack of a LayoutTransform becomes very painful. Sure, you could set the RenderTransform property of the Button to a ScaleTransform and animate that, but the Button would merely grow and shrink in size: It would not push the other buttons aside. You really want the new size of the Button to be respected in layout.

Although Silverlight does not itself have a LayoutTransform property, the indispensable Silverlight toolkit has a handy LayoutTransformer element that can enclose other elements and apply transforms to them that work much like LayoutTransform.

But if you try using LayoutTransformer for this job, you'll discover another problem: LayoutTransformer is not notified when properties of its attached transform are being modified, and hence cannot respond to animations! This is really due to another big Silverlight deficiency — the Freezable class, which in WPF implements sub-property notifications. (As Rob Eisenberg commented in one of my previous blog entries, "The absence of Freezable is pretty much the source of all evil in Silverlight.")

Of course, David Anson, the author of LayoutTransformer is well aware of this limitation, and he's offered a solution in a blog entry entitled A bit more than meets the eye.

Although I played around with his work-around, I wasn't entirely happy. After I created the third and fourth versions of the WPF fisheye button shown above, I came to the conclusion that I didn't like it. I don't think that the whole button (including its chrome and border) should grow and shrink. I think the content of the button should be changing size, and the button should accomodate that content. For that reason (and to make things interesting for myself) I decided to pursue a different approach involving deriving from ContentPresenter, which I suspect is fairly rare.

If you've never written a template for a ContentControl derivative, you've probably never encountered ContentPresenter, but it's the thing that all ContentControl objects contain to display the content of the control.

My derived class is called ScalableContentPresenter. It sets its own RenderTransform property to a ScaleTransform object stored as a field named xform and defines two dependency properties named ScaleX and ScaleY. When either of these two properties changes, the property-changed handler sets the corresponding properties of that ScaleTransform:

static void OnScaleChanged(DependencyObject obj, 
                           DependencyPropertyChangedEventArgs args)
{
    ScalableContentPresenter scaler = 
                        obj as ScalableContentPresenter;
    scaler.xform.ScaleX = scaler.ScaleX;
    scaler.xform.ScaleY = scaler.ScaleY;
    scaler.InvalidateMeasure();
}

Scaling its own RenderTransform causes this ScalableContentPresenter to become larger, but that new size is not reflected in layout. But notice the call to InvalidateMeasure. That call initiates a new layout pass resulting in a call to MeasureOverride, which ScalableContentPresenter handles like so:

protected override Size MeasureOverride(Size availableSize)
{
    Size desiredSize = base.MeasureOverride(availableSize);
    desiredSize.Width *= ScaleX;
    desiredSize.Height *= ScaleY;
    return desiredSize;
}

It simply calls the MeasureOverride in the base class (ContentPresenter itself) and then bumps that returned size up or down based on its ScaleX and ScaleY properties.

The XAML file for FisheyeButton still has a fairly simple visual tree, consisting of a Button enclosing a ScalableContentPresenter. This ScalableContentPresenter does not replace the button's normal ContentPresenter. Instead, that ContentPresenter will have as its content this ScalableContentPresenter.

The Storyboard objects now contain animations that target the ScaleX and ScaleY properties of the ScalableContentPresenter

The code-behind file is pretty much the same as in the previous version with one crucial difference: When the ButtonContent property changes, the property-changed handler doesn't set Content property of the Button, but instead sets the Content property of the ScalableContentPresenter:

static void OnButtonContentChanged(DependencyObject sender,
                  DependencyPropertyChangedEventArgs args)
{
    (sender as FisheyeButton).presenter.Content = args.NewValue;
}

Obviously this is my preferred approach to implementing fisheye buttons in Silverlight, but I don't think I could have arrived here in a single step.