2010年6月10日木曜日

番外編 VisualStateManagerを使ってみる

今回はVisual State Manager(以下VSM)を使ってみます。
VSMは名前の通り、コントロールなどの表示物の状態管理をしやすくするものです。


詳しい経緯は知らないのですが、
もともとはTriggerが貧弱なSilverLight用に用意されたものらしく、
WPFでは必ずしも使わなくても済ませられるようです。
しかしこれを使うことにより、状態の管理、およびモデルとビューの分離がしやすくなるのではと思っています。


これは.Net Framework3.5だけでは使えないので、
3.5にWPF tool kitを導入するか、.Net Frameworkの4.0を導入してください。


今回のサンプルは以下のようなイメージです。

まずは単純に2つの楕円が回転するだけのコードを。


<Window x:Class="VisualStateManager.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="200" Width="300">
    <Grid>
  <Ellipse Stroke="Green" StrokeThickness="2" Width="100" Height="50">
   <Ellipse.RenderTransform>
    <RotateTransform Angle="45" CenterX="50" CenterY="25" x:Name="_rotate1"/>
   </Ellipse.RenderTransform>
  </Ellipse>
  <Ellipse Stroke="Green" StrokeThickness="2" Width="100" Height="50">
   <Ellipse.RenderTransform>
    <RotateTransform Angle="0" CenterX="50" CenterY="25" x:Name="_rotate2"/>
   </Ellipse.RenderTransform>
  </Ellipse>
  <Grid.Triggers>
   <EventTrigger RoutedEvent="Loaded">
    <BeginStoryboard>
     <Storyboard BeginTime="0:0:0" RepeatBehavior="Forever">
      <DoubleAnimation Storyboard.TargetName="_rotate1" Storyboard.TargetProperty="Angle"
       Duration="0:0:2" From="45" To="405"/>
      <DoubleAnimation Storyboard.TargetName="_rotate2" Storyboard.TargetProperty="Angle"
       Duration="0:0:2" From="360" To="0"/>
     </Storyboard>
    </BeginStoryboard>
   </EventTrigger>
  </Grid.Triggers>
  
  <Grid.Background>
   <RadialGradientBrush Center="0.5, 0.5">
    <GradientStop Color="Gray" Offset="0"/>
    <GradientStop Color="Black" Offset="1"/>
   </RadialGradientBrush>
  </Grid.Background>
 </Grid>
</Window>

2つの楕円(Ellipse)を配置して、
それぞれRotateTransformで角度を付けています。

あとはLoadedイベントでアニメーション(Storyboard)を開始します。
アニメーションはそれぞれ逆方向に
RotateTransformの角度の数値(double値)を2秒で360度変化するように設定しています。
最後のBackgroundは雰囲気を出すためだけのものです。

ではVisualStateManagerを使ってみます。
状態が「通信中」の時だけアニメーションするようにしてみましょう。
「通信中」かどうかの状態変化はチェックボックスで代用します。


<Window x:Class="VisualStateManagerSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="200" Width="300">

 <VisualStateManager.VisualStateGroups>
  <VisualStateGroup>
   <VisualState x:Name="Idle">

   </VisualState>
   <VisualState Name="Accessing">
    <Storyboard BeginTime="0:0:0" RepeatBehavior="Forever">
     <DoubleAnimation Storyboard.TargetName="_rotate1" Storyboard.TargetProperty="Angle"
       Duration="0:0:2" From="45" To="405"/>
     <DoubleAnimation Storyboard.TargetName="_rotate2" Storyboard.TargetProperty="Angle"
       Duration="0:0:2" From="360" To="0"/>
    </Storyboard>
   </VisualState>
  </VisualStateGroup>
 </VisualStateManager.VisualStateGroups>

 <Grid>
  <CheckBox Foreground="White" Checked="CheckBox_Checked" Unchecked="CheckBox_Unchecked">通信中</CheckBox>
  <Ellipse Stroke="Green" StrokeThickness="2" Width="100" Height="50">
   <Ellipse.RenderTransform>
    <RotateTransform Angle="45" CenterX="50" CenterY="25" x:Name="_rotate1"/>
   </Ellipse.RenderTransform>
  </Ellipse>
  <Ellipse Stroke="Green" StrokeThickness="2" Width="100" Height="50">
   <Ellipse.RenderTransform>
    <RotateTransform Angle="0" CenterX="50" CenterY="25" x:Name="_rotate2"/>
   </Ellipse.RenderTransform>
  </Ellipse>
  
  <Grid.Background>
   <RadialGradientBrush Center="0.5, 0.5">
    <GradientStop Color="Gray" Offset="0"/>
    <GradientStop Color="Black" Offset="1"/>
   </RadialGradientBrush>
  </Grid.Background>
 </Grid>
</Window>


Windowの下にを置きます。
ちなみにのような書き方になっていないのは添付プロパティだからです。
んでそこにAccessingとIdleの2つの状態を作っています。
AccessingのVisualStateに前出のStoryboardをごっそり持って行きます。
これでAccessingになったらStoryboardが動き出すようになります。

あとはコードビハインドで状態を変化させてやるだけです。


  private void CheckBox_Checked(object sender, RoutedEventArgs e) {
   VisualStateManager.GoToElementState(this, "Accessing", false);
  }

  private void CheckBox_Unchecked(object sender, RoutedEventArgs e) {
   VisualStateManager.GoToElementState(this, "Idle", false);
  }


VisualStateManagerはWindowが(添付プロパティとして)持っているので
最初の引数はthisです。

さて、GoToElementStateの最初の引数がControl(=View)なので、
ModelとViewModelがViewを知らないという前提のMVVMモデルでは、
ModelやViewModelから単純にこの操作ができません。

これに対するよい解法が以下のページで紹介されています。
http://tdanemar.wordpress.com/2009/11/15/using-the-visualstatemanager-with-the-model-view-viewmodel-pattern-in-wpf-or-silverlight/

というわけで、まずはViewModelを作ります。
これは今までの連載で何度も書いている形なのでわかると思います。


namespace VisualStateManagerSample {
 class CommunicationViewModel : INotifyPropertyChanged {
  public CommunicationViewModel() {
  }

  private bool _isAccessing;
  public bool IsAccessing {
   get {
    return _isAccessing;
   }
   set {
    if (_isAccessing != value) {
     _isAccessing = value;
     OnPropertyChanged("IsAccessing");
     OnPropertyChanged("State");
    }
   }
  }

  public string State {
   get {
    if (IsAccessing) {
     return "Accessing";
    } else {
     return "Idle";
    }
   }
  }

  public event PropertyChangedEventHandler PropertyChanged;

  private void OnPropertyChanged(string propertyName) {
   var handler = PropertyChanged;
   if (handler != null) {
    handler(this, new PropertyChangedEventArgs(propertyName));
   }
  }
 }
}

いつものOnPropertyChangedと、

状態をboolで持っていて、状態を表す文字列Stateが連動しているだけです。

MainWindowのコードビハインドはCheckboxのイベントハンドラが無くなって
以下のようにスッカラカンになります。
上記URLからもらってきたStateManagerをほぼそのままおいています。
※これは別ファイルに書くのがめんどくさかっただけでMainWindowにある必要はありません
唯一書き換えているのはGoToState→GoToElementStateに呼び出しをかえました。
MSDNを見る限りVisualStateManagerをControlTemplateに置くかどうかで使い分けるみたいです。


namespace VisualStateManagerSample {
 /// <summary>
 /// MainWindow.xaml の相互作用ロジック
 /// </summary>
 public partial class MainWindow : Window {
  public MainWindow() {
   InitializeComponent();
  }
 }

 public class StateManager : DependencyObject {
  public static string GetVisualStateProperty(DependencyObject obj) {
   return (string)obj.GetValue(VisualStatePropertyProperty);
  }

  public static void SetVisualStateProperty(DependencyObject obj, string value) {
   obj.SetValue(VisualStatePropertyProperty, value);
  }

  public static readonly DependencyProperty VisualStatePropertyProperty =
   DependencyProperty.RegisterAttached(
   "VisualStateProperty",
   typeof(string),
   typeof(StateManager),
   new PropertyMetadata((s, e) => {
    var propertyName = (string)e.NewValue;
    var ctrl = s as Control;
    if (ctrl == null)
     throw new InvalidOperationException("This attached property only supports types derived from Control.");
    System.Windows.VisualStateManager.GoToElementState(ctrl, (string)e.NewValue, true);
   }));
 }

}


XAMLは以下のようになります。


<Window x:Class="VisualStateManagerSample.MainWindow"
  xmlns:local="clr-namespace:VisualStateManagerSample"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="200" Width="300"
  local:StateManager.VisualStateProperty="{Binding Path=State}">
 <Window.DataContext>
  <local:CommunicationViewModel/>
 </Window.DataContext>
 
 <VisualStateManager.VisualStateGroups>
  <VisualStateGroup>
   <VisualState x:Name="Idle">

   </VisualState>
   <VisualState Name="Accessing">
    <Storyboard BeginTime="0:0:0" RepeatBehavior="Forever">
     <DoubleAnimation Storyboard.TargetName="_rotate1" Storyboard.TargetProperty="Angle"
       Duration="0:0:2" From="45" To="405"/>
     <DoubleAnimation Storyboard.TargetName="_rotate2" Storyboard.TargetProperty="Angle"
       Duration="0:0:2" From="360" To="0"/>
    </Storyboard>
   </VisualState>
  </VisualStateGroup>
 </VisualStateManager.VisualStateGroups>

 <Grid>
  <CheckBox Foreground="White" IsChecked="{Binding Path=IsAccessing}">通信中</CheckBox>
  <Ellipse Stroke="Green" StrokeThickness="2" Width="100" Height="50">
   <Ellipse.RenderTransform>
    <RotateTransform Angle="45" CenterX="50" CenterY="25" x:Name="_rotate1"/>
   </Ellipse.RenderTransform>
  </Ellipse>
  <Ellipse Stroke="Green" StrokeThickness="2" Width="100" Height="50">
   <Ellipse.RenderTransform>
    <RotateTransform Angle="0" CenterX="50" CenterY="25" x:Name="_rotate2"/>
   </Ellipse.RenderTransform>
  </Ellipse>
  
  <Grid.Background>
   <RadialGradientBrush Center="0.5, 0.5">
    <GradientStop Color="Gray" Offset="0"/>
    <GradientStop Color="Black" Offset="1"/>
   </RadialGradientBrush>
  </Grid.Background>
 </Grid>
</Window>


DataContextにはViewModelを入れていて、
local:StateManager.VisualStateProperty="{Binding Path=State}"で
ViewModelのStateに応じてVisualStateManagerが動作するようになります。

ViewModelの動作をシミュレートするためにCheckboxを残していますが、
基本的にViewModelが自分の状態を表す文字列を書き換えるだけでよく、
Viewはその文字列に従って状態を表すアニメーションするだけでよい構造になっていることがわかるかと思います。

今回のソースコードはどこにもアップロードしていませんが、
ここに書いている部分で、デフォルトのAppクラスを除いたらすべてです。