WPF animation: binding to the "To" attribute of storyboard animation

user264621 picture user264621 · Feb 2, 2010 · Viewed 35.3k times · Source

I'm trying to create a button that behaves similarly to the "slide" button on the iPhone. I have an animation that adjusts the position and width of the button, but I want these values to be based on the text used in the control. Currently, they're hardcoded.

Here's my working XAML, so far:

<CheckBox x:Class="Smt.Controls.SlideCheckBox"
          xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
          xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
          xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
          xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
          xmlns:local="clr-namespace:Smt.Controls"
          xmlns:System.Windows="clr-namespace:System.Windows;assembly=PresentationCore"
          Name="SliderCheckBox"
          mc:Ignorable="d">
    <CheckBox.Resources>
        <System.Windows:Duration x:Key="AnimationTime">0:0:0.2</System.Windows:Duration>
        <Storyboard x:Key="OnChecking">
            <DoubleAnimation Storyboard.TargetName="CheckButton"
                             Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.X)"
                             Duration="{StaticResource AnimationTime}"
                             To="40" />
            <DoubleAnimation Storyboard.TargetName="CheckButton"
                             Storyboard.TargetProperty="(Button.Width)"
                             Duration="{StaticResource AnimationTime}"
                             To="41" />
        </Storyboard>
        <Storyboard x:Key="OnUnchecking">
            <DoubleAnimation Storyboard.TargetName="CheckButton"
                             Storyboard.TargetProperty="(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.X)"
                             Duration="{StaticResource AnimationTime}"
                             To="0" />
            <DoubleAnimation Storyboard.TargetName="CheckButton"
                             Storyboard.TargetProperty="(Button.Width)"
                             Duration="{StaticResource AnimationTime}"
                             To="40" />
        </Storyboard>
        <Style x:Key="SlideCheckBoxStyle"
               TargetType="{x:Type local:SlideCheckBox}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type local:SlideCheckBox}">
                        <Canvas>
                            <ContentPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                              Content="{TemplateBinding Content}"
                                              ContentTemplate="{TemplateBinding ContentTemplate}"
                                              RecognizesAccessKey="True"
                                              VerticalAlignment="Center"
                                              HorizontalAlignment="Center" />
                            <Canvas>
                                <!--Background-->
                                <Rectangle Width="{Binding ElementName=ButtonText, Path=ActualWidth}"
                                           Height="{Binding ElementName=ButtonText, Path=ActualHeight}"
                                           Fill="LightBlue" />
                            </Canvas>
                            <Canvas>
                                <!--Button-->
                                <Button Width="{Binding ElementName=CheckedText, Path=ActualWidth}"
                                        Height="{Binding ElementName=ButtonText, Path=ActualHeight}"
                                        Name="CheckButton"
                                        Command="{x:Static local:SlideCheckBox.SlideCheckBoxClicked}">
                                    <Button.RenderTransform>
                                        <TransformGroup>
                                            <TranslateTransform />
                                        </TransformGroup>
                                    </Button.RenderTransform>
                                </Button>
                            </Canvas>
                            <Canvas>
                                <!--Text-->
                                <StackPanel Name="ButtonText"
                                            Orientation="Horizontal"
                                            IsHitTestVisible="False">
                                    <Grid Name="CheckedText">
                                        <Label Margin="7 0"
                                               Content="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:SlideCheckBox}}, Path=CheckedText}" />
                                    </Grid>
                                    <Grid Name="UncheckedText"
                                          HorizontalAlignment="Right">
                                        <Label Margin="7 0"
                                               Content="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:SlideCheckBox}}, Path=UncheckedText}" />
                                    </Grid>
                                </StackPanel>
                            </Canvas>
                        </Canvas>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsChecked"
                                     Value="True">
                                <Trigger.EnterActions>
                                    <BeginStoryboard Storyboard="{StaticResource OnChecking}" />
                                </Trigger.EnterActions>
                                <Trigger.ExitActions>
                                    <BeginStoryboard Storyboard="{StaticResource OnUnchecking}" />
                                </Trigger.ExitActions>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </CheckBox.Resources>
    <CheckBox.CommandBindings>
        <CommandBinding Command="{x:Static local:SlideCheckBox.SlideCheckBoxClicked}"
                        Executed="OnSlideCheckBoxClicked" />
    </CheckBox.CommandBindings>
</CheckBox>

And the code behind:

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace Smt.Controls
{
    public partial class SlideCheckBox : CheckBox
    {
        public SlideCheckBox()
        {
            InitializeComponent();
            Loaded += OnLoaded;
        }

        public static readonly DependencyProperty CheckedTextProperty = DependencyProperty.Register("CheckedText", typeof(string), typeof(SlideCheckBox), new PropertyMetadata("Checked Text"));
        public string CheckedText
        {
            get { return (string)GetValue(CheckedTextProperty); }
            set { SetValue(CheckedTextProperty, value); }
        }

        public static readonly DependencyProperty UncheckedTextProperty = DependencyProperty.Register("UncheckedText", typeof(string), typeof(SlideCheckBox), new PropertyMetadata("Unchecked Text"));
        public string UncheckedText
        {
            get { return (string)GetValue(UncheckedTextProperty); }
            set { SetValue(UncheckedTextProperty, value); }
        }

        public static readonly RoutedCommand SlideCheckBoxClicked = new RoutedCommand();

        void OnLoaded(object sender, RoutedEventArgs e)
        {
            Style style = TryFindResource("SlideCheckBoxStyle") as Style;
            if (!ReferenceEquals(style, null))
            {
                Style = style;
            }
        }

        void OnSlideCheckBoxClicked(object sender, ExecutedRoutedEventArgs e)
        {
            IsChecked = !IsChecked;
        }
    }
}

The problem comes when I try to bind the "To" attribute in the DoubleAnimations to the actual width of the text, the same as I'm doing in the ControlTemplate. If I bind the values to an ActualWidth of an element in the ControlTemplate, the control comes up as a blank checkbox (my base class). However, I'm binding to the same ActualWidths in the ControlTemplate itself without any problems. Just seems to be the CheckBox.Resources that have a problem with it.

For instance, the following will break it:

        <DoubleAnimation Storyboard.TargetName="CheckButton"
                         Storyboard.TargetProperty="(Button.Width)"
                         Duration="{StaticResource AnimationTime}"
                         To="{Binding ElementName=CheckedText, Path=ActualWidth}" />

I don't know whether this is because it's trying to bind to a value that doesn't exist until a render pass is done, or if it's something else. Anyone have any experience with this sort of animation binding?

Answer

Jason Frank picture Jason Frank · Jan 4, 2013

I've had similar situations in ControlTemplates where I've wanted to bind the "To" attribute to a value (rather than hard-coding it), and I finally found a solution.

Quick side note: If you dig around on the web you'll find examples of people being able to use data binding for the "From" or "To" properties. However, in those examples the Storyboards are not in a Style or ControlTemplate. If your Storyboard is in a Style or ControlTemplate, you'll have to use a different approach, such as this solution.

This solution gets around the freezable issue because it simply animates a double value from 0 to 1. It works with a clever use of the Tag property and a Multiply converter. You use a multibinding to bind to both a desired property and your "scale" (the Tag), which get multiplied together. Basically the idea is that your Tag value is what you animate, and its value acts like a "scale" (from 0 to 1) bringing your desired attribute value to "full scale" once you've animated the Tag to 1.

You can see this in action here. The crux of it is this:

<local:MultiplyConverter x:Key="multiplyConverter" />
<ControlTemplate x:Key="RevealExpanderTemp" TargetType="{x:Type Expander}">
    <!-- (other stuff here...) -->
    <ScrollViewer x:Name="ExpanderContentScrollView">
        <!-- ** BEGIN IMPORTANT PART #1 ...  -->
        <ScrollViewer.Tag>
            <sys:Double>0.0</sys:Double>
        </ScrollViewer.Tag>
        <ScrollViewer.Height>
            <MultiBinding Converter="{StaticResource multiplyConverter}">
               <Binding Path="ActualHeight" ElementName="ExpanderContent"/>
               <Binding Path="Tag" RelativeSource="{RelativeSource Self}" />
            </MultiBinding>
        </ScrollViewer.Height>
        <!-- ...end important part #1.  -->
        <ContentPresenter x:Name="ExpanderContent" ContentSource="Content"/>

    </ScrollViewer>

  <ControlTemplate.Triggers>
    <Trigger Property="IsExpanded" Value="True">
        <Trigger.EnterActions>
            <BeginStoryboard>
                <Storyboard>
                   <!-- ** BEGIN IMPORTANT PART #2 (make TargetProperty 'Tag') ...  -->
                   <DoubleAnimation Storyboard.TargetName="ExpanderContentScrollView"
                         Storyboard.TargetProperty="Tag"
                         To="1"
                         Duration="0:0:0.4"/>
                    <!-- ...end important part #2 -->
               </Storyboard>
            </BeginStoryboard>
        </Trigger.EnterActions>
    </Trigger>
  </ControlTemplate.Triggers>
</ControlTemplate>

With this value converter:

public class MultiplyConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
       double result = 1.0;
       for (int i = 0; i < values.Length; i++)
       {
           if (values[i] is double)
               result *= (double)values[i];
       }

       return result;
    }

   public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
   {
       throw new Exception("Not implemented");
   }
}