これはドリコム Advent Calendar 2020 の19日目です。
前回は ひらしー さんの「WEBサービス監視を設計する際に決めること」です。

初めに

こんにちは、DRIPのクライアントエンジニアのEglissです。
今日は自身の携わっている業務の話…ではなく、GUIツールを作る方なら誰もが「一度は作ってみたい!」と憧れる(要出典 )「ノードエディタ」についてお話していきたいと思います。

ノードエディタ

ノードエディタとは、ノードと呼ばれる要素を線で繋ぐことでデータや処理の流れを表現するGUIを持ったエディタです。
Blender 2.82 シェーダーエディター より
ノードベースエディタ と呼ばれたりもします(本記事では「ノードエディタ」と表記します)。

このノードエディタの何が便利かというと、ノードを作ってノード同士を繋ぐ という単純な操作だけでデータを加工できる所です。
先ほど挙げたBlenderの例では、RGBノードをベースカラーに繋ぐと指定した色がキューブの基になる色として設定される というのが見て取れるかと思います。

今回は、このノードエディタをWPFとMVVMを駆使して自力で組み上げてみよう という趣旨の記事になります。

目標

  • WPFで動くノードエディタの基礎部分を作る
  • MVVMを自分なりに破壊理解してみる

WPFとMVVM

WPFはWindows Presentation Foundation の略で、要はWindowsで動く描画フレームワークです 。
WindowsにはUWP というWPFよりも新しいフレームワークが存在しますが、いくつかの欲しかった機能が標準で提供されておらず、苦戦しそうな雰囲気を感じたのでWPFを使用する事にしました。

MVVMは、 Model、View、ViewModelという3つの役割を持つ型を組み合わせてアプリケーションを作るための設計手法です。
ViewとViewModelがバインディング(データが変更されたら相手に通知する手法)を駆使してやりとりすることが大きな特徴として挙げられます。
MVCとかMVP の親戚と言えば伝わる人も居るのではないでしょうか。

環境

  • Windows 10
  • .NET Framework 4.7.x (依存ライブラリが.NET Core 非対応のため)
  • WPF系ライブラリ
    • ReactiveProperty
    • MathConverter(XAML内での計算の簡略化 )
    • Microsoft.Xaml.Behaviors.Wpf(標準のTriggerが辛いので導入)

雑設計

  • エディタは複数のグループを管理できる
  • グループはノード・ノード同士の接続・その他のオブジェクト の3種類の要素を保持する
  • ノードは自身を構成するための要素を複数持つ
  • 要素は他のノードの要素と接続するためのポートを持つ

ざっくりとこんな感じでの構想で進めていきました。
完成時にどれだけ原型が残っているか楽しみです。

MVVMと設計の方針

前述した通り、WPFではModel View ViewModelという3つの役割を持つ型を組み合わせてGUIを作る「MVVMパターン」を使うことが一般的です。
が、私自身このMVVMにあまり詳しくなく、 設計もどちらかというと苦手な人間なので、ざっくりとしたルールを決めて後は自由!という方針で進めることにしました。

  • 保存/入力時のデータ形式は別に用意する(Modelをシリアライズ対象にしない)
    • Model各要素はReactiveProperty等の通知可能な型でラップされる可能性が高く、シリアライズに向かないと感じたため
  • ViewModel -> Modelへのアクセスは原則インターフェースを挟む
    • 戻り値無しの関数と読み取り専用な物だけ公開
    • ViewModelのコンストラクタのみModelの実際の型にアクセスしてバインディングを行う
  • コードビハインドの利用(Viewの.csへの記述)は制限しない
  • ModelはModel同士の比較や検索を行うためにGuidを保持する
  • ノードに関わるViewは、対応するViewModelの型を受け取れる依存プロパティを公開する(全てViewModelにまとめてしまう)

実装

お待たせしました。ここからが実装になります。
今回は実装内容を全てではなく、特筆すべき箇所をいくつか選んで紹介します。

背景のグリッド

まずノードを配置するための背景部分を作成します。 背景のグリッドは、ユーザーの操作によって上下左右に移動します。
そのため、背景となるUIの位置を動かさずに背景をスクロールさせるか、背景となるUIを各方向にループ配置する必要があります。
今回はグリッドのBackground.Brushを調整することで背景のみスクロールさせる方式を実現しました。
Viewportは画像1枚分のサイズを指定し、TileModeに”Tile”を指定することで、自動でループしてくれるようになります。

<Canvas.Background>
  <ImageBrush x:Name="gridBrush" AlignmentX="Center" AlignmentY="Center" Stretch="None" TileMode="Tile" Viewport="0,0,100,100" ViewportUnits="Absolute">
  </ImageBrush>
</Canvas.Background>

移動量はGridのMouseMoveイベントを使ってViewModelに通知します。
また、同時に得られる引数からマウス中ボタンが押されている時のみ移動するように制限しています。 グリッドの座標は、TransformGroup.TranslateTransformを使って操作しました。
注意点として、何も調整していないとグリッドの左上が原点となるため、中央を原点としたい場合はグリッドの横幅の半分を引いた座標を渡す必要がありました。

public NodeGrid()
{
    this.Loaded += (_, __) =>
    {
        this.NodeEditor.position
            // positionの変更があったら描画用オフセットを再算出
            .Subscribe(position => this.RenderingOffset = this.ActualHalfSize - position)
            .AddTo(this._disposables);
    }
}


<ImageBrush.Transform>
      <TransformGroup x:Name="transform">
        <!-- 描画用オフセットを背景に反映 -->
        <TranslateTransform X="{Binding ElementName=nodeGrid, Path=RenderingOffset.X}" Y="{Binding ElementName=nodeGrid, Path=RenderingOffset.Y}" />
      </TransformGroup>
</ImageBrush.Transform>

座標軸と位置表示を加えたグリッド

ノードをグリッドの上に追加する

ノードの配置はCanvasを用いて行います。
より正確には、Canvasの添付プロパティである、Canvas.Top,Canvas.Leftに各ノードの座標を入れることで実現できます。

これをXAML上で表現するには、ItemsControlを使って実現します。

<ItemsControl ItemsSource="{Binding ElementName=nodeGrid, Path=NodeEditor.activeGruop.Value.nodes}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <!-- 指定が無いと描画されない -->
            <Canvas Background="White" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <!--  ItemSourceとして渡って来る要素がそのままBindingの対象になる  -->
            <node:Node NodeViewModel="{Binding}"/>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
    <!--  要素の添付プロパティ  -->
    <ItemsControl.ItemContainerStyle>
        <Style>
            <Setter Property="Canvas.Left">
                <Setter.Value>
                    <MultiBinding
                                 Converter="{StaticResource math}"
                                 ConverterParameter="(x - y)">
                        <Binding Path="position.Value.X" />
                        <Binding
                                ElementName="nodeGrid"
                                Path="NodeEditor.position.Value.X" />
                    </MultiBinding>
                </Setter.Value>
            </Setter>
            <Setter Property="Canvas.Top">
                <Setter.Value>
                    <!-- ノードの座標 + エディタ自体の座標 -->
                    <MultiBinding
                                 Converter="{StaticResource math}"
                                 ConverterParameter="(x - y)">
                        <Binding Path="position.Value.Y" />
                        <Binding
                                ElementName="nodeGrid"
                                Path="NodeEditor.position.Value.Y" />
                    </MultiBinding>
                </Setter.Value>
            </Setter>
            <!-- 奥行 選択されたら前面に出す 等を行う場合に使う -->
            <Setter Property="Panel.ZIndex">
                <Setter.Value>
                    <Binding Path="zIndex.Value" />
                </Setter.Value>
            </Setter>
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

後はItemsSourceとしてバインディングしているコンテナにViewModelを追加すれば<node:Node>のViewが生成されます。

ノードを選択する

ノードエディタでは、ノードを選んで削除したりコピーしたり等を行う場合があります。また、複数のノードが同時に選択されることも考えられます。
この「ノードを選んでいる」という状態を分かりやすくするための実装を行います。今回はアウトラインを出すようにしました。
が、ただアウトラインを出すだけだと面白みに欠けるので、フェードイン/フェードアウトさせてみます。

WPFでは時間を掛けて値を変える処理を「アニメーション」と呼び、この処理は「ストーリーボード」によって管理され、「トリガーアクション」によって起動されます。
順番に流れを見ていきましょう。

<UserControl
    xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
    <!-- -->
>
<!-- -->
<i:Interaction.Triggers>
    <!-- Bindingの式の結果がValueになったら実行されるトリガー -->
    <i:DataTrigger
        Binding="{Binding ElementName=node, Path=NodeViewModel.isSelecting.Value}"
        Value="True">
        <i:DataTrigger.Actions>
            <!-- outlineEnableAnimationの実行 -->
            <i:ControlStoryboardAction
                ControlStoryboardOption="Play"
                Storyboard="{StaticResource outlineEnableAnimation}" />
                <!-- 最前面表示要求を送る -->
                <i:InvokeCommandAction Command="{Binding ElementName=node, Path=NodeViewModel.bringToFront}" />
            </i:DataTrigger.Actions>
        </i:DataTrigger>
        <i:DataTrigger
            Binding="{Binding ElementName=node, Path=NodeViewModel.isSelecting.Value}"
            Value="False">
            <i:DataTrigger.Actions>
                <!-- outlineDisableAnimationの実行 -->
                <i:ControlStoryboardAction
                    ControlStoryboardOption="Play"
                    Storyboard="{StaticResource outlineDisableAnimation}" />
            </i:DataTrigger.Actions>
        </i:DataTrigger>
</i:Interaction.Triggers>
</UserControl>

まず、ストーリーボードを起動するためのトリガーを定義します。
今回のトリガーは、i:DataTrigger つまり、値が特定の状態になったら という条件です。
今回はノードが選択中かを監視し、選択時と非選択時にそれぞれストーリーボードを起動するようにしています。

<UserControl.Resources>
    <Storyboard x:Key="outlineEnableAnimation">
        <DoubleAnimation
            Storyboard.TargetName="nodeOutline"
            Storyboard.TargetProperty="Opacity"
            To="1"
            Duration="0:0:0.1" />
    </Storyboard>
    <Storyboard x:Key="outlineDisableAnimation">
        <DoubleAnimation
            Storyboard.TargetName="nodeOutline"
            Storyboard.TargetProperty="Opacity"
            To="0"
            Duration="0:0:0.1" />
    </Storyboard>
</UserControl.Resources>

ストーリーボードの持つアニメーションは、誰の何をどのように変化させるか を指定します。
今回の例は、x:NameがnodeOutlineの要素の透明度を0.1秒かけて変化させるアニメーションになります。
ここで指定したx:Keyが前述したトリガーから起動されるストーリーボードの名前になります。

フェードするアウトラインの図

ノードを線で繋ぐ

前提として ノード同士の接続は、ノードとは別にポートを選択できるようにする必要があります。
ポートの選択はノードと違って1個だけ選択可能で、2個目の要素を選択すると、そのポートへ接続出来るか試し、可能であれば線を生成する といった流れになります。

また、ノードと違ってポートは非常に小さく、単純な強調では選択中かどうかを判断できないため、選択地点とマウスカーソルの位置を点線で繋ぐようにしてみました。

<view:ConnectionPreview MouseDown="ConnectionPreview_MouseDown">
    <view:ConnectionPreview.Visibility>
        <Binding
            Converter="{StaticResource bool2Visible}"
            ElementName="nodeGrid"
            Path="NodeEditor.isPortSelecting.Value" />
    </view:ConnectionPreview.Visibility>
    <view:ConnectionPreview.StartPoint>
        <MultiBinding Converter="{StaticResource pointAdd}">
            <Binding
                ElementName="nodeGrid"
                Path="NodeEditor.activePort.Value.RenderingPosition.Value" />
        </MultiBinding>
    </view:ConnectionPreview.StartPoint>
    <view:ConnectionPreview.EndPoint>
        <Binding
            Converter="{StaticResource vector2point}"
            ElementName="nodeGrid"
            Path="NodeEditor.mousePosition.Value" />
    </view:ConnectionPreview.EndPoint>
</view:ConnectionPreview>

ConnectionPreviewはStartとEndを受け取るPathのラッパーです。
後は同じ要領でポート同士に関連付く実際の接続を表現するオブジェクトを作成すればOKです。

<!-- 基本的にノードの時と同じ 渡す値が違うだけ -->
<ItemsControl
    x:Name="connectionContainer"
        ItemsSource="{Binding ElementName=nodeGrid, Path=NodeEditor.activeGruop.Value.connections}">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Canvas Background="White" />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <nodeconnection:NodeConnection>
                    <nodeconnection:NodeConnection.StartPoint>
                        <Binding
                            Converter="{StaticResource vector2point}"
                            Path="outPort.RenderingPosition.Value" />
                    </nodeconnection:NodeConnection.StartPoint>
                <nodeconnection:NodeConnection.EndPoint>
                    <Binding
                        Converter="{StaticResource vector2point}"
                        Path="inPort.RenderingPosition.Value" />
                </nodeconnection:NodeConnection.EndPoint>
            </nodeconnection:NodeConnection>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

線で繋ぐ動作の一通りのテスト

出来上がったもの

ノードの種類を追加し、ノード内の表現を調整して最終的にこのようになりました。

躓いたこと

XAMLのプレビュー画面がエラーになる

画像を扱うViewを作成し、別のViewで利用しようとすると、画像のパス解決に失敗したエラーが表示されることがあります。
これは、XAMLのプレビューがソリューション(.slnファイル)の位置を起点に画像を探してしまうため発生します。 今回は、プレビュー時用のディレクトリ解決を行う関数を用意して、コードビハインドで画像のロードを行うことで回避しました。

var url = XamlPreview.Resolve("Resources/Images/background-grid.png");
var imageUri = new Uri(url, UriKind.Relative);
this.gridBrush.ImageSource = new BitmapImage(imageUri);

ただし、この手法では コンポーネント自身をプレビューする際に画像が表示されません。せめてもの抵抗として、XAML側で画像表示を行うコードをコメントアウトしておいて結果が見たいときに手動で元に戻すようにしました。

ContextMenuの持つMenuにBindingが出来ない


<Grid x:Name="wrapper">
  <Grid.ContextMenu>
    <ContextMenu x:Name="gridContext">
      <MenuItem 
        Header="Reset Position"
        Command="{Binding ElementName=UserControlのx:Name, Path=NodeEditor.resetViewPosition}"
      />
      <!-- 略 -->
    </ContextMenu>
  </Grid.ContextMenu>
</Grid>

上記のようなバインディングを行うと、UserControlが見つけられずCommandのバインディングに失敗しました。
気になってVisualTreeを確認した所、ContextMenuは
以下の画像のように Popupという特殊な扱いになっており、XAML上では親として存在するはずの要素が存在しないため解決を行うことが出来ません。
今回は、コードビハインド上で以下のコードを呼び出すことでバインディング先を検出できるようにしました。

NameScope.SetNameScope(/* ContextMenuのx:Name */ this.gridContext, NameScope.GetNameScope(/* 仮想的な親として扱いたい要素 */this))

ノードエディタを作ってみて

WPF

ノードエディタの設計が難しい という所も当然ありましたが、それ以上にWPF 特にXAMLについての情報を調べるのが難しいと感じました。 バインディングに関しては構文がXAML特有のものでありながら定期的に記述するため暗記が不可欠で、<Setter>句などの補完が効きづらい場所では手が止まりっぱなしでした。
が、補完が効くXAMLそのものは快適で、値変換のための冗長な記述や、XAML要素内にコメントが書けないといった問題を解決出来ればそんなに悪くないのではないかと感じました。

また、たくさんの記述が必要ではあるものの、見た目の調整はそれなりに自由度が高いので、慣れれば凝ったものが作れそうだと感じました。

MVVM

とにかく難しい
各型の役割が分かりづらい、バインディング忘れてデータが来ない、ViewModelをViewを反映する方法等、様々なポイントで詰まりました。
成功したこととして、最初に決めたルールのうちのViewModel->Modelの操作を戻り値無し関数のみにする という制約のお陰でこの付近のコードは分かりやすく、値を出来るだけバインディング経由で反映させることを実現出来ました。

次はPrism等のフレームワークに乗っかって本物のMVVMを実現したいです。

ノードエディタ

自分の作ったツールを使ってみると、「ノードを繋ぐときに補正が無いと判定が狭くて繋げにくい」とか「ノードの折りたたみが無いと縦に延びていく」といった問題が露わになり、 世に出ているノードエディタが如何に扱いやすいかを知る良い機会になりました。

今回実装しなかった「1つ前に戻る」といった操作を筆頭に、ノードの中に別のグループを展開したり等、たくさん欲しい機能が出来ました。機会があったらまた実装してみようと思います。

設計

思った程には壊れることはありませんでした。
ViewModelが直接Viewを触るようなコードもなく、比較的平和に(?)実装することが出来ました。

あえて問題を挙げるとすれば、実装中はMVVMパターンに対して「本当にこの実装で良いのか…?」と疑心暗鬼に陥る時間が大半を占めていたので、もう少しMVVMに対しての理解を深めておけば良かったという後悔が大きいです。
実装中のメモ
親子関係にあるViewModelの初期化について悩んでいた時の図

最後に

ここまで読んでいただきありがとうございました。
ノードエディタに限らず、GUIを持つツールを作るのは難しいですが、GUIツール開発ならではの楽しさもたくさんあります。
そんな楽しさがこの記事を読んでいただいた皆様に少しでも伝わればと思います。

次回は まさ さんの「スクラムってる組織~ソフトウェア開発以外でスクラムいけるの?~」です。
ドリコムでは一緒に働くメンバーを募集しています!募集一覧はコチラを御覧ください!