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();
        }
    }
}

6 comments:

  1. Looks interesting but you should've include the result.

    ReplyDelete
  2. I've been searching for a solution on this for a really long time and I found some cumbersome solutions that I really didn't like.

    This solution is awesome. Just 3 attached properties and a little bit code for viewmodels and its done. Simple yet awesome.

    Thank you very much!

    ReplyDelete
  3. This comment has been removed by the author.

    ReplyDelete
  4. I am trying to remove several models at once, from my viewmodel I iterate and set IsMarkedForRemoval = true for multiple items, but I am getting this exception in the behavior "MarkedForRemoval requires PerformRemoval to be set too".

    When the DependencyObject gets to the method behavior the line "o.GetValue(PerformRemovalProperty) as ICommand" returns me a null.

    You can replicate my error if you remove the DataContext resource from you XAML and create the instance on the Code behind like below,
    also take out the DataContext attribute from the ListView

    public MainWindow()
    {
    InitializeComponent();
    DataContext = new PersonListViewModel();
    // comment out this line to replicte error
    //((PersonListViewModel)DataContext).PersonList[0].IsMarkedForRemoval = true;
    }

    Any idea how to set the PerformRemoval from the viewmodel ?
    Thanks

    ReplyDelete
  5. Thanks! This is the easiest way to animate an element.

    ReplyDelete
  6. Thank you very much! This is exactly what I was looking for.

    ReplyDelete