Sunday, February 16, 2014

Animating a WPF property when it changes to a non specified value

While working on a small personal project, I decided to make a graphical tool that displayed a list of items in a Canvas. After making it work (for details go here), I've decided to make the items animate when changing their position. In my mind it had to be a simple solution, akin to jQuery animate or something like that; it was not.

The final solution was to finally give up on a generic method for this and switch to the trusted attached properties. But if you are curious to see what else I tried and how ugly it got, read it here:
Click here to get ugly!

Well, long story short: attached properties. I created two attached properties CanvasLeft and CanvasTop. When they change, I animate the real properties and, at the end of the animation, I set the value. Here is the code:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Animation;

namespace Siderite.AttachedProperties
{
    public static class UIElementProperties
    {
        public static readonly DependencyProperty CanvasLeftProperty = DependencyProperty.RegisterAttached("CanvasLeft", typeof(double), typeof(UIElementProperties), new FrameworkPropertyMetadata(
                                                                                            0.0,
                                                                                            FrameworkPropertyMetadataOptions.OverridesInheritanceBehavior,
                                                                                            CanvasLeftChanged));

        [AttachedPropertyBrowsableForType(typeof(UIElement))]
        public static double GetCanvasLeft(DependencyObject element)
        {
            if (element == null)
            {
                throw new ArgumentNullException("element");
            }
            return (double)element.GetValue(CanvasLeftProperty);
        }

        [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
        public static void SetCanvasLeft(DependencyObject element, double value)
        {
            if (element == null)
            {
                throw new ArgumentNullException("element");
            }
            element.SetValue(CanvasLeftProperty, value);
        }

        private static void CanvasLeftChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var sb = new Storyboard();
            var oldVal = (double)e.OldValue;
            if (double.IsNaN(oldVal)) oldVal = 0;
            var newVal = (double)e.NewValue;
            if (double.IsNaN(newVal)) newVal = oldVal;
            var anim = new DoubleAnimation
            {
                From = oldVal,
                To = newVal,
                Duration = new Duration(TimeSpan.FromSeconds(1)),
                FillBehavior = FillBehavior.Stop
            };
            Storyboard.SetTarget(anim, d);
            Storyboard.SetTargetProperty(anim, new PropertyPath("(Canvas.Left)"));
            sb.Children.Add(anim);
            sb.Completed += (s, ev) =>
            {
                d.SetValue(Canvas.LeftProperty, newVal);
            };
            var fe = d as FrameworkElement;
            if (fe != null)
            {
                sb.Begin(fe, HandoffBehavior.Compose);
                return;
            }
            var fce = d as FrameworkContentElement;
            if (fce != null)
            {
                sb.Begin(fce, HandoffBehavior.Compose);
                return;
            }
            sb.Begin();
        }
        

        public static readonly DependencyProperty CanvasTopProperty = DependencyProperty.RegisterAttached("CanvasTop", typeof(double), typeof(UIElementProperties), new FrameworkPropertyMetadata(
                                                                                        0.0,
                                                                                        FrameworkPropertyMetadataOptions.OverridesInheritanceBehavior,
                                                                                        CanvasTopChanged));

        [AttachedPropertyBrowsableForType(typeof(UIElement))]
        public static double GetCanvasTop(DependencyObject element)
        {
            if (element == null)
            {
                throw new ArgumentNullException("element");
            }
            return (double)element.GetValue(CanvasTopProperty);
        }

        [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
        public static void SetCanvasTop(DependencyObject element, double value)
        {
            if (element == null)
            {
                throw new ArgumentNullException("element");
            }
            element.SetValue(CanvasTopProperty, value);
        }

        private static void CanvasTopChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var sb = new Storyboard();
            var oldVal = (double)e.OldValue;
            if (double.IsNaN(oldVal)) oldVal = 0;
            var newVal = (double)e.NewValue;
            if (double.IsNaN(newVal)) newVal = oldVal;
            var anim = new DoubleAnimation
            {
                From = oldVal,
                To = newVal,
                Duration = new Duration(TimeSpan.FromSeconds(1)),
                FillBehavior = FillBehavior.Stop
            };
            Storyboard.SetTarget(anim, d);
            Storyboard.SetTargetProperty(anim, new PropertyPath("(Canvas.Top)"));
            sb.Children.Add(anim);
            sb.Completed += (s, ev) =>
            {
                d.SetValue(Canvas.TopProperty, newVal);
            };
            var fe = d as FrameworkElement;
            if (fe != null)
            {
                sb.Begin(fe, HandoffBehavior.Compose);
                return;
            }
            var fce = d as FrameworkContentElement;
            if (fce != null)
            {
                sb.Begin(fce, HandoffBehavior.Compose);
                return;
            }
            sb.Begin();
        }
    }
}

and this is how you would use them:

<ListView ItemsSource="{Binding KernelItems}" 
          SelectedItem="{Binding SelectedItem,Mode=TwoWay}"
          SelectionMode="Single"
          >
    <ListView.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Black" />
        </ItemsPanelTemplate>
    </ListView.ItemsPanel>
    <ListView.ItemContainerStyle>
        <Style TargetType="{x:Type ListViewItem}">
            <Setter Property="FocusVisualStyle" Value="{x:Null}"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="att:UIElementProperties.CanvasLeft" >
                <Setter.Value>
                    <MultiBinding Converter="{StaticResource CoordinateConverter}">
                        <Binding Path="X"/>
                        <Binding Path="ActualWidth" ElementName="lvKernelItems"/>
                        <Binding Path="DataContext.Zoom" RelativeSource="{RelativeSource AncestorType={x:Type Window}}"/>
                    </MultiBinding>
                </Setter.Value>
            </Setter>
            <Setter Property="att:UIElementProperties.CanvasTop" >
                <Setter.Value>
                    <MultiBinding Converter="{StaticResource CoordinateConverter}">
                        <Binding Path="Y"/>
                        <Binding Path="ActualHeight" ElementName="lvKernelItems"/>
                        <Binding Path="DataContext.Zoom" RelativeSource="{RelativeSource AncestorType={x:Type Window}}"/>
                    </MultiBinding>
                </Setter.Value>
            </Setter>
            <Style.Resources>
                <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="Transparent" />
                <SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="Transparent" />
                <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="Black" />
                <SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}" Color="Black" />
            </Style.Resources>
            <Style.Triggers>
                <Trigger Property="IsSelected" Value="True">
                    <Setter Property="Foreground" Value="Cyan"/>
                    <Setter Property="Effect">
                        <Setter.Value>
                            <DropShadowEffect ShadowDepth="0" Color="White" Opacity="0.5" BlurRadius="10"/>
                        </Setter.Value>
                    </Setter>
                    <Setter Property="Canvas.ZIndex" Value="1000"/>
                </Trigger>
            </Style.Triggers>
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

Hope it helps.

2 comments:

Craig S. said...

In your code, what did the FrameworkPropertyMetadataOptions.OverrideInheritanceBehavior accomplish? I tried to understand the documentation at MSDN, but it's not real clear and there are few examples of its use.

Siderite said...

Honestly, I copy pasted it from previous work. It's so annoying to have to write that from scratch. But from what I remember it was all about children not inheriting the value. For example you want something like opacity to be handled down to the children, otherwise you have opaque elements hovering over translucent ones. In this case each element has their own property and you don't want to propagate it downwards.