Ripple Effect in WPF

This article was sparked by a question on StackOverflow. At the beginning I thought that would be a trivial problem. Turns out it was a lot more difficult than expected.

The effect should start where the user clicked, and from there on send out a wave that changes the color of the control.

Ripple Effect Gif

The easiest way to go at it is to define a Decorator, which implements this effect. ContentControls can have exactly one child, so they are ideal for this purpose. By inheriting from ContentControl, we get the implementation for displaying the child element for free. The only thing we have to do is implement the ripple effect.

Lets start with the RippleEffectDecorator class:

public class RippleEffectDecorator : ContentControl
{
    static RippleEffectDecorator()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(RippleEffectDecorator), new FrameworkPropertyMetadata(typeof(RippleEffectDecorator)));
    }
}

The next thing we need is a HighlightBackground property, since after the user clicks on the element, the background should gradually change to another color. We’ll use a dependency property, so that it is possible to use the rich possibilities WPF provides like data binding and animations.

public class RippleEffectDecorator : ContentControl
{
    static RippleEffectDecorator()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(RippleEffectDecorator), new FrameworkPropertyMetadata(typeof(RippleEffectDecorator)));
    }

    public Brush HighlightBackground
    {
        get { return (Brush)GetValue(HighlightBackgroundProperty); }
        set { SetValue(HighlightBackgroundProperty, value); }
    }

    // Using a DependencyProperty as the backing store for HighlightBackground.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty HighlightBackgroundProperty =
        DependencyProperty.Register("HighlightBackground", typeof(Brush), typeof(RippleEffectDecorator), new PropertyMetadata(Brushes.White));
}

The next step is adding defining the template for RippleEffectDecorator. To do that add a ResourceDictionary to your project, ideally in a folder for custom controls.
On the ResourceDictionary, add the namespace where the class RippleEffectDecorator lives in. We need that to define the TargetType of the style we’re about to add.
Your ResourceDictionary should look like that:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:l="clr-namespace:RippleEffectDecoratorApp">
</ResourceDictionary>

Now lets define the ControlTemplate. The basic effect is a circle that expands its radius and therefore covers more and more space, until the whole control is covered by it. Since there is no Circle control in WPF, we use the Ellipse control and bind its Height to its Width (which makes it a circle).
We also need a ContentPresenter, so that the content of the RippleEffectDecorator is displayed.
To be able to have the Ellipse and the ContentPresenter on the same layer, we need a Grid as root element, which allows having multiple children.

<ControlTemplate TargetType="{x:Type l:RippleEffectDecorator}">
    <Grid x:Name="PART_grid" ClipToBounds="True" Background="{TemplateBinding Background}">
        <Ellipse x:Name="PART_ellipse"
            Fill="{Binding Path=HighlightBackground, RelativeSource={RelativeSource TemplatedParent}}" 
            Width="0" Height="{Binding Path=Width, RelativeSource={RelativeSource Self}}" 
            HorizontalAlignment="Left" VerticalAlignment="Top"/>

        <ContentPresenter x:Name="PART_contentpresenter" />
    </Grid>
</ControlTemplate>

The Grids Background is set to the Background of the custom control we are developing. The Ellipse is filled with HighlightBackground. Since the Ellipse is a child of the Grid, its Background is displayed over the Grids Background, which enables us to animate the Ellipse and therefore create the effect we desire.

Now we need to define the animation. In WPF this can easily be done using Storyboards, which can contain an arbitrary number of Animations. Animations change a particular value over a specific amount of time.

The Storyboard has 3 parts: the expanding circle, changing the color back to the original one, collapse the circle.

Unfortunately the ellipse is drawn from its top left corner, so when it expands, it only expands to the bottom right. Therefore we need to animate its margin so that it stays centered. Increasing the radius is a lot easier.
The Animations for the first part look like this:

<DoubleAnimation Storyboard.TargetProperty="Width" From="0" />
<ThicknessAnimation Storyboard.TargetProperty="Margin" />

The properties will be set in code, because we cannot know where the user will click and how the expansion should unfold at design time.

We can change the color back to the original one simply by gradually changing the Opacity from 1 to 0.

<DoubleAnimation BeginTime="0:0:1" Duration="0:0:0.25" Storyboard.TargetProperty="Opacity" From="1" To="0" />

The last part, collapsing the circle and resetting the opacity should be invisible to the user. Therefore we set the duration to 0.

<DoubleAnimation Storyboard.TargetProperty="Width" To="0" BeginTime="0:0:1.25" Duration="0:0:0" />
<DoubleAnimation BeginTime="0:0:1.25" Duration="0:0:0" Storyboard.TargetProperty="Opacity" To="1" />

The whole XAML markup looks like this:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:l="clr-namespace:RippleEffectDecoratorApp">
    <Style TargetType="{x:Type l:RippleEffectDecorator}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type l:RippleEffectDecorator}">
                    <Grid x:Name="PART_grid" ClipToBounds="True" Background="{TemplateBinding Background}">
                        <Ellipse x:Name="PART_ellipse"
                            Fill="{Binding Path=HighlightBackground, RelativeSource={RelativeSource TemplatedParent}}" 
                            Width="0" Height="{Binding Path=Width, RelativeSource={RelativeSource Self}}" 
                            HorizontalAlignment="Left" VerticalAlignment="Top"/>

                        <ContentPresenter x:Name="PART_contentpresenter" />

                        <Grid.Resources>
                            <Storyboard x:Key="PART_animation" Storyboard.TargetName="PART_ellipse">
                                <DoubleAnimation Storyboard.TargetProperty="Width" From="0" />
                                <ThicknessAnimation Storyboard.TargetProperty="Margin" />
                                <DoubleAnimation BeginTime="0:0:1" Duration="0:0:0.25" Storyboard.TargetProperty="Opacity"
                                    From="1" To="0" />
                                <DoubleAnimation Storyboard.TargetProperty="Width" To="0" BeginTime="0:0:1.25" Duration="0:0:0" />
                                <DoubleAnimation BeginTime="0:0:1.25" Duration="0:0:0" Storyboard.TargetProperty="Opacity" To="1" />
                            </Storyboard>
                        </Grid.Resources>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

Now lets move on to the code, which triggers the animation.

The OnApplyTemplate method is used to add event handler. We will add one for the MouseDown event. But first we need a reference to the elements we defined in the Template:

Ellipse ellipse = GetTemplateChild("PART_ellipse") as Ellipse;
Grid grid = GetTemplateChild("PART_grid") as Grid;
Storyboard animation = grid.FindResource("PART_animation") as Storyboard;

In the event handler we have to calculate the target width, so that the circle will cover the whole control. We also need the center point for the animation, which is the position the user clicked on, and the target position for the Ellipse, so that its center stays at the initial position. After setting these properties on the Animations we previously defined in the ResourceDictionary, the Storyboard can be started.

this.AddHandler(MouseDownEvent, new RoutedEventHandler((sender, e) =>
{
    var targetWidth = Math.Max(ActualWidth, ActualHeight) * 2;
    var mousePosition = (e as MouseButtonEventArgs).GetPosition(this);
    var startMargin = new Thickness(mousePosition.X, mousePosition.Y, 0, 0);
    //set initial margin to mouse position
    ellipse.Margin = startMargin;
    //set the to value of the animation that animates the width to the target width
    (animation.Children[0] as DoubleAnimation).To = targetWidth;
    //set the to and from values of the animation that animates the distance relative to the container (grid)
    (animation.Children[1] as ThicknessAnimation).From = startMargin;
    (animation.Children[1] as ThicknessAnimation).To = new Thickness(mousePosition.X - targetWidth / 2, mousePosition.Y - targetWidth / 2, 0, 0);
    ellipse.BeginStoryboard(animation);
}), true);

Passing true as the last parameter signals WPF that this handler should also handle events that were marked as handled by other handlers. This is important, because some Controls (e.g. Button) handle the click event, which means that our animation would never start.

The whole RippleEffectDecorator class looks like this:

public class RippleEffectDecorator : ContentControl
{
    static RippleEffectDecorator()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(RippleEffectDecorator), new FrameworkPropertyMetadata(typeof(RippleEffectDecorator)));
    }

    public Brush HighlightBackground
    {
        get { return (Brush)GetValue(HighlightBackgroundProperty); }
        set { SetValue(HighlightBackgroundProperty, value); }
    }

    // Using a DependencyProperty as the backing store for HighlightBackground.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty HighlightBackgroundProperty =
        DependencyProperty.Register("HighlightBackground", typeof(Brush), typeof(RippleEffectDecorator), new PropertyMetadata(Brushes.White));

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        Ellipse ellipse = GetTemplateChild("PART_ellipse") as Ellipse;
        Grid grid = GetTemplateChild("PART_grid") as Grid;
        Storyboard animation = grid.FindResource("PART_animation") as Storyboard;

        this.AddHandler(MouseDownEvent, new RoutedEventHandler((sender, e) =>
        {
            var targetWidth = Math.Max(ActualWidth, ActualHeight) * 2;
            var mousePosition = (e as MouseButtonEventArgs).GetPosition(this);
            var startMargin = new Thickness(mousePosition.X, mousePosition.Y, 0, 0);
            //set initial margin to mouse position
            ellipse.Margin = startMargin;
            //set the to value of the animation that animates the width to the target width
            (animation.Children[0] as DoubleAnimation).To = targetWidth;
            //set the to and from values of the animation that animates the distance relative to the container (grid)
            (animation.Children[1] as ThicknessAnimation).From = startMargin;
            (animation.Children[1] as ThicknessAnimation).To = new Thickness(mousePosition.X - targetWidth / 2, mousePosition.Y - targetWidth / 2, 0, 0);
            ellipse.BeginStoryboard(animation);
        }), true);
    }
}

To use it you simply have to add the namespace you created the CustomControl in and set the RippleEffectDecorator around an element.

<Window x:Class="RippleEffectDecoratorApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:RippleEffectDecoratorApp">
    <Grid>
        <local:RippleEffectDecorator Background="Black" HighlightBackground="White">
            <Label FontSize="60">test</Label>
        </local:RippleEffectDecorator>
    </Grid>
</Window>

You can download the sample code here.