Monday, July 20, 2015

WPF: Animate the removal of a ListViewItem from a ListView


Animating the addition of a new item to a ListView is relatively straightforward. See this question and answer on StackOverflow, for example. But doing the same thing when an item is removed is more difficult. This is because, by the time events like Unloaded go out, the control has already been removed from the tree.

The key to making this work is to add a "MarkedForDeletion" concept somewhere. The act of marking an item for deletion should trigger the animation, and the completion of the animation should trigger the actual removal from the underlying collection. I successfully implemented this idea using an attached behavior I called AnimateItemRemovalBehavior. This class exposes three public properties, all of which must be set on each ListViewItem:
  1. Storyboard of type Storyboard. This is the actual animation you want to run when an item is removed.
  2. PerformRemoval of type ICommand. This is a command that will be executed when the animation is done running. It should execute code to actually remove the element from the databound collection.
  3. IsMarkedForRemoval of type bool. Set this to true when you decide to remove an item from the list (e.g. in a button click handler). As soon as the attached behavior sees this property change to true, it will begin the animation. And when the animation is complete, the PerformRemoval command will Execute.
With that said, let's look at the code! The behavior itself looks like this:

namespace Demo
{
    using System;
    using System.Windows;
    using System.Windows.Input;
    using System.Windows.Media.Animation;

    internal class AnimateItemRemovalBehavior
    {
        public static readonly DependencyProperty StoryboardProperty =
            DependencyProperty.RegisterAttached(
                "Storyboard",
                typeof(Storyboard),
                typeof(AnimateItemRemovalBehavior),
                null);

        public static Storyboard GetStoryboard(DependencyObject o)
        {
            return o.GetValue(StoryboardProperty) as Storyboard;
        }

        public static void SetStoryboard(DependencyObject o, Storyboard value)
        {
            o.SetValue(StoryboardProperty, value);
        }



        public static readonly DependencyProperty PerformRemovalProperty =
            DependencyProperty.RegisterAttached(
                "PerformRemoval",
                typeof(ICommand),
                typeof(AnimateItemRemovalBehavior),
                null);

        public static ICommand GetPerformRemoval(DependencyObject o)
        {
            return o.GetValue(PerformRemovalProperty) as ICommand;
        }

        public static void SetPerformRemoval(DependencyObject o, ICommand value)
        {
            o.SetValue(PerformRemovalProperty, value);
        }



        public static readonly DependencyProperty IsMarkedForRemovalProperty =
            DependencyProperty.RegisterAttached(
                "IsMarkedForRemoval",
                typeof(bool),
                typeof(AnimateItemRemovalBehavior),
                new UIPropertyMetadata(OnMarkedForRemovalChanged));

        public static bool GetIsMarkedForRemoval(DependencyObject o)
        {
            return o.GetValue(IsMarkedForRemovalProperty) as bool? ?? false;
        }

        public static void SetIsMarkedForRemoval(DependencyObject o, bool value)
        {
            o.SetValue(IsMarkedForRemovalProperty, value);
        }



        private static void OnMarkedForRemovalChanged(DependencyObject d,
            DependencyPropertyChangedEventArgs e)
        {
            if ((e.NewValue as bool?) != true)
                return;

            var element = d as FrameworkElement;
            if (element == null)
                throw new InvalidOperationException(
                    "MarkedForRemoval can only be set on a FrameworkElement");

            var performRemoval = GetPerformRemoval(d);
            if (performRemoval == null)
                throw new InvalidOperationException(
                    "MarkedForRemoval requires PerformRemoval to be set too");

            var sb = GetStoryboard(d);
            if (sb == null)
                throw new InvalidOperationException(
                    "MarkedForRemoval requires Stoyboard to be set too");

            if (sb.IsSealed || sb.IsFrozen)
                sb = sb.Clone();

            Storyboard.SetTarget(sb, d);
            sb.Completed += (_, __) =>
            {
                var vm = element.DataContext;
                if (!performRemoval.CanExecute(vm))
                    return;
                performRemoval.Execute(vm);
            };

            sb.Begin();
        }
    }
}

And here is some XAML giving example usage:

<Window x:Class="Demo.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:Demo"
    mc:Ignorable="d"
    Title="MainWindow" Height="350" Width="525">

    <Window.Resources>
        <local:PersonListViewModel x:Key="ViewModel" />
    </Window.Resources>
    
    <DockPanel>
        <TextBlock DockPanel.Dock="Top" Margin="0,0,0,10"
            Text="Click an item to remove it from the list" />
        
        <ListView DataContext="{StaticResource ViewModel}"
            ItemsSource="{Binding PersonList}">
            
            <ListView.ItemContainerStyle>
                <Style TargetType="ListViewItem">
                    <Setter Property="local:AnimateItemRemovalBehavior.Storyboard">
                        <Setter.Value>
                            <Storyboard>
                                <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                    From="1.0" To="0.0" Duration="0:0:0.2" />
                            </Storyboard>
                        </Setter.Value>
                    </Setter>

                    <Setter Property="local:AnimateItemRemovalBehavior.IsMarkedForRemoval"
                        Value="{Binding IsMarkedForRemoval}" />

                    <Setter Property="local:AnimateItemRemovalBehavior.PerformRemoval"
                        Value="{Binding RelativeSource={RelativeSource FindAncestor,
                            AncestorType={x:Type ListView}},
                            Path=DataContext.RemovePersonCommand}" />
                </Style>
            </ListView.ItemContainerStyle>

            <ListView.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}">
                        <TextBlock.InputBindings>
                            <MouseBinding MouseAction="LeftClick"
                                Command="{Binding MarkForRemovalCommand}" />
                        </TextBlock.InputBindings>
                    </TextBlock>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </DockPanel>
</Window>

And finally, some example viewmodel code to go with the XAML:

namespace Demo
{
    using System;
    using System.Collections.ObjectModel;
    using System.ComponentModel;
    using System.Runtime.CompilerServices;
    using System.Windows;
    using System.Windows.Input;

    class ViewModelBase : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged(
            [CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this,
                new PropertyChangedEventArgs(propertyName));
        }
    }

    public class RelayCommand<T> : ICommand where T : class
    {
        private readonly Action<T> _methodToExecute;
        private readonly Func<T, bool> _canExecuteEvaluator;

        public event EventHandler CanExecuteChanged
        {
            add { CommandManager.RequerySuggested += value; }
            remove { CommandManager.RequerySuggested -= value; }
        }

        public RelayCommand(Action<T> methodToExecute,
            Func<T, bool> canExecuteEvaluator = null)
        {
            _methodToExecute = methodToExecute;
            _canExecuteEvaluator = canExecuteEvaluator;
        }

        public bool CanExecute(object parameter)
        {
            return _canExecuteEvaluator?.Invoke(parameter as T) ?? true;
        }

        public void Execute(object parameter)
        {
            _methodToExecute.Invoke(parameter as T);
        }
    }



    class PersonViewModel : ViewModelBase
    {
        public PersonViewModel(string name)
        {
            Name = name;
            MarkForRemovalCommand = new RelayCommand<object>(MarkForRemoval);
        }

        public string Name { get; }

        private bool _isMarkedForRemoval;
        public bool IsMarkedForRemoval
        {
            get { return _isMarkedForRemoval; }

            set
            {
                if (_isMarkedForRemoval == value)
                    return;
                _isMarkedForRemoval = value;
                OnPropertyChanged();
            }
        }

        public ICommand MarkForRemovalCommand { get; }
        private void MarkForRemoval(object argument)
        {
            IsMarkedForRemoval = true;
        }
    }



    class PersonListViewModel : ViewModelBase
    {
        public PersonListViewModel()
        {
            PersonList = new ObservableCollection<PersonViewModel>
            {
                new PersonViewModel("Fred"),
                new PersonViewModel("Barney"),
                new PersonViewModel("Wilma"),
                new PersonViewModel("Pebbles"),
                new PersonViewModel("Bam-bam")
            };

            RemovePersonCommand = new RelayCommand<PersonViewModel>(RemovePerson);
        }

        public ObservableCollection<PersonViewModel> PersonList { get; }

        public ICommand RemovePersonCommand { get; }
        private void RemovePerson(PersonViewModel person)
        {
            PersonList.Remove(person);
        }
    }



    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

Saturday, July 18, 2015

WPF: Using an object as the Value in a Style Setter will seal and freeze it

The title says it all. I discovered this during an attempt to animate the addition of a ListViewItem to a ListView. I decided to do this via an attached behavior that involved an attached property of type Storyboard. The XAML looked something like this:

<Window>
   <Window.Resources>
      <Storyboard x:Key="TheAnimation" x:Shared="False">
         <DoubleAnimation From="0.0" To="1.0" Duration="0:0:0.20"
            Storyboard.TargetProperty="Opacity" />
      </Storyboard>
   </Window.Resources>
    
   <Grid>
      <ListView>
         <ListView.Resources>
            <Style TargetType="{x:Type ListViewItem}">
               <Setter Property="local:MyBehavior.Storyboard"
                  Value="{StaticResource TheAnimation}" />
            </Style>
         </ListView.Resources>
      </ListView>
   </Grid>
</Window>

As you can see, because the ListViewItems are not directly accessibly in XAML, I used a Style to set their MyBehavior.Storyboard attached properties. Then, in the C# code for the behavior, I tried to do this:

private static void StoryboardChanged(DependencyObject d,
   DependencyPropertyChangedEventArgs e)
{
   var listViewItem = d as ListViewItem;
   if (d == null)
      return;

   var sb = e.NewValue as Storyboard;
   if (sb == null)
      return;

   Storyboard.SetTarget(sb, listViewItem);
   sb.Begin();
}

But this blew up with an InvalidOperationException on the call to Storyboard.SetTarget():
Cannot set a property on object 'System.Windows.Media.Animation.Storyboard' because it is in a read-only state
And indeed, inspecting sb in the debugger showed that its IsSealed and IsFrozen properties were both set to true.

Searching around the web turned up evidence that Styles are indeed sealed by design the first time they're used. For example, this page at MSDN ("Note that once a style has been applied, it is sealed and cannot be changed. If you want to dynamically change a style that has already been applied, you must create a new style to replace the existing one"). Examining the source code confirms this, and also demonstrates that sealing a Style seals its Setters, and sealing a Setter seals its Values. So there's no mystery about what happened, but it's not at all clear why all of this sealing is being done in the first place. There are a few claims that this has to do with thread safety, for example, here and here. That seems plausible, but I wonder if there isn't another reason toothe Style node in the XAML presumably translates into a single Style object that is shared among all the objects that consume it. If one of them were to modify it (or its contents) in some way, the change would affect everyone, which is probably not what you want. This is just a guess; I haven't been able to prove it, but it makes sense. Assuming, of course, that I am correct in presuming that only one Style object is created.

In any case, in this case, I was able to solve my problem simply by cloning the Storyboard and working with the clone:

if(sb.IsSealed || sb.IsFrozen)
   sb = sb.Clone();

A more general solution might be to make the property type XyzFactory instead of Xyz, though that would obviously limit your ability to put things together in markup.