for WPF developers
Home Profile Tips 全記事一覧

ListBox のアイテム追加/削除のときにアニメーションする

(2017/05/16 8:15:17 created.)

(2017/05/24 17:00:48 modified.)

ListBox コントロールに動的にアイテムを追加または削除したとき、ほわっとアニメーションして表示/非表示されると視覚的に楽しいアプリになります。

ItemsControl クラスから派生している ListBox コントロールなどは、与えられたアイテムを羅列するとき、各アイテムを格納したコンテナを表示しています。ここでは、このコンテナにカスタムコントロールを使用するようにし、このカスタムコントロールでアニメーションを実現します。

そういうわけで、カスタムコントロールとして次のような AnimatedContainer コントロールを定義します。

Generic.AnimatedContainer.xaml
  1. <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  2.                     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  3.                     xmlns:local="clr-namespace:Tips_AnimatedListBoxItem">
  4.     <Style TargetType="{x:Type local:AnimatedContainer}">
  5.         <Setter Property="Template">
  6.             <Setter.Value>
  7.                 <ControlTemplate TargetType="{x:Type local:AnimatedContainer}">
  8.                     <Border Background="{TemplateBinding Background}"
  9.                             BorderBrush="{TemplateBinding BorderBrush}"
  10.                             BorderThickness="{TemplateBinding BorderThickness}">
  11.                         <StackPanel>
  12.                             <Button x:Name="PART_DeleteButton" />
  13.                             <ContentPresenter />
  14.                         </StackPanel>
  15.                     </Border>
  16.                 </ControlTemplate>
  17.             </Setter.Value>
  18.         </Setter>
  19.     </Style>
  20. </ResourceDictionary>
Generic.AnimatedContainer.xaml.cs
  1. namespace Tips_AnimatedListBoxItem
  2. {
  3.     using System;
  4.     using System.Windows;
  5.     using System.Windows.Controls;
  6.     using System.Windows.Input;
  7.     using System.Windows.Media.Animation;
  8.  
  9.     /// <summary>
  10.     /// アニメーションを含むコンテナを表します。
  11.     /// </summary>
  12.     [TemplatePart(Name = PART_DeleteButton, Type = typeof(Button))]
  13.     public class AnimatedContainer : ContentControl
  14.     {
  15.         #region TemplatePart
  16.         /// <summary>
  17.         /// 削除ボタンに対する名前
  18.         /// </summary>
  19.         private const string PART_DeleteButton = "PART_DeleteButton";
  20.  
  21.         private Button _deleteButton;
  22.         /// <summary>
  23.         /// 削除ボタンを取得または設定します。
  24.         /// </summary>
  25.         private Button DeleteButton
  26.         {
  27.             get { return this._deleteButton; }
  28.             set
  29.             {
  30.                 if (this._deleteButton != null)
  31.                 {
  32.                     this._deleteButton.Click -= OnDeleteButtonClick;
  33.                 }
  34.                 this._deleteButton = value;
  35.                 if (this._deleteButton != null)
  36.                 {
  37.                     this._deleteButton.Click += OnDeleteButtonClick;
  38.                 }
  39.             }
  40.         }
  41.  
  42.         /// <summary>
  43.         /// テンプレートを適用します。
  44.         /// </summary>
  45.         public override void OnApplyTemplate()
  46.         {
  47.             base.OnApplyTemplate();
  48.  
  49.             this.DeleteButton = this.Template.FindName(PART_DeleteButton, this) as Button;
  50.         }
  51.         #endregion TemplatePart
  52.  
  53.         #region コンストラクタ
  54.         /// <summary>
  55.         /// 静的なコンストラクタを表します。
  56.         /// </summary>
  57.         static AnimatedContainer()
  58.         {
  59.             DefaultStyleKeyProperty.OverrideMetadata(typeof(AnimatedContainer), new FrameworkPropertyMetadata(typeof(AnimatedContainer)));
  60.         }
  61.  
  62.         /// <summary>
  63.         /// 新しいインスタンスを生成します。
  64.         /// </summary>
  65.         public AnimatedContainer()
  66.         {
  67.             this.Opacity = 0;
  68.  
  69.             #region InAnimation 初期化
  70.  
  71.             this._heightInAnimation = new DoubleAnimation()
  72.             {
  73.                 From = 0,
  74.                 Duration = TimeSpan.FromMilliseconds(200),
  75.             };
  76.             Storyboard.SetTargetProperty(this._heightInAnimation, new PropertyPath("Height"));
  77.  
  78.             this._widthInAnimation = new DoubleAnimation()
  79.             {
  80.                 From = 0,
  81.                 Duration = TimeSpan.FromMilliseconds(200),
  82.             };
  83.             Storyboard.SetTargetProperty(this._widthInAnimation, new PropertyPath("Width"));
  84.  
  85.             this._opacityInAnimation = new DoubleAnimation()
  86.             {
  87.                 From = 0,
  88.                 To = 1,
  89.                 BeginTime = TimeSpan.FromMilliseconds(240),
  90.                 Duration = TimeSpan.FromMilliseconds(200),
  91.             };
  92.             Storyboard.SetTargetProperty(this._opacityInAnimation, new PropertyPath("Opacity"));
  93.  
  94.             this._inStoryboard = new Storyboard();
  95.             this._inStoryboard.Completed += (_, __) => this._isAnimated = false;
  96.  
  97.             #endregion InAnimation 初期化
  98.  
  99.             #region OutAnimation 初期化
  100.  
  101.             this._heightOutAnimation = new DoubleAnimation()
  102.             {
  103.                 To = 0,
  104.                 BeginTime = TimeSpan.FromMilliseconds(240),
  105.                 Duration = TimeSpan.FromMilliseconds(200),
  106.             };
  107.             Storyboard.SetTargetProperty(this._heightOutAnimation, new PropertyPath("Height"));
  108.  
  109.             this._widthOutAnimation = new DoubleAnimation()
  110.             {
  111.                 To = 0,
  112.                 BeginTime = TimeSpan.FromMilliseconds(240),
  113.                 Duration = TimeSpan.FromMilliseconds(200),
  114.             };
  115.             Storyboard.SetTargetProperty(this._widthOutAnimation, new PropertyPath("Width"));
  116.  
  117.             this._opacityOutAnimation = new DoubleAnimation()
  118.             {
  119.                 From = 1,
  120.                 To = 0,
  121.                 Duration = TimeSpan.FromMilliseconds(200),
  122.             };
  123.             Storyboard.SetTargetProperty(this._opacityOutAnimation, new PropertyPath("Opacity"));
  124.  
  125.             this._outStoryboard = new Storyboard();
  126.             this._outStoryboard.Completed += (_, __) =>
  127.             {
  128.                 this._isAnimated = false;
  129.                 if (this.DeletedCommand != null)
  130.                 {
  131.                     if (DeletedCommand.CanExecute(this.DataContext))
  132.                     {
  133.                         DeletedCommand.Execute(this.DataContext);
  134.                     }
  135.                 }
  136.             };
  137.  
  138.             #endregion OutAnimation 初期化
  139.  
  140.             this.SizeChanged += OnSizeChanged;
  141.         }
  142.         #endregion コンストラクタ
  143.  
  144.         #region DeletedCommand 依存関係プロパティ
  145.  
  146.         /// <summary>
  147.         /// DeletedCommand 依存関係プロパティを定義します。
  148.         /// </summary>
  149.         public static DependencyProperty DeletedCommandProperty = DependencyProperty.Register("DeletedCommand", typeof(ICommand), typeof(AnimatedContainer), new PropertyMetadata(null));
  150.  
  151.         /// <summary>
  152.         /// 削除ボタンクリック後に実行されるコマンドを取得または設定します。
  153.         /// </summary>
  154.         public ICommand DeletedCommand
  155.         {
  156.             get { return (ICommand)GetValue(DeletedCommandProperty); }
  157.             set { SetValue(DeletedCommandProperty, value); }
  158.         }
  159.  
  160.         #endregion DeletedCommand 依存関係プロパティ
  161.  
  162.         #region Direction 依存関係プロパティ
  163.         public static readonly DependencyProperty DirectionProperty = DependencyProperty.Register("Direction", typeof(SizeToContent), typeof(AnimatedContainer), new PropertyMetadata(SizeToContent.Height));
  164.  
  165.         public SizeToContent Direction
  166.         {
  167.             get { return (SizeToContent)GetValue(DirectionProperty); }
  168.             set { SetValue(DirectionProperty, value); }
  169.         }
  170.         #endregion Direction 依存関係プロパティ
  171.  
  172.         #region イベントハンドラ
  173.  
  174.         /// <summary>
  175.         /// 削除ボタンクリックイベントハンドラ
  176.         /// </summary>
  177.         /// <param name="sender">イベント発行元</param>
  178.         /// <param name="e">イベント引数</param>
  179.         private void OnDeleteButtonClick(object sender, RoutedEventArgs e)
  180.         {
  181.             BeginOutAnimation();
  182.         }
  183.  
  184.         /// <summary>
  185.         /// SizeChanged イベントハンドラ
  186.         /// </summary>
  187.         /// <param name="sender">イベント発行元</param>
  188.         /// <param name="e">イベント引数</param>
  189.         private void OnSizeChanged(object sender, SizeChangedEventArgs e)
  190.         {
  191.             if (this._isAnimated)
  192.                 return;
  193.  
  194.             this._heightInAnimation.To = e.NewSize.Height;
  195.             this._widthInAnimation.To = e.NewSize.Width;
  196.             BeginInAnimation();
  197.         }
  198.  
  199.         #endregion イベントハンドラ
  200.  
  201.         #region アニメーション
  202.         /// <summary>
  203.         /// 表示開始アニメーションを開始します。
  204.         /// </summary>
  205.         private void BeginInAnimation()
  206.         {
  207.             this._inStoryboard.Children.Clear();
  208.             switch (this.Direction)
  209.             {
  210.                 case SizeToContent.Height:
  211.                     this._inStoryboard.Children.Add(this._heightInAnimation);
  212.                     break;
  213.  
  214.                 case SizeToContent.Manual:
  215.                     break;
  216.  
  217.                 case SizeToContent.Width:
  218.                     this._inStoryboard.Children.Add(this._widthInAnimation);
  219.                     break;
  220.  
  221.                 case SizeToContent.WidthAndHeight:
  222.                     this._inStoryboard.Children.Add(this._heightInAnimation);
  223.                     this._inStoryboard.Children.Add(this._widthInAnimation);
  224.                     break;
  225.             }
  226.             this._inStoryboard.Children.Add(this._opacityInAnimation);
  227.  
  228.             this._isAnimated = true;
  229.             this.BeginStoryboard(this._inStoryboard);
  230.         }
  231.  
  232.         /// <summary>
  233.         /// 非表示アニメーションを開始します。
  234.         /// </summary>
  235.         private void BeginOutAnimation()
  236.         {
  237.             this._outStoryboard.Children.Clear();
  238.             switch (this.Direction)
  239.             {
  240.                 case SizeToContent.Height:
  241.                     this._outStoryboard.Children.Add(this._heightOutAnimation);
  242.                     break;
  243.  
  244.                 case SizeToContent.Manual:
  245.                     break;
  246.  
  247.                 case SizeToContent.Width:
  248.                     this._outStoryboard.Children.Add(this._widthOutAnimation);
  249.                     break;
  250.  
  251.                 case SizeToContent.WidthAndHeight:
  252.                     this._outStoryboard.Children.Add(this._heightOutAnimation);
  253.                     this._outStoryboard.Children.Add(this._widthOutAnimation);
  254.                     break;
  255.             }
  256.             this._outStoryboard.Children.Add(this._opacityOutAnimation);
  257.  
  258.             this._isAnimated = true;
  259.             this.BeginStoryboard(this._outStoryboard);
  260.         }
  261.         #endregion アニメーション
  262.  
  263.         #region private フィールド
  264.  
  265.         /// <summary>
  266.         /// アニメーション中かどうかを判別します。
  267.         /// </summary>
  268.         private bool _isAnimated;
  269.  
  270.         /// <summary>
  271.         /// 表示されるときのアニメーション用ストーリーボード
  272.         /// </summary>
  273.         private Storyboard _inStoryboard;
  274.  
  275.         /// <summary>
  276.         /// 表示されるときの高さアニメーション
  277.         /// </summary>
  278.         private DoubleAnimation _heightInAnimation;
  279.  
  280.         /// <summary>
  281.         /// 表示されるときの幅アニメーション
  282.         /// </summary>
  283.         private DoubleAnimation _widthInAnimation;
  284.  
  285.         /// <summary>
  286.         /// 表示されるときの透明度アニメーション
  287.         /// </summary>
  288.         private DoubleAnimation _opacityInAnimation;
  289.  
  290.         /// <summary>
  291.         /// 非表示になるときのアニメーション用ストーリーボード
  292.         /// </summary>
  293.         private Storyboard _outStoryboard;
  294.  
  295.         /// <summary>
  296.         /// 非表示になるときの高さアニメーション
  297.         /// </summary>
  298.         private DoubleAnimation _heightOutAnimation;
  299.  
  300.         /// <summary>
  301.         /// 非表示になるときの幅アニメーション
  302.         /// </summary>
  303.         private DoubleAnimation _widthOutAnimation;
  304.  
  305.         /// <summary>
  306.         /// 非表示になるときの透明度アニメーション
  307.         /// </summary>
  308.         private DoubleAnimation _opacityOutAnimation;
  309.  
  310.         #endregion private フィールド
  311.     }
  312. }

外観を定義している XAML はカスタムコントロール作成時に自動生成されたコードからほとんど触っておらず、Border コントロールの中に Button コントロールと ContentPresenter コントロールを含む StackPanel コントロールを追加しただけとなっています。追加したコントロールについて特に何も設定していませんが、この AnimatedContainer コントロールを使うときは、ユーザー側が ControlTemplate を与えることで自分のアプリケーションに合ったレイアウトにしてもらうため、ここでは特に外観を定義することはありません。

コードビハインドのほうでは、用意されたボタンに対するクリックイベントを購読するようにしています。

やりたいことは、ListBox コントロールにアイテムが追加/削除されるときにアニメーションしたいということでした。まずアイテムが追加されるシーンを考えてみましょう。アイテムが追加されるということは、ListBox コントロールの中でコンテナが追加生成されるということになります。したがって、AnimatedContainer コントロールが生成されたときにアニメーションするようにすればアイテム追加時にアニメーションされるようになるでしょう。

コントロールが生成されるとき、Loaded イベントが発生し、コントロールのサイズが確定すると SizeChanged イベントが発生します。ここでは、コントロールのサイズを使用したアニメーションを使用するため、SizeChanged イベントを購読します。

コンストラクタの中でイベントを購読し、SizeChanged イベントのイベントハンドラでアニメーションを開始しています。ただし、private フィールドを使ってアニメーション中に再度実行されないようにしています。

次に、ListBox コントロールからアイテムを削除するときのアニメーションです。削除するときにアニメーションする場合、アイテムが削除されてしまうとそのコンテナが消えてしまうためアニメーションできません。つまり、アニメーションしてからアイテムを削除する、というように順序を逆にする必要があります。

ここでは、冒頭で用意したボタンの Click イベントハンドラで削除用のアニメーションを開始させます。

削除用のアニメーションが終了したら ListBox コントロールから実際にアイテムを削除する処理を実行しなければいけません。ここでは、その処理をこのコントロールに委譲できるように DeletedCommand 依存関係プロパティを追加し、これをアニメーション終了時に実行するようにします。また、アニメーション終了時に実行させるには、Storyboard クラスの Completed イベントを利用します。

このカスタムコントロールを使用したサンプルアプリケーションのコードを以下に掲載します。

MainViewModel.cs
  1. namespace Tips_AnimatedListBoxItem.ViewModels
  2. {
  3.     using System;
  4.     using System.Collections.ObjectModel;
  5.     using System.ComponentModel;
  6.     using System.Runtime.CompilerServices;
  7.  
  8.     internal class MainViewModel : INotifyPropertyChanged
  9.     {
  10.         private ObservableCollection<string> _stringCollection = new ObservableCollection<string>();
  11.         /// <summary>
  12.         /// コレクションデータを取得します。
  13.         /// </summary>
  14.         public ObservableCollection<string> StringCollection
  15.         {
  16.             get { return this._stringCollection; }
  17.         }
  18.  
  19.         /// <summary>
  20.         /// コレクションデータ数を取得します。
  21.         /// </summary>
  22.         public int Count
  23.         {
  24.             get { return this.StringCollection.Count; }
  25.         }
  26.  
  27.         private DelegateCommand _addCommand;
  28.         /// <summary>
  29.         /// コレクション追加コマンドを取得します。
  30.         /// </summary>
  31.         public DelegateCommand AddCommand
  32.         {
  33.             get
  34.             {
  35.                 return this._addCommand ?? (this._addCommand = new DelegateCommand(_ =>
  36.                 {
  37.                     this._counter++;
  38.                     Add(string.Format("私がアイテム No.{0} だ。", this._counter));
  39.                 }));
  40.             }
  41.         }
  42.  
  43.         private DelegateCommand _deleteCommand;
  44.         /// <summary>
  45.         /// コレクション削除コマンドを取得します。
  46.         /// </summary>
  47.         public DelegateCommand DeleteCommand
  48.         {
  49.             get
  50.             {
  51.                 return this._deleteCommand ?? (this._deleteCommand = new DelegateCommand(p =>
  52.                 {
  53.                     Delete(p as string);
  54.                 }));
  55.             }
  56.         }
  57.  
  58.         /// <summary>
  59.         /// カウンタ
  60.         /// </summary>
  61.         private int _counter;
  62.  
  63.         /// <summary>
  64.         /// 乱数発生器
  65.         /// </summary>
  66.         private Random _random = new Random();
  67.  
  68.         /// <summary>
  69.         /// コレクションにアイテムを追加します。
  70.         /// </summary>
  71.         /// <param name="item">追加するアイテムを指定します。</param>
  72.         private void Add(string item)
  73.         {
  74.             this.StringCollection.Insert(this._random.Next(0, this.StringCollection.Count), item);
  75.             RaisePropertyChanged("Count");
  76.         }
  77.  
  78.         /// <summary>
  79.         /// コレクションからアイテムを削除します。
  80.         /// </summary>
  81.         /// <param name="item"></param>
  82.         private void Delete(string item)
  83.         {
  84.             this.StringCollection.Remove(item);
  85.             RaisePropertyChanged("Count");
  86.         }
  87.  
  88.         #region INotifyPropertyChanged のメンバ
  89.         /// <summary>
  90.         /// プロパティ値変更時に発生します。
  91.         /// </summary>
  92.         public event PropertyChangedEventHandler PropertyChanged;
  93.  
  94.         /// <summary>
  95.         /// PropertyChanged イベントを発行します。
  96.         /// </summary>
  97.         /// <param name="propertyName">プロパティ値が変更されたプロパティ名を指定します。</param>
  98.         private void RaisePropertyChanged([CallerMemberName]string propertyName = null)
  99.         {
  100.             var h = this.PropertyChanged;
  101.             if (h != null) h(this, new PropertyChangedEventArgs(propertyName));
  102.         }
  103.         #endregion INotifyPropertyChanged のメンバ
  104.     }
  105. }
MainView.xaml
  1. <Window x:Class="Tips_AnimatedListBoxItem.Views.MainView"
  2.         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  3.         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  4.         xmlns:local="clr-namespace:Tips_AnimatedListBoxItem"
  5.         Title="MainView" Height="300" Width="300">
  6.     <Grid>
  7.         <Grid.RowDefinitions>
  8.             <RowDefinition Height="Auto" />
  9.             <RowDefinition />
  10.         </Grid.RowDefinitions>
  11.  
  12.         <StackPanel>
  13.             <Button Content="Add" Command="{Binding AddCommand}" />
  14.             <TextBlock Text="{Binding Count, StringFormat='{}アイテムが {0} 個登録されています。'}" />
  15.         </StackPanel>
  16.  
  17.         <ListBox Grid.Row="1" ItemsSource="{Binding StringCollection}">
  18.             <ListBox.ItemContainerStyle>
  19.                 <Style TargetType="{x:Type ListBoxItem}">
  20.                     <Setter Property="Template">
  21.                         <Setter.Value>
  22.                             <ControlTemplate TargetType="{x:Type ContentControl}">
  23.                                 <Border Background="{TemplateBinding Background}">
  24.                                     <local:AnimatedContainer DeletedCommand="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
  25.                                                              Direction="Height">
  26.                                         <local:AnimatedContainer.Template>
  27.                                             <ControlTemplate TargetType="{x:Type local:AnimatedContainer}">
  28.                                                 <StackPanel Orientation="Horizontal">
  29.                                                     <Button x:Name="PART_DeleteButton"
  30.                                                             Content="Delete" Margin="2"
  31.                                                             />
  32.                                                     <TextBlock Text="{Binding .}" VerticalAlignment="Center" />
  33.                                                 </StackPanel>
  34.                                             </ControlTemplate>
  35.                                         </local:AnimatedContainer.Template>
  36.                     </local:AnimatedContainer>
  37.                                 </Border>
  38.                             </ControlTemplate>
  39.                         </Setter.Value>
  40.                     </Setter>
  41.                     <Style.Triggers>
  42.                         <Trigger Property="IsMouseOver" Value="True">
  43.                             <Setter Property="Background" Value="LightGray" />
  44.                         </Trigger>
  45.                         <Trigger Property="IsSelected" Value="True">
  46.                             <Setter Property="Background" Value="Plum" />
  47.                         </Trigger>
  48.                     </Style.Triggers>
  49.                 </Style>
  50.             </ListBox.ItemContainerStyle>
  51.         </ListBox>
  52.     </Grid>
  53. </Window>

Add ボタンを押すと ListBox コントロールにアイテムがランダムの順序で追加され、アイテム中の Delete ボタンを押すと該当するアイテムが削除されます。アイテム追加/削除されるときにはアニメーションが実行されるようになっています。