Livet を使ってみた (by VB)
国内有数の MVVMer である尾上さんが開発中の Livet を触ってみました。
現在はまだドキュメントが整備されておらずどこから手を付けていいか判らない状態なので、まずはサンプルプロジェクトを触ってみようということに。ちなみに Livet って何という人のために、以下公式から引用しときます。
Livet (リベット) は WPF4 のための MVVM (Model/View/ViewModel) パターン用インフラストラクチャです。.NET Framework 4 Client Profile 以上で動作し、zlib/libpng ライセンスで提供しています。zlib/libpng ライセンスでは、ライブラリとしての利用に留めるのであれば再配布時にも著作権表示などの義務はありません。しかし、ソースコードを改変しての再配布にはその旨の明示が義務付けられます。
サンプルは C# だけなので、またいつもの悪い癖がふつふつと沸いてきました。そう VB 化ですw VB に移植しながら MVVM パターンとそのインフラである Livet について学ぶ。一石二鳥ということで、早速チャレンジしてみました。
まず Model から
Livet の有難いところは、0.97RC から VB にも対応したので VB ユーザーでも利用できることです。VB ユーザーはこと MVVM インフラにおいておきざりにされてる感じが強かっただけに VB 対応は嬉しいですね。「新しいプロジェクト」→ 「Visual Vasic」と選択すると、テンプレート一覧の先頭に「Livet WPF4 MVVM アプリケーション」と表示されています。 VB 版のサンプルを作るにあたり、プロジェクト名は「LivetVBSampleProject」としました。
まず Model から実装することにします。C# のコードを見ながらせこせこ移植します。Livet 独自のクラス、NotificationObject から派生してるのが判ると思います。解説によると「変更通知機能を持つオブジェクトです。主にModelの基底クラスとして利用されます」とのことです。
ちなみに Option Explicit ステートメントはテンプレートでは付きませんが、個人的には付けるようにしてます。また Main というクラス名が何とも引っかかりますが、ここは気にせず実装することにいたしましょうw
' Main.vb Option Explicit On Option Strict On Imports System.Linq Imports System.Collections.ObjectModel Public Class Main Inherits NotificationObject Public Sub New() Members = New ObservableCollection(Of Member)() Members.Add(New Member(Me) With {.Name = "neuecc", .Birthday = New DateTime(1983, 12, 30), .Memo = "LINQ星人です。"}) Members.Add(New Member(Me) With {.Name = "ugaya40", .Birthday = New DateTime(1983, 10, 23), .Memo = "僕です。"}) End Sub Public _Members As ObservableCollection(Of Member) Public Property Members As ObservableCollection(Of Member) Get Return _Members End Get Private Set(value As ObservableCollection(Of Member)) _Members = value End Set End Property End Class
次は Member クラス・・・これも同じく NotificationObject から継承してます。変更通知用プロパティが三つ程でてきますので注目。
' Member.vb Option Explicit On Option Strict On Public Class Member Inherits NotificationObject Public Sub New(parent As Main) Main = parent End Sub Private _Main As Main Public Property Main() As Main Get Return _Main End Get Private Set(ByVal value As Main) _Main = value End Set End Property Private _Name As String Public Property Name() As String Get Return _Name End Get Set(ByVal value As String) If (_Name = value) Then Return _Name = value RaisePropertyChanged("Name") End Set End Property Private _Birthday As DateTime Public Property Birthday() As DateTime Get Return _Birthday End Get Set(ByVal value As DateTime) If (_Birthday = value) Then Return _Birthday = value RaisePropertyChanged("Birthday") End Set End Property Private _Memo As String Public Property Memo() As String Get Return _Memo End Get Set(ByVal value As String) If (_Memo = value) Then Return _Memo = value RaisePropertyChanged("Memo") End Set End Property Public Function IsIncludedInMainCollection() As Boolean Return Main.Members.Contains(Me) End Function Public Sub AddThisToMainCollection() Main.Members.Add(Me) End Sub Public Sub RemoveThisFromMainCollection() Main.Members.Remove(Me) End Sub End Class
モデル Member は NotificationObject の派生クラスです。「Name」「Birthday」「Memo」の各プロパティはバインド用プロパティのため、セッターで RaisePropertyChanged メソッドがコールされています。
Livet ならこのバインド用プロパティも簡単に実装できます。コードスニペット「lprop」を書いて TAB キーを二回押すとバインド用プロパティのスニペットが挿入されます。
プロパティ名を変更すると、フィールドと RaisePropertyChanged に渡すメソッド名も同時に変わるのでかなり便利。
現在のバージョン 0.97RC では既定の型が Object になってますが、VB の場合、プロパティのコードスニペット同様 String 型の方がいいように思えますね。
ViewModel です。
お次は ViewModel です。C# のサンプル見ながらしこしこ移植します。
' MainWindowViewModel.vb Option Explicit On Option Strict On Imports System.Collections.ObjectModel Imports System.ComponentModel Imports Livet Public Class MainWindowViewModel Inherits ViewModel 'コマンド、プロパティの定義にはそれぞれ ' ' ldcom : DelegateCommand(パラメータ無) ' ldcomn : DelegateCommand(パラメータ無・CanExecute無) ' ldcomp : DelegateCommand(型パラメータ有) ' ldcompn : DelegateCommand(型パラメータ有・CanExecute無) ' lprop : 変更通知プロパティ ' 'を使用してください。 'ViewModelからViewを操作したい場合は、 'Messengerプロパティからメッセージ(各種InteractionMessage)を発信してください。 'UIDispatcherを操作する場合は、DispatcherHelperのメソッドを操作してください。 'UIDispatcher自体はApplication.xaml.vbでインスタンスを確保してあります。 Private _model As Main Public Sub New() _model = New Main() ' 変更通知をDispatcher経由で行うコレクションを、Modelのコレクションから生成しています。 ' 作成されたコレクションは、ソースのコレクションと同期されます。 ' 弱イベントを使用してリークの心配のない方法で同期されています。 Members = ViewModelHelper.CreateReadOnlyNotificationDispatcherCollection( _model.Members, Function(m) New MemberViewModel(m, Me), DispatcherHelper.UIDispatcher) End Sub Private newPropertyValue As ReadOnlyNotificationDispatcherCollection(Of MemberViewModel) Public Property Members() As ReadOnlyNotificationDispatcherCollection(Of MemberViewModel) Get Return newPropertyValue End Get Private Set(ByVal value As ReadOnlyNotificationDispatcherCollection(Of MemberViewModel)) newPropertyValue = value End Set End Property #Region "EditNewCommand" Private _EditNewCommand As DelegateCommand Public ReadOnly Property EditNewCommand() As DelegateCommand Get If _EditNewCommand Is Nothing Then _EditNewCommand = New DelegateCommand(AddressOf EditNew) End If Return _EditNewCommand End Get End Property Private Sub EditNew() ' Viewに画面遷移用メッセージを送信しています。 ' Viewは対応するメッセージキーを持つInteractionTransitionMessageTriggerでこのメッセージを受信します。 Messenger.Raise(New TransitionMessage(New MemberViewModel(New Member(_model), Me), "Transition")) End Sub #End Region #Region "RemoveCommand" Private _RemoveCommand As DelegateCommand Public ReadOnly Property RemoveCommand() As DelegateCommand Get If _RemoveCommand Is Nothing Then _RemoveCommand = New DelegateCommand(AddressOf Remove, AddressOf CanRemove) End If Return _RemoveCommand End Get End Property Private Function CanRemove() As Boolean Return Members.Any(Function(m) m.Checked) End Function Private Sub Remove() Members.Where(Function(m) m.Checked).ToList().ForEach(Sub(m) m.RemoveCommand.Execute()) End Sub #End Region End Class
お次は MemberViewModel クラスです。IDataErrorInfo.Item プロパティのインターフェイスが C# とは異なり ReadOnly になってるので、RaisePropertyChanged("Error") の呼び出し場所を プロパティの方に移動しています。この辺りは注意が必要ですね。また Item が ReadOnly のため、Me("InputBirthday") = "名前は必須です" と書けません。_errors("InputBirthday") = "名前は必須です" という具合に _errors フィールドを直接呼ぶ必要があります。
' MemberViewModel.vb Option Explicit On Option Strict On Imports System.Collections.Generic Imports System.ComponentModel Public Class MemberViewModel Inherits ViewModel Implements IDataErrorInfo Private _model As Member Private _errors As Dictionary(Of String, String) = New Dictionary(Of String, String)() Public Sub New(m As Member, parent As MainWindowViewModel) _model = m MainWindowViewModel = parent ' 入力値やエラー情報の初期化 InitializeInput() ' ModelのPropertyChangedイベントはBindNotifyChangedを使用してハンドルします。 ' こうする事で弱イベントによるModelイベントの購読が行われます。 ' イベントハンドラのライフサイクルはこのViewModelと同じなのでリークしませんし、 ' イベントハンドラがViewModelより先に消えません。 ViewModelHelper.BindNotifyChanged( _model, Me, Sub(sender, e) RaisePropertyChanged(e.PropertyName) If (e.PropertyName = "Birthday") Then RaisePropertyChanged("Age") End If End Sub) End Sub #Region "Modelプロパティのラッパー" Public Property Name() As String Get Return _model.Name End Get Set(ByVal value As String) _model.Name = value End Set End Property Public Property Birthday() As DateTime Get Return _model.Birthday End Get Set(ByVal value As DateTime) _model.Birthday = value End Set End Property Public Property Memo() As String Get Return _model.Memo End Get Set(ByVal value As String) _model.Memo = value End Set End Property #End Region #Region "ViewModelオリジナルのプロパティ" Public ReadOnly Property Age As Integer Get Return CInt((DateTime.Now - Birthday).Days / 365) End Get End Property Private _Checked As Boolean ' チェックされているかを取得、または設定します。 Public Property Checked() As Boolean Get Return _Checked End Get Set(ByVal value As Boolean) If (_Checked = value) Then Return _Checked = value RaisePropertyChanged("Checked") End Set End Property Private _MainWindowViewModel As MainWindowViewModel Public Property MainWindowViewModel() As MainWindowViewModel Get Return _MainWindowViewModel End Get Private Set(ByVal value As MainWindowViewModel) _MainWindowViewModel = value End Set End Property #End Region #Region "入力用プロパティ" Private _inputName As String ' 名前の入力用プロパティです。 Public Property InputName() As String Get Return _inputName End Get Set(ByVal value As String) _inputName = value If (String.IsNullOrEmpty(_inputName.Trim())) Then _errors("InputName") = "名前は必須です" Else _errors("InputName") = Nothing End If RaisePropertyChanged("Error") End Set End Property Private _inputBirthday As String ' 生年月日の入力用プロパティです。 Public Property InputBirthday() As String Get Return _inputBirthday End Get Set(ByVal value As String) _inputBirthday = value Dim inputDateTime As DateTime If (String.IsNullOrEmpty(_inputBirthday)) Then _errors("InputBirthday") = "生年月日は必須です" ElseIf (Not DateTime.TryParse(_inputBirthday, inputDateTime)) Then _errors("InputBirthday") = "年月日として不正な形式です" ElseIf (inputDateTime > DateTime.Now) Then _errors("InputBirthday") = "未来の日付は指定できません" Else _errors("InputBirthday") = Nothing End If RaisePropertyChanged("Error") End Set End Property ' 備考の入力用プロパティです Public Property InputMemo As String #End Region ' 入力値用プロパティを初期化します Private Sub InitializeInput() _inputName = _model.Name If (_model.Birthday <> DateTime.MinValue) Then _inputBirthday = _model.Birthday.ToString("yyyy/MM/dd") End If _InputMemo = _model.Memo _errors.Clear() End Sub #Region "SaveCommand" Private _SaveCommand As DelegateCommand Public ReadOnly Property SaveCommand() As DelegateCommand Get If _SaveCommand Is Nothing Then _SaveCommand = New DelegateCommand(AddressOf Save, AddressOf CanSave) End If Return _SaveCommand End Get End Property Private Function CanSave() As Boolean If (Not String.IsNullOrEmpty(Me.Error)) Then Return False End If If (String.IsNullOrEmpty(InputName) OrElse String.IsNullOrEmpty(InputBirthday)) Then Return False End If Return True End Function Private Sub Save() Name = InputName Birthday = DateTime.Parse(InputBirthday) Memo = InputMemo If (Not _model.IsIncludedInMainCollection) Then _model.AddThisToMainCollection() End If ' Viewに画面遷移用メッセージを送信しています。 ' Viewは対応するメッセージキーを持つInteractionTransitionMessageTriggerでこのメッセージを受信します。 Messenger.Raise(New WindowActionMessage("Close", WindowAction.Close)) End Sub #End Region #Region "CancelCommand" Private _CancelCommand As DelegateCommand Public ReadOnly Property CancelCommand() As DelegateCommand Get If _CancelCommand Is Nothing Then _CancelCommand = New DelegateCommand(AddressOf Cancel) End If Return _CancelCommand End Get End Property Private Sub Cancel() ' 入力情報初期化 InitializeInput() ' Viewに画面遷移用メッセージを送信しています。 ' Viewは対応するメッセージキーを持つInteractionTransitionMessageTriggerでこのメッセージを受信します。 Messenger.Raise(New WindowActionMessage("Close", WindowAction.Close)) End Sub #End Region #Region "RemoveCommand" Private _RemoveCommand As DelegateCommand Public ReadOnly Property RemoveCommand() As DelegateCommand Get If _RemoveCommand Is Nothing Then _RemoveCommand = New DelegateCommand(AddressOf Remove, AddressOf CanRemove) End If Return _RemoveCommand End Get End Property Private Function CanRemove() As Boolean Return Checked End Function Private Sub Remove() _model.RemoveThisFromMainCollection() End Sub #End Region #Region "IDataErrorInfo" Public ReadOnly Property [Error] As String Implements IDataErrorInfo.Error Get Dim errorPropertyList = New List(Of String)() If (Not String.IsNullOrEmpty(Me("InputName"))) Then errorPropertyList.Add("名前") End If If (Not String.IsNullOrEmpty(Me("InputBirthday"))) Then errorPropertyList.Add("生年月日") End If If (errorPropertyList.Count <> 0) Then Return String.Join("・", errorPropertyList.ToArray()) + "が不正です" End If Return Nothing End Get End Property Default Public ReadOnly Property Item(columnName As String) As String Implements IDataErrorInfo.Item Get If (_errors.Keys.Contains(columnName)) Then Return _errors(columnName) Else Return Nothing End If End Get End Property #End Region End Class
コマンドの実装もコードスニペットを使います。「ldcom」と入力して TAB キー二回でコマンドのスニペットが挿入されます。
コマンド名のプレフィックス部分を書き換えると、#Regionディレクティブ・フィールド・ヘルパーメソッドのプレフィックスをすべて置き換えます。これもいい感じです。
最後は View でんがな。
DetailWindow です。基本的に XAML コピってクラスと参照だけ書き換えてるだけですが、実は本日現在(2011/07/20) bitbucket に公開されているサンプルは古いやつだそうで、クラス名が一部変わってしまっててビルドできないため修正しました。XAML コピペすりゃすぐ終わるだろうと思ってただけに一瞬焦りましたw でも bitbucket でコミット履歴の詳細が閲覧できるため、どのクラス名がどう置き換わったかすぐ調べることができたので助かりました。
<Window x:Class="DetailWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions" xmlns:l="http://schemas.livet-mvvm.net/2011/wpf" Title="メンバー詳細" Height="300" Width="300" WindowStartupLocation="CenterScreen"> <Window.Resources> <Style TargetType="{x:Type TextBlock}"> <Setter Property="HorizontalAlignment" Value="Right"/> <Setter Property="VerticalAlignment" Value="Center"/> </Style> <Style TargetType="{x:Type TextBox}"> <Setter Property="HorizontalAlignment" Value="Stretch"/> <Setter Property="VerticalAlignment" Value="Center"/> <Setter Property="Height" Value="30"/> <Setter Property="Margin" Value="5"/> <Style.Triggers> <Trigger Property="Validation.HasError" Value="True"> <Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}"/> </Trigger> </Style.Triggers> </Style> </Window.Resources> <!--Closeというメッセージキーを持つメッセージがViewModelから届いた際に起動するトリガーです--> <i:Interaction.Triggers> <l:InteractionMessageTrigger MessageKey="Close" Messenger="{Binding Messenger}"> <l:WindowInteractionMessageAction/> </l:InteractionMessageTrigger> </i:Interaction.Triggers> <Grid> <Grid.RowDefinitions> <RowDefinition/> <RowDefinition/> <RowDefinition/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="50"/> <ColumnDefinition/> </Grid.ColumnDefinitions> <TextBlock Grid.Row="0" Grid.Column="0">名前:</TextBlock> <TextBlock Grid.Row="1" Grid.Column="0">誕生日:</TextBlock> <TextBlock Grid.Row="2" Grid.Column="0">備考:</TextBlock> <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding InputName,ValidatesOnDataErrors=True,UpdateSourceTrigger=PropertyChanged}"/> <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding InputBirthday,ValidatesOnDataErrors=True,UpdateSourceTrigger=PropertyChanged}"/> <TextBox Grid.Row="2" Grid.Column="1" Text="{Binding InputMemo,ValidatesOnDataErrors=True,UpdateSourceTrigger=PropertyChanged}"/> <TextBlock Grid.Row="3" Grid.ColumnSpan="2" TextWrapping="Wrap" FontSize="16" Foreground="Red" FontWeight="Bold" Text="{Binding Error}" HorizontalAlignment="Center"/> <StackPanel Height="30" Grid.Row="4" Grid.ColumnSpan="2" Margin="5" Orientation="Horizontal" HorizontalAlignment="Right"> <Button Width="70" Command="{Binding SaveCommand}">確定</Button> <Button Width="70" Command="{Binding CancelCommand}">キャンセル</Button> </StackPanel> </Grid> </Window>
とりは MainWindow です。View クラス名・参照および Livert のクラス名を一部最新のものに書き換えてます。
この XAML、Livet の機能をよく表しています。ViewModelを経由せずにメッセージを生成して Windowを閉じてたり、ViewModel を経由せず詳細 View を表示してたりしてます。XAML 中のコメントに注目してください。
<Window x:Class="MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions" xmlns:l="http://schemas.livet-mvvm.net/2011/wpf" xmlns:local="clr-namespace:LivetVBSampleProject" Title="メンバー管理" Height="350" Width="525"> <Window.DataContext> <local:MainWindowViewModel /> </Window.DataContext> <i:Interaction.Triggers> <!--ViewからのTransitionというメッセージキーを持つメッセージを受信します--> <!--TransitionInteractionMessageAction で画面遷移を行っています--> <l:InteractionMessageTrigger MessageKey="Transition" Messenger="{Binding Messenger}"> <l:TransitionInteractionMessageAction WindowType="{x:Type local:DetailWindow}" Mode="Modal"/> </l:InteractionMessageTrigger> </i:Interaction.Triggers> <Grid> <Grid.RowDefinitions> <RowDefinition Height="30"/> <RowDefinition/> </Grid.RowDefinitions> <Grid Grid.Row="0" VerticalAlignment="Center"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="50"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" FontSize="17">メンバー管理</TextBlock> <Button Grid.Column="2" Command="{Binding EditNewCommand}">追加</Button> <!-- DelegateCommand.LatestCanExecuteResultプロパティは最新のCanExecuteの結果をboolで保持します。 コントロールのCommandプロパティを使用しない場合の、コマンドの実行可否状態によるコントロールの制御に使用します。 --> <Button Grid.Column="3" Content="削除" IsEnabled="{Binding RemoveCommand.LatestCanExecuteResult}"> <i:Interaction.Triggers> <i:EventTrigger EventName="Click"> <l:ConfirmationDialogInteractionMessageAction> <!-- DirectInteractionMessageのCallbackCommandプロパティにコマンドを設定する事で Viewで生成したメッセージを元にアクション実行後、コマンドを実行させる事ができます。 その場合、コマンドには引数としてメッセージが渡ります --> <l:DirectInteractionMessage CallbackCommand="{Binding RemoveCommand}"> <l:ConfirmationMessage Button="OKCancel" Caption="確認" Text="本当にチェックの付けられたメンバー情報を削除しますか?" Image="Information"/> </l:DirectInteractionMessage> </l:ConfirmationDialogInteractionMessageAction> </i:EventTrigger> </i:Interaction.Triggers> </Button> <!--ViewModelを経由せずにメッセージを生成し、Windowを閉じています--> <!--Livetでは、ViewModelを経由する必要のない相互作用をこの様にView内で完結させられます--> <Button Grid.Column="4" Content="終了"> <i:Interaction.Triggers> <i:EventTrigger EventName="Click"> <l:WindowInteractionMessageAction> <l:DirectInteractionMessage> <l:WindowActionMessage Action="Close"/> </l:DirectInteractionMessage> </l:WindowInteractionMessageAction> </i:EventTrigger> </i:Interaction.Triggers> </Button> </Grid> <ListView Grid.Row="1" ItemsSource="{Binding Members}"> <ListView.View> <GridView> <GridViewColumn Width="30"> <GridViewColumn.CellTemplate> <DataTemplate> <CheckBox IsChecked="{Binding Checked}"/> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> <GridViewColumn Header="名前" Width="100"> <GridViewColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Name}"/> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> <GridViewColumn Header="年齢" Width="40"> <GridViewColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Age}"/> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> <GridViewColumn Header="生年月日" Width="85"> <GridViewColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Birthday,StringFormat=yyyy/MM/dd}" /> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> <GridViewColumn Header="備考" Width="170"> <GridViewColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Memo}" /> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> <GridViewColumn Width="65"> <GridViewColumn.CellTemplate> <DataTemplate> <Button Width="50" Content="変更"> <i:Interaction.Triggers> <i:EventTrigger EventName="Click"> <!--Viewから詳細ウィンドウを表示させています。ViewModelを経由させていません--> <l:TransitionInteractionMessageAction Mode="Modal" WindowType="{x:Type local:DetailWindow}"> <l:DirectInteractionMessage> <l:TransitionMessage TransitionViewModel="{Binding}"/> </l:DirectInteractionMessage> </l:TransitionInteractionMessageAction> </i:EventTrigger> </i:Interaction.Triggers> </Button> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> </GridView> </ListView.View> </ListView> </Grid> </Window>