AvalonDockを使ってみる(非MVVM)で
DockingManager.DocumentsSourceを使わない場合を紹介しました。今回は、DockingManagerにビューのコレクションをBindしますがコード量が一挙に増えます。
補助的なコードがたくさん要るのです。
#AvalonDockの構造
- WPFでドッキングウィンドウ(AvalonDock) 使い方 その2 - AvalonDockのアーキテクチャ
- AvalonDock 2.0 getting started guide PART 1
- https://avalondock.codeplex.com/SourceControl/latest#Version2.0/AvalonDock.MVVMTestApp/
ある程度は構造を把握する必要がある。
AvalonDockはLayoutContentをレイアウトするのが目的であり、LayoutContentにはLayoutDocument(編集対象のメインウインドウ)とLayoutAnchorable(情報表示のサブウインドウ)の2種類がある。
各LayoutContentにContentIdを手がかりにしてViewModelが割り当てられてられる。
ViewModelはDockingManagerのDocumentSource(IEnumerable)とAnchorableSource(IEnumerable)から供給される。
コンテントは以下のようにスプリッターを組み合わせて領域を分割した末端にタブパネルにはめ込む方式でレイアウトする。
DockingManager
RootPanel
LayoutPanel(縦) スプリッター
LayoutPanel(横) スプリッター
LayoutAnchorablePane タブパネル
(LayoutAnchorable ContentId)
LayoutDocumentPane 最低ひとつは必要。LayoutDocument専用のタブパネル
[LayoutDocument ContentId]
LayoutAnchorablePane タブパネル
(LayoutAnchorable ContentId)
ということでAvalonDock(MVVM)を使うには以下の要素が必要になる。
- DockingManager
- LayoutItemとViewModel(コンテント)のプロパティをバインドするStyleSelector
- DocumentSourceとAnchorableSourceを提供するViewModelたるWorkspace(本家のサンプルMVVMTestAppに準拠した名前)
- 非表示にしたAnchorableを表示するためのメニュー(無くても動くが必須であろう)
- 初期レイアウト。レイアウトのセーブ・ロード
- 個々のコンテンツの見た目を定義するDataTemplate
という感じでまいります。
補助ライブラリ
AvalonDockを素でMVVMで使うには負担が重いので補助ライブラリを作った。
補助ライブラリでWorkSpace、AnchorableContent、DocumentContentのお決まりのコードとLayoutItemとAnchorableContent
やDocumentContentのバインディングを制御するContentPropertyAttribute, ContentPropertyStyleSelectorを提供している。
ツール(Anchorable)の実装
xaml
最初は、DockingManagerとAvalonDockのAnchorableの表示・非表示切り替え用のメニューのみ。
<Window x:Class="AvalonDockMVVMSample.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:avalonDock="http://schemas.xceed.com/wpf/xaml/avalondock"
xmlns:vm="clr-namespace:UriListViewModel;assembly=UriListViewModel"
xmlns:local="clr-namespace:AvalonDockMVVMSample"
xmlns:util="clr-namespace:AvalonDockUtil;assembly=AvalonDockUtil"
Title="MainWindow" Height="350" Width="525"
AllowDrop="True"
>
<Window.Resources>
<util:ActiveDocumentConverter x:Key="ActiveDocumentConverter"/>
<avalonDock:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
</Window.Resources>
<Window.DataContext>
<local:MainWindowViewModel />
</Window.DataContext>
<DockPanel>
<!-- itemssourceで複数のToolContentのメニュー化 -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="表示" ItemsSource="{Binding Tools}">
<MenuItem.ItemTemplate>
<DataTemplate>
<MenuItem
Header="{Binding Title}"
IsCheckable="True"
IsChecked="{Binding IsVisible, Mode=TwoWay}" />
</DataTemplate>
</MenuItem.ItemTemplate>
</MenuItem>
</Menu>
<avalonDock:DockingManager x:Name="_dockingManager"
DocumentsSource="{Binding Documents}"
ActiveContent="{Binding ActiveDocument, Mode=TwoWay, Converter={StaticResource ActiveDocumentConverter}}"
AnchorablesSource="{Binding Tools}"
>
<avalonDock:DockingManager.LayoutItemContainerStyleSelector>
<!-- style setterを生成するクラス -->
<util:ContentPropertyStyleSelector />
</avalonDock:DockingManager.LayoutItemContainerStyleSelector>
<!-- 初期レイアウト予定地 -->
<avalonDock:LayoutRoot />
</avalonDock:DockingManager>
</DockPanel>
</Window>
Anchorable
とりあえず2種類のAnchorableを用意。
public class StatusToolContent: ToolContent
{
public StatusToolContent()
: base("Status")
{ }
}
public class MessageToolContent : ToolContent
{
public MessageToolContent()
: base("Message")
{ }
}
workspace
初期化時にToolsを作る。
public class MainWindowViewModel: AvalonDockUtil.WorkspaceBase
{
protected override void InitializeTools()
{
Tools.Add(new MessageToolContent());
Tools.Add(new StatusToolContent());
}
}
Anchorableのxボタンで非表示とメニューからの再表示ができる。
レイアウトのSave・Loadと初期位置への配置
作られたふたつのToolsはデフォルトの方法(適当なLayoutAnchorablePane、無ければ勝手に作られる)で配置されていた。これをLayoutRoot下に初期配置を作ってContentIdが一致するところに移動させるコードを作る。ついでにレイアウトのシリアライズ・デシリアライズの呼び出しも追加。
<!-- Windowのイベント -->
<i:EventTrigger EventName="Loaded">
<l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="LoadLayout" MethodParameter="{Binding ElementName=_dockingManager}" />
</i:EventTrigger>
<i:EventTrigger EventName="Unloaded">
<l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="SaveLayout" MethodParameter="{Binding ElementName=_dockingManager}" />
</i:EventTrigger>
<!-- メニューに追加 -->
<MenuItem Header="レイアウト" >
<MenuItem Header="配置を初期化する">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click">
<l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="DefaultLayout" MethodParameter="{Binding ElementName=_dockingManager}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</MenuItem>
</MenuItem>
<!-- LayoutRootへの初期配置設定 -->
<avalonDock:LayoutRoot>
<avalonDock:LayoutPanel Orientation="Vertical">
<avalonDock:LayoutPanel Orientation="Horizontal">
<avalonDock:LayoutDocumentPane />
<avalonDock:LayoutAnchorablePane DockWidth="150">
<avalonDock:LayoutAnchorable ContentId="Status" />
</avalonDock:LayoutAnchorablePane>
</avalonDock:LayoutPanel>
<avalonDock:LayoutAnchorablePane DockHeight="70">
<avalonDock:LayoutAnchorable ContentId="Message" />
</avalonDock:LayoutAnchorablePane>
</avalonDock:LayoutPanel>
</avalonDock:LayoutRoot>
実装の解説
コードが関与しない方法で初期配置をコントロールすることができなかったのでデシリアライズに介入することでToolContentが目的のLayoutAnchorableに割り当てれるようにした。
まず、一番最初にavalonDock:LayoutRootの記述から生成されるレイアウトをシリアライズで保存している。
// backup default layout
using (var ms = new MemoryStream())
{
var serializer = new XmlLayoutSerializer(dockManager);
serializer.Serialize(ms);
m_defaultLayout = ms.ToArray();
}
こいつに対してToolContentを割り当てるのに、デシリアライズ時のコールバックを利用する方法を使うことにした。
var serializer = new XmlLayoutSerializer(dockManager);
// コールバック
serializer.LayoutSerializationCallback += MatchLayoutContent;
/// xmlのノードごとに呼び出される
void MatchLayoutContent(object o, LayoutSerializationCallbackEventArgs e)
{
var contentId = e.Model.ContentId;
if (e.Model is LayoutAnchorable)
{
// Tool Windows
foreach (var tool in Tools)
{
if (tool.ContentId == contentId)
{
// デシリアライズされたLayoutAnchorableとConentIdとが一致したToolContentを割り当てている
e.Content = tool;
return;
}
}
// Unknown
ErrorMessage(new Exception("unknown ContentID: " + contentId));
return;
}
#ドキュメントの追加
<MenuItem Header="ドキュメント">
<MenuItem Header="新規" Command="{Binding NewDocumentCommand}" />
<MenuItem Header="開く" Command="{Binding OpenDocumentCommand}" />
</MenuItem>
#ドキュメントの中身としてビューモデルを乗せる
AvalonDockのドキュメントのビューモデルDocumentContentの上に、表示用DataTemplate用のビューモデルが乗っている2階建てにした。表示用のビューモデルはAvalonDockを関知しない。
##UriDocument
新しくUriListDocumentクラスを作る。
// AvalonDockのDocument用のビューモデル
public class UriListDocument : AvalonDockUtil.DocumentContent
{
// DataTemplate用のビューモデル
UriListViewModel.UriListViewModel m_viewModel;
public UriListViewModel.UriListViewModel ViewModel
{
get{
return m_viewModel;
}
}
// Messengerがバインドされる前にダイアログが出したいのでworkspaceのmessengerを共用することにした
public UriListDocument(InteractionMessenger messenger)
{
m_viewModel = new UriListViewModel.UriListViewModel(messenger);
m_viewModel.PropertyChanged += (o, e) =>
{
if (e.PropertyName == "Path")
{
Title = System.IO.Path.GetFileName(m_viewModel.Path);
}
};
}
}
public class MainWindowViewModel : AvalonDockUtil.WorkspaceBase
{
public override AvalonDockUtil.DocumentContent NewDocument()
{
return new UriListDocument(this.Messenger);
}
RelayCommand m_openDocumentCommand;
public ICommand OpenDocumentCommand
{
get
{
if (m_openDocumentCommand == null)
{
m_openDocumentCommand = new RelayCommand(() =>
{
var document = NewDocument() as UriListDocument;
if (!document.ViewModel.Open())
{
return;
}
Documents.Add(document);
});
}
return m_openDocumentCommand;
}
}
}
DocumentのFilePathをレイアウトのシリアライズ・デシリアライズに追加する
protected override void ModifySerializedXml(System.Xml.XmlDocument doc)
{
var nodes = doc.GetElementsByTagName("LayoutDocument");
for (int i = 0; i < nodes.Count; ++i)
{
var node = nodes[i];
var contentId = node.Attributes["ContentId"].Value;
var document = GetDocumentByContentId(contentId) as UriListDocument;
if (document != null && !String.IsNullOrEmpty(document.ViewModel.Path))
{
// documentのファイルパスを追記する
var attrib = doc.CreateAttribute("FilePath");
attrib.Value = document.ViewModel.Path;
node.Attributes.Append(attrib);
}
}
}
protected override void RestoreDocumentsFromBytes(Byte[] bytes)
{
// 独自にxmlを解析する
using (var stream = new MemoryStream(bytes))
{
var doc = new XmlDocument();
doc.Load(stream);
// ContentIDが"Document"のIDを探す
var nodes = doc.GetElementsByTagName("LayoutDocument");
for (int i = 0; i < nodes.Count; ++i)
{
var node = nodes[i];
var document = GetDocumentByContentId(node.Attributes["ContentId"].Value) as UriListDocument;
if (document != null)
{
var viewModel = document.ViewModel;
foreach (XmlAttribute attrib in node.Attributes)
{
if (attrib.Name == "FilePath")
{
viewModel.Path = attrib.Value;
viewModel.Load();
}
}
}
}
}
}
Document向けにDataTemplateを定義する
ユーザコントロールに分けた。DataContextにUriDocument.ViewModelをセットしてAvalonDockに乗せる前のデータテンプレートを流用。
<DataTemplate DataType="{x:Type local:UriListDocument}">
<local:UriList DataContext="{Binding ViewModel}"/>
</DataTemplate>
#ツールのデータテンプレート(後で書く)
Status
ManagerのDocuments, ActiveDocument, Toolsをデバッグ表示する
Message
Activeなドキュメントを参照して、アクティブなドキュメントに対する操作のボタンを実装する
一応完成。
長い。
サンプルコードは以下のような構成になっている。
AvalonDockMVVMSample(exe)
AvalonDockUtil(WorkspaceBase, DocumentContent, ToolContentなどのベースクラス、ContentPropertyStyleSelectorなどの補助クラスが定義してある)
UriListViewModel(UriDocumentのViewModelとして流用した)
AvalonDockUtilはそれなりに汎用に作ったのでこいつを使えばかなり労力を減らせると思う。
情報源としては、サンプルコードのベースにした本家のソースに付属するMVVMTestAppが一番である。
大きく変えたところは、
- DocumentのContentIdにFilePathではなくGuidを付与。FilePathはレイアウト情報に手作業で埋め込む方式
- PanesStyleSelectorをContentPropertyStyleSelectorに換装
- PanesTemplateSelectorをやめて単なるDataTemlate
http://www.codeproject.com/Articles/483507/AvalonDock-Tutorial-Part-Adding-a-Tool-Windo
という記事もあったが長すぎる(この記事も長いが)ので読んでおりません(終盤パート5?でMVVMに言及していたような)。