Help us understand the problem. What is going on with this article?

AvalonDockをMVVMで使う

More than 5 years have passed since last update.

AvalonDockを使ってみる(非MVVM)
DockingManager.DocumentsSourceを使わない場合を紹介しました。今回は、DockingManagerにビューのコレクションをBindしますがコード量が一挙に増えます。
補助的なコードがたくさん要るのです。

サンプルコード

AvalonDockの構造

ある程度は構造を把握する必要がある。
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を提供している。

https://github.com/ousttrue/WpfSample/tree/master/AvalonDockUtil

ツール(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を用意。

Anchorable関連
    public class StatusToolContent: ToolContent
    {
        public StatusToolContent()
            : base("Status")
        { }
    }

    public class MessageToolContent : ToolContent
    {
        public MessageToolContent()
            : base("Message")
        { }
    }

workspace

初期化時にToolsを作る。

workspace
    public class MainWindowViewModel: AvalonDockUtil.WorkspaceBase
    {
        protected override void InitializeTools()
        {
            Tools.Add(new MessageToolContent());
            Tools.Add(new StatusToolContent());
        }
    }

ss01.png

Anchorableのxボタンで非表示とメニューからの再表示ができる。

レイアウトのSave・Loadと初期位置への配置

作られたふたつのToolsはデフォルトの方法(適当なLayoutAnchorablePane、無ければ勝手に作られる)で配置されていた。これをLayoutRoot下に初期配置を作ってContentIdが一致するところに移動させるコードを作る。ついでにレイアウトのシリアライズ・デシリアライズの呼び出しも追加。

xaml
<!-- 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の記述から生成されるレイアウトをシリアライズで保存している。

WorkspaceBase.LoadLayout
            // backup default layout
            using (var ms = new MemoryStream())
            {
                var serializer = new XmlLayoutSerializer(dockManager);
                serializer.Serialize(ms);
                m_defaultLayout = ms.ToArray();
            }

こいつに対してToolContentを割り当てるのに、デシリアライズ時のコールバックを利用する方法を使うことにした。

WorkspaceBase.LoadLayoutFromBytes
 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>

ss02.png

ドキュメントの中身としてビューモデルを乗せる

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>

ss03.png

ツールのデータテンプレート(後で書く)

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に言及していたような)。

ousttrue
virtualcast
VRシステム(バーチャルキャスト)の開発、運営、企画
https://virtualcast.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした