前回のLivetとかReactiveCommandを駆使したMVVM風アプリ作成のメモ
の続きから始める。
画面レイアウトをWPFのDockingライブラリであるところのAvalonDockに乗せ換える。
あと、MVVM版は別物になることが判明したので記事を分割することにした。
あとサンプルコードはブランチとか止めた方がいいデス。
違うディレクトリに切っていく方がやりやすい。
#更新メモ
- MVVMは別記事にすることにして修正
#新規プロジェクト
編集を続けるのをやめて新しいプロジェクトを作成することにした。そのためにViewModelをクラスライブラリに分離した。
前回のViewModelへの参照を追加する。
nugetでLivetへの参照を使いする。
AvalonDock導入
WPFでドッキングウィンドウ(AvalonDock) 使い方 その1 - 導入を参考に導入。
前回のxamlをコピペしてListBox回りをAvalonDockで囲う。
xmlns:avalonDock="http://schemas.xceed.com/wpf/xaml/avalondock"
<avalonDock:DockingManager x:Name="_dockingManager">
<avalonDock:LayoutRoot>
<avalonDock:LayoutPanel Orientation="Horizontal">
<avalonDock:LayoutDocumentPane>
<!-- ドキュメント1 -->
<avalonDock:LayoutDocument Title="Document1">
<ListBox ItemsSource="{Binding Items}"
SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
>
</ListBox>
</avalonDock:LayoutDocument>
</avalonDock:LayoutDocumentPane>
<avalonDock:LayoutAnchorablePane DockWidth="150">
<!-- ツールウィンドウ1 -->
<avalonDock:LayoutAnchorable Title="ToolWindow1">
<StackPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
<Button Command="{Binding RemoveSelectedItemCommand}"
IsEnabled="{Binding SelectedItem, Converter={StaticResource NullToFalseConverter}}"
>Remove</Button>
<Button Command="{Binding ClearItemsCommand}"
IsEnabled="{Binding HasAnyItem}"
>Clear</Button>
</StackPanel>
</StackPanel>
</avalonDock:LayoutAnchorable>
</avalonDock:LayoutAnchorablePane>
</avalonDock:LayoutPanel>
</avalonDock:LayoutRoot>
</avalonDock:DockingManager>
avalonDock:LayoutRoot以下がレイアウトの木構造になっている。
#Documentのxボタンを削除してメニューから消したPaneを復活できるようにする
これだけだとx押して消したパーツが行方不明になるのでメニューから表示を復旧できるようにする。
WPFでドッキングウィンドウ(AvalonDock) 使い方 その1 - 導入
にあるとおり。
<MenuItem Header="表示">
<MenuItem
Header="ToolWindow1"
IsCheckable="True"
IsChecked="{Binding IsVisible, ElementName=_toolWindow1}" />
</MenuItem>
<avalonDock:LayoutDocument Title="Document1" CanClose="False" CanFloat="False">
<avalonDock:LayoutAnchorable x:Name="_toolWindow1" Title="ToolWindow1">
ここまでで、
Gridパネル + Splitterの替わり的に使うことができる。
レイアウトの保存・復帰
https://avalondock.codeplex.com/SourceControl/latest#Version2.0/AvalonDock.TestApp/MainWindow.xaml.cs
の通りにやってみた。
ビューモデルを変更する必要が出たので継承。
public class MainWindowViewModel: UriListViewModel.UriListViewModel
{
String LayoutFile
{
get
{
return System.IO.Path.ChangeExtension(
Environment.GetCommandLineArgs()[0]
, ".AvalonDock.config"
);
}
}
public void LoadLayout(DockingManager dockManager)
{
// restore layout
Byte[] bytes;
try
{
bytes = System.IO.File.ReadAllBytes(LayoutFile);
}
catch (FileNotFoundException ex)
{
return;
}
if(!LoadLayout(dockManager, bytes)){
return;
}
}
bool LoadLayout(DockingManager dockManager, Byte[] bytes)
{
var serializer = new XmlLayoutSerializer(dockManager);
try
{
using (var stream = new MemoryStream(bytes))
{
serializer.Deserialize(stream);
}
return true;
}
catch (Exception ex)
{
ErrorDialog(ex);
return false;
}
}
public void SaveLayout(DockingManager dockManager)
{
var serializer = new XmlLayoutSerializer(dockManager);
var doc=new XmlDocument();
using (var stream = new MemoryStream())
{
serializer.Serialize(stream);
stream.Position = 0;
doc.Load(stream);
}
}
}
これらのコマンドをLoaded, Unloadedイベントで実行するようにxamlを変更。
Livet力が上がってLivetCallMethodActionが使われるようになった。
<local:MainWindowViewModel x:Key="ViewModel" />
<i:EventTrigger EventName="Closed">
<!-- Disposeより先に -->
<l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="SaveLayout" MethodParameter="{Binding ElementName=_dockingManager}"/>
<l:DataContextDisposeAction />
</i:EventTrigger>
<i:EventTrigger EventName="Loaded">
<l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="LoadLayout" MethodParameter="{Binding ElementName=_dockingManager}"/>
</i:EventTrigger>
<!-- 各PaneにContentIDを指定 -->
<avalonDock:LayoutDocument Title="Document1" CanClose="False" CanFloat="False" ContentId="Document">
<avalonDock:LayoutAnchorable x:Name="_toolWindow1" Title="ToolWindow1" ContentId="Tool">
保存されたレイアウト
<?xml version="1.0" encoding="utf-8"?>
<LayoutRoot xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<RootPanel Orientation="Vertical">
<LayoutPanel Orientation="Horizontal" DockHeight="1.41901569404236*">
<LayoutDocumentPane>
<LayoutDocument Title="Document1" IsSelected="True" IsLastFocusedDocument="True" ContentId="Document" CanClose="False" CanFloat="False" LastActivationTimeStamp="02/12/2015 12:31:51" />
</LayoutDocumentPane>
</LayoutPanel>
<LayoutAnchorablePaneGroup Orientation="Horizontal" DockWidth="150" DockHeight="76.26" FloatingWidth="150" FloatingHeight="269" FloatingLeft="429" FloatingTop="545">
<LayoutAnchorablePane DockHeight="76.26" FloatingWidth="150" FloatingHeight="269" FloatingLeft="429" FloatingTop="545">
<LayoutAnchorable AutoHideMinWidth="100" AutoHideMinHeight="100" Title="ToolWindow1" IsSelected="True" ContentId="Tool" FloatingLeft="429" FloatingTop="545" FloatingWidth="150" FloatingHeight="269" LastActivationTimeStamp="02/12/2015 12:31:51" />
</LayoutAnchorablePane>
</LayoutAnchorablePaneGroup>
</RootPanel>
<TopSide />
<RightSide />
<LeftSide />
<BottomSide />
<FloatingWindows />
<Hidden />
</LayoutRoot>
ContentIDがLayoutDocumentとLayoutAnchorableの行先を示している。
確かにレイアウトが復旧するようになった。
しかし、レイアウトのDeserializeが走ったところでメニューの表示・非表示のバインディング
<MenuItem
Header="ToolWindow1"
IsCheckable="True"
IsChecked="{Binding IsVisible, ElementName=_toolWindow1}" />
が無効になることが判明。おそらくデシリアライズでLayoutAnchorableが新規に作り直されるため。
無理やり対策した。
デシリアライズでLayoutAnchorableが行方不明になる件の対策
serializer.LayoutSerializationCallback += (o, e) =>
{
if (e.Model.ContentId == "Tool")
{
// シリアライズされて新しくできたLayoutAnchorableを保存しとく
Tool = (LayoutAnchorable)e.Model;
}
};
こいつとメニューをバインディングする。
<MenuItem
Header="ToolWindow1"
IsCheckable="True"
IsChecked="{Binding Tool.IsVisible, Mode=TwoWay}" />
<Separator/>
バッドノウハウ。
レイアウトデータを改造して最後に開いていたファイルの情報を追加してみる
public void LoadLayout(DockingManager dockManager)
{
var bytes = System.IO.File.ReadAllBytes(LayoutFile);
var serializer = new XmlLayoutSerializer(dockManager);
try
{
using (var stream = new MemoryStream(bytes))
{
serializer.Deserialize(stream);
}
}
catch (FileNotFoundException ex)
{
}
catch (Exception ex)
{
ErrorDialog(ex);
}
// 独自にxmlを解析する
using (var stream = new MemoryStream(bytes))
{
var doc = new XmlDocument();
doc.Load(stream);
// ContentIDが"Document"のIDを探す
var documents = doc.SelectNodes("//*[@ContentId=\"Document\"]");
if (documents.Count > 0)
{
var document = documents[0];
foreach (XmlAttribute attrib in document.Attributes)
{
if (attrib.Name == "FilePath")
{
Path = attrib.Value;
Load();
break;
}
}
}
}
}
public void SaveLayout(DockingManager dockManager)
{
var serializer = new XmlLayoutSerializer(dockManager);
var doc=new XmlDocument();
using (var stream = new MemoryStream())
{
serializer.Serialize(stream);
stream.Position = 0;
doc.Load(stream);
}
if(!String.IsNullOrEmpty(Path)){
// ContentIDが"Document"のIDを探す
var documents=doc.SelectNodes("//*[@ContentId=\"Document\"]");
if (documents.Count > 0)
{
// documentのファイルパスを追記する
var document = documents[0];
var attrib=doc.CreateAttribute("FilePath");
attrib.Value=Path;
document.Attributes.Append(attrib);
}
}
using (var stream = new FileStream(LayoutFile, FileMode.Create))
{
doc.Save(stream);
}
}
いい感じになった。
#レイアウトを初期化する
これもバッドノウハウ気味。
// backup default layout
using (var ms = new MemoryStream())
{
var serializer = new XmlLayoutSerializer(dockManager);
serializer.Serialize(ms);
m_defaultLayout = ms.ToArray();
}
レイアウトの読み書きを入れると急に扱い辛くなった感がある。
レイアウトのDeserializeで困った場合は、LayoutSerializationCallbackで頑張れば何とかなるかもしれない。
規模の小さいアプリなら無理にMVVMにしない方がわかりやすいかもしれないが、同時に複数のドキュメントを開く、サブウインドウの種類がたくさんあるとかプラグインで拡張するなどがあるならMVVMにする必要性がある。
次回、AvalonDockMVVMに続く。