LoginSignup
2
3

More than 1 year has passed since last update.

.NET6.0/C#10.0でオートシェイプ風図形描画ライブラリを作ろう!(Chapter5)

Posted at

※本記事は下記のエントリから始まる連載記事となります。

.NET5.0/C#9.0でオートシェイプ風図形描画ライブラリを作ろう!(Chapter0)

(この記事から.NETとC#のバージョンを上げて、
.NET6.0/C#10.0でオートシェイプ風図形描画ライブラリを作ろう!にタイトルを変更しました)

WPFアプリをリファクタリングしょう

この連載でサンプルとして作成しているWPFアプリですですが、前回までで処理もだいぶ複雑になり、そろそろ少し整理したくなってきました。
そこで、本連載の本筋とは少し離れますが、今回はこのWPFサンプルアプリをMVVMアーキテクチャで再実装するというテーマで進めたいと思います。

ソースコード

Capter5の内容は下記ブランチにて実装されています。実装の詳細はこちらをご確認ください。
https://github.com/pierre3/CoreShape/tree/blog/chapter5

MVVM

Wikipadiaより

Model-View-ViewModel (MVVM、モデル・ビュー・ビューモデル) はUIを持つソフトウェアに適用されるソフトウェアアーキテクチャの一種である。
MVVMはソフトウェアをModel・View・ViewModelの3要素に分割する。プレゼンテーションとドメインを分離し(V-VM / M)また宣言的Viewを分離し状態とマッピングを別にもつ(V / VM)ことでソフトウェアの保守性・開発生産性を向上させる。
Model-View-ViewModelパターンはModel-View-Controller (MVC) パターンの派生であり、特にPresentation Model パターンを直接の祖先に持つ。元来マイクロソフトのユーザインタフェースサブシステムであるWindows Presentation Foundation (WPF) やSilverlightの世界で生まれた考え方ではあるが、現在はAndroidやウェブブラウザ上でのJavaScriptの世界でもMVVMの利用は広がっている。

ReactiveProperty

MVVMをサポートするライブラリはいろいろありますが、今回はマウス操作がメインのアプリケーションではマウスのイベントを一連のストリームとして処理できるReactivePropertyを採用したいと思います。

ReactivePropertyの概要や利用方法については下記をご確認ください。
https://github.com/runceel/reactiveproperty

Model

まずは、MainWindowのコードビハインド(MainWindow.xaml.cs)のフィールドとして定義されていた図形を管理する変数と、それを操作する処理を別のクラスへ切り出すことから始めます。

図形を管理するフィールド

  • IList<IShape> shapes: 配置した図形を保存しておくリスト
  • IDraggable? activeShape: 現在操作中または選択中の図形
  • IShapePen? shapePen: 新たに図形を配置するためのPenオブジェクト

上記のフィールドと、それを操作する一連の処理をShapeManagerというクラスに押し込みます。
この時、Viewのオブジェクトを直接操作するような処理は含まないように注意します。

そうすると、下記の様にマウス操作の単位で処理がまとまってくると思います。

using CoreShape.Graphics;
using CoreShape.Shapes;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SampleWPF.Models
{
    internal class ShapeManager
    {
        private readonly IList<IShape> shapes = new List<IShape>();
        private IDraggable? activeShape;
        private IShapePen? shapePen;

        //図形の描画処理
        public void Draw(IGraphics g)
        {
            g.ClearCanvas(CoreShape.Color.Ivory);
            foreach (var shape in shapes)
            {
                shape.Draw(g);
            }
            if (activeShape is IShapePen shapePen)
            {
                shapePen.Draw(g);
            }
        }
        
        //マウスドラッグ中の処理
        public void Drag(CoreShape.Point oldPoint, CoreShape.Point currentPoint)
        {
            activeShape?.Drag(oldPoint, currentPoint);
        }

        //マウスカーソルとの当たり判定処理
        public HitResult HitTest(CoreShape.Point currentPoint)
        {
            activeShape = shapePen;
            var hitResult = HitResult.None;
            foreach (var shape in shapes.Reverse())
            {
                hitResult = shape.HitTest(currentPoint);
                if (hitResult is not HitResult.None)
                {
                    activeShape = shape;
                    break;
                }
            }
            return hitResult;
        }

        //ShapePenで図形を配置する処理
        public void Locate(CoreShape.Point location)
        {
            if (activeShape is IShapePen shapePen)
            {
                shapePen.Locate(location);
            }
            foreach (var shape in shapes)
            {
                shape.IsSelected = shape == activeShape;
            }
        }

        //ドラッグ後、マウスボタンを離したときの処理
        public void Drop()
        {
            if (activeShape is null)
            {
                return;
            }
            activeShape.Drop();
            if (activeShape is IShapePen shapePen)
            {
                var shape = shapePen.CreateShape();
                if (shape is null)
                {
                    return;
                }
                shapes.Add(shape);
                activeShape = shape;
            }
        }

        //ShapePenをNULLに設定
        public void SetDefaultPen()
        {
            shapePen = null;
        }

        //ShapePenを矩形描画用に切り替え
        public void SetRectanglePen()
        {
            shapePen = new ShapePen<RectangleShape>(
                new Stroke(CoreShape.Color.Red, 2.0f),
                new Fill(CoreShape.Color.LightSeaGreen));
        }
        
        //ShapePenを楕円配置用に切り替え
        public void SetOvalPen()
        {
            shapePen = new ShapePen<OvalShape>(
                new Stroke(CoreShape.Color.Green, 1.0f),
                new Fill(CoreShape.Color.LightYellow));
        }
    }
}

ShapeManagerの各メソッドを、View(Mainwindow)の各マウスイベント内で実行するようにするだけでも、下記の様にMainWindowのコードビハインドがスッキリしますね。

public partial class MainWindow : Window
{
    private ShapeManager shapeManager= new ShapeManager();

    public MainWindow()
    {
        InitializeComponent();
    }
    //PaintSurfaceイベントハンドラ
    private void SKElement_PaintSurface(object sender, SKPaintSurfaceEventArgs e)
    {
        var g = new SkiaGraphics(e.Surface.Canvas);
        shapeManager.Draw(g);
    }
    //MouseMoveイベントハンドラ
    private void SKElement_MouseMove(object sender, MouseEventArgs e)
    {
        var currentPoint = GetMousePoint(e);
        if (e.LeftButton == MouseButtonState.Pressed)
        {
            shapeManager.Drag(currentPoint);
        }
        else
        {
            Cursor = SwitchCursor(shapeManager.HitTest(currentPoint));
        }
    }
    //MouseDownイベントハンドラ  
    private void SkElement_MouseDown(object sender, MouseButtonEventArgs e)
    {
        if (e.LeftButton != MouseButtonState.Pressed)
        {
            return;
        }
        var currentPoint = GetMousePoint(e);
        shapeManager.Locate(currentPoint);
        skElement.InvalidateVisual();
    }
    //MouseUpイベントハンドラ
    private void SkElement_MouseUp(object sender, MouseButtonEventArgs e)
    {
        if (e.LeftButton != MouseButtonState.Released)
        {
            return;
        }
        shapeManager.Drop();
        skElement.InvalidateVisual();
    }
    //↖ボタンClickイベントハンドラ
    private void DefaultButton_Checked(object sender, RoutedEventArgs e)
    {
        shapeManager.SetDefaultPen();
    }
    //□ボタンClickイベントハンドラ
    private void RectButton_Checked(object sender, RoutedEventArgs e)
    {
        shapeManager.SetRectanglePen();
    }
    //〇ボタンClickイベントハンドラ
    private void OvalButton_Checked(object sender, RoutedEventArgs e)
    {
        shapeManager.SetOvalPen();
    }
}

ViewModel

まず、MainWindowに対応するViewModel MainWindowViewModelクラスを作ります。

EventからCommandへ

MainWindowのイベントハンドラに記載していた処理は、ViewModel側でコマンドとして実装します。
ReactivePropertyでは、ReactiveCommandクラスを使います。

今実装しているイベントは、大きく分けて次の2種類です

  • ツールバーボタンのクリックイベント
  • マウス操作関連のイベント
class MainWindowViewModel
{
    //ツールバーボタンのコマンド
    public ReactiveCommand DefaultPenCommand { get; }
    public ReactiveCommand RectanglePenCommand { get; }
    public ReactiveCommand OvalPenCommand { get; }

    //マウスボタン関連のコマンド
    public ReactiveCommand<MouseEvent> MouseMoveCommand { get; }
    public ReactiveCommand<MouseEvent> MouseDownCommand { get; }
    public ReactiveCommand<MouseEvent> MouseUpCommand { get; }
    public ReactiveCommand<PaintSurfaceEvent> PaintSurfaceCommand { get; }
}

Viewのイベント引数をConverterでViewModel側のクラスへ変換する

Viewのイベント引数はそのままViewModelで受け取ることも可能ですが、ViewModelは可能な限りView側のクラスに依存させたくありません。

そこで、ViewModelで利用する情報の受け渡し用のクラスを作成して、Viewからわたってくるイベント引数をこのクラスに変換するようにします。

  • マウスイベント情報をViewModel側で受け取るMouseEventクラス
internal record MouseEvent
{
    //マウスの座標(CoreShapeのPoint構造体で取得)
    public Point MousePosition { get; init; }
    //マウスの左ボタンが押された状態か
    public bool IsLButtonPressed { get; init; }
    //マウスの左ボタンが押されていない状態か
    public bool IsLButtonReleased { get; init; }
    //Viewの描画面を更新するためのデリゲート
    public Action? InvalidateVisual { get; init; }
}

ReactiveCommandを利用する場合、ReactiveConverterを利用することで、イベント引数を別のクラスに変換することが可能となります。
OnConvertメソッドをオーバーライドし、IObserbableのSelectでMouseEventArgsからMouseEventに変換します。

  • Viewのイベント引数MouseEventArgsMouseEventに変換するコンバーター
internal class MouseEventReactiveConverter : ReactiveConverter<MouseEventArgs, MouseEvent>
{
    protected override IObservable<MouseEvent> OnConvert(IObservable<MouseEventArgs> source)
    {
        return source.Select(e => new MouseEvent
        {
            MousePosition = e.ConvertMousePoint(),
            IsLButtonPressed = e.LeftButton == MouseButtonState.Pressed,
            IsLButtonReleased = e.LeftButton == MouseButtonState.Released,
            InvalidateVisual = () => (AssociateObject as FrameworkElement)?.InvalidateVisual()
        });
    }
}

なお、マウス座標の変換には下記の拡張メソッドをにて別途実装しています。
ここでは、モニタのDPIを考慮した座標の変換と、CoreShapeライブラリで定義されるPoint構造体に変換する処理が記載されています。

internal static class MouseEventArgsExtensions
{
    public static CoreShape.Point ConvertMousePoint(this MouseEventArgs e)
    {
        var p = e.GetPosition(e.Source as IInputElement);
        var dpi = VisualTreeHelper.GetDpi(e.Device.ActiveSource.RootVisual);
        return new CoreShape.Point((float)(p.X * dpi.DpiScaleX), (float)(p.Y * dpi.DpiScaleY));
    }
}
InvalidateVisualプロパティについて

InvalidateVisualプロパティには、イベント呼び出し元のコントロールSkElementInvalidateVisualメソッドを実行するデリゲートを登録しておきます。
InvalidateVisualを実行するとSkElementの再描画を促すことができます。そして再描画の際に、PaintSurfaceイベントが呼び出されます。

ReactiveCommandでマウスイベントを処理する

まずは、コンストラクタ内でReactiveCommandを初期化します。
マウス操作関連のイベントは下記の3つです。

public MainWindowViewModel()
{
    MouseMoveCommand = new ReactiveCommand<MouseEvent>();
    MouseDownCommand = new ReactiveCommand<MouseEvent>();
    MouseUpCommand = new ReactiveCommand<MouseEvent>();
}

ReactiveCommandは、発生したイベントを連続するデータとして扱うことが可能となり、フィルタや加工により
モデル側で扱う処理単位に分割して記述することができます。

(ボタンを押していない状態での)マウス移動

  • マウスカーソルと図形との当たり判定処理
//MouseModeイベントで左ボタンが押されていない場合のみをフィルタ
private void SubscribeMouseMoveCommand()
{
    MouseMoveCommand
        .Where(args => !args.IsLButtonPressed)
        .Subscribe(args => HitResult.Value = ShapeManager.HitTest(args.MousePosition))
        .AddTo(disposable);
}

マウスの左ボタンが押下された

  • 新しい図形の配置処理を実行
//MouseDownイベントで左ボタンが押されている場合のみをフィルタ
private void SubscribeMouseDownCommand()
{
    MouseDownCommand
        .Where(args => args.IsLButtonPressed)
        .Subscribe(args =>
        {
            ShapeManager.Locate(args.MousePosition);
            args.InvalidateVisual?.Invoke();
        })
        .AddTo(disposable);
}

マウスドラッグ(左ボタン押下中の移動)

  • アクティブな図形のドラッグ処理を実行
//MouseMoveイベントの前後の値をPairwiseメソッドでまとめ
//左ボタンが押されている場合のみをフィルタ
//(前後のマウス座標の値からマウスの移動量が計算できる)
private void SubscribeMouseDragCommand()
{
    MouseMoveCommand
        .Pairwise()
        .Where(args => args.NewItem.IsLButtonPressed)
        .Subscribe(args =>
        {
            ShapeManager.Drag(args.OldItem.MousePosition, args.NewItem.MousePosition);
            args.NewItem.InvalidateVisual?.Invoke();
        }).AddTo(disposable);
}

マウス左ボタンを離した

  • ドラッグ後処理や、新しい図形の追加処理を実行
private void SubscribeMouseUpCommand()
{
    MouseUpCommand
        .Where(args => args.IsLButtonReleased)
        .Subscribe(args =>
        {
            ShapeManager.Drop();
            args.InvalidateVisual?.Invoke();
        })
        .AddTo(disposable);
}

ツールバーボタンのコマンド

各種ツールバーボタンをクリックした際の処理をSubscribeに記述します。
それぞれに、ShapeManagerのPen切り替え用のメソッドを実行するだけです。

public MainWindowViewModel()
{
    DefaultPenCommand = new ReactiveCommand()
        .WithSubscribe(() => ShapeManager.SetDefaultPen())
        .AddTo(disposable);
    RectanglePenCommand = new ReactiveCommand()
        .WithSubscribe(() => ShapeManager.SetRectanglePen())
        .AddTo(disposable);
    OvalPenCommand = new ReactiveCommand()
        .WithSubscribe(() => ShapeManager.SetOvalPen())
        .AddTo(disposable);
}

WithSubscribeは、ReactiveCommandのインスタンス生成とSubsrcibeを同時に行うために用意されているメソッドで、下記の様に書くのと同じ意味です。

    DefaultPenCommand = new ReactiveCommand();
    DefaultPenCommand
        .Subscribe(() => ShapeManager.SetDefaultPen())
        .AddTo(disposable);

描画面更新イベントのコマンド

マウスイベントと、ツールバーボタンのイベントのほかにもう一つ、描画面更新時に実行されるPaintSurfaceがありました。

試してみたけどダメだった

当初、下記のようなコンバータを用意して、SKPaintSurfaceEventArgsのSurface.CanvasをReactiveCommandに引き渡すような実装を考えていました。

internal class SKPaintSurfaceEventReactiveConverter : ReactiveConverter<SKPaintSurfaceEventArgs, PaintSurfaceEvent>
{
    protected override IObservable<PaintSurfaceEvent> OnConvert(IObservable<SKPaintSurfaceEventArgs> source)
    {
        //SKPaintSurfaceEventArgsのSurface.Canvasを渡して、SkiaGraphicsオブジェクトを生成
        return source.Select(args => new PaintSurfaceEvent(new SkiaGraphics(args.Surface.Canvas)));
    }
}

internal record PaintSurfaceEvent
{
    public SkiaGraphics Graphics { get; }
    public PaintSurfaceEvent(SkiaGraphics graphics)
    {
        Graphics = graphics;
    }
}

どうやら、SKPaintSurfaceEventArgsで渡ってくる Surface.Canvas のオブジェクトは、イベント発生毎に内部データのメモリの確保⇒解放が行われるらしく、Subscribe のタイミングでは解放済みとなり、メモリ参照エラーが発生してしまいました。

public MainWindowViewModel()
{
    PaintSurfaceCommand = new ReactiveCommand<PaintSurfaceEvent>()
        .WithSubscribe(args => ShapeManager.Draw(args.Graphics)) //←ここでGraphicsにアクセスするとエラー
        .AddTo(disposable);
}

仕方なくコードビハインドで書くことに

もっとエレガントな解決方法があるかもしれませんが、今回はこの処理だけViewコードビハインドでViewModelのメソッドを呼び出す処理でお茶を濁します。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void SKElement_PaintSurface(object sender, SKPaintSurfaceEventArgs e)
        => (DataContext as MainWindowViewModel)?.Draw(new SkiaGraphics(e.Surface.Canvas));
}

internal class MainWindowViewModel : INotifyPropertyChanged, IDisposable
{
    //....(省略)...

    public void Draw(CoreShape.Graphics.IGraphics graphics)
    {
        ShapeManager.Draw(graphics);
    }
}

マウスカーソルのバインド

マウスカーソルと図形との当たり判定の結果、マウスカーソルの形状を変更する処理があるのですが、
マウスカーソルの形状は、SkElementのCursorプロパティで変更可能です。

このCursorプロパティにReactivePropertyをバインドするのですが、間にConverterを挟んで下記の変換を行います

・HitTestの結果を表すenum HitResult から System.Windows.Input.Cursorクラスに変換

internal class HitResultToCursorConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var hitResult = (HitResult)(value ?? HitResult.None);
        return hitResult switch
        {
            HitResult.Body => Cursors.SizeAll,
            HitResult.ResizeN => Cursors.SizeNS,
            HitResult.ResizeS => Cursors.SizeNS,
            HitResult.ResizeE => Cursors.SizeWE,
            HitResult.ResizeW => Cursors.SizeWE,
            HitResult.ResizeNW => Cursors.SizeNWSE,
            HitResult.ResizeSE => Cursors.SizeNWSE,
            HitResult.ResizeNE => Cursors.SizeNESW,
            HitResult.ResizeSW => Cursors.SizeNESW,
            _ => Cursors.Arrow
        };
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

ViewModelでは、HitResultのリアクティブプロパティ ReactiveProperty<HitResult> を用意しておきます。

internal class MainWindowViewModel : INotifyPropertyChanged, IDisposable
{
    //....(省略)...

    public ReactiveProperty<HitResult> HitResult { get; } = new();
}

マウス移動イベントで、HitTestの結果をHitTest.Valueに代入
⇒ Converterを介してCursorオブジェクトに変換されてView側に渡される。

//HitTestの結果をHitResult.Valueに設定
private void SubscribeMouseMoveCommand()
{
    MouseMoveCommand
        .Where(args => !args.IsLButtonPressed)
        .Subscribe(args => HitResult.Value = ShapeManager.HitTest(args.MousePosition))
        .AddTo(disposable);
}

View

これで、Model、ViewModel準備は整いました。あとは、XAMLを編集してViewModelで定義したReactivePropertyとReactiveCommandをバインドさせるだけです。

<Window x:Class="SampleWPF.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:skiaSharp="clr-namespace:SkiaSharp.Views.WPF;assembly=SkiaSharp.Views.WPF"
        xmlns:viewmodels="clr-namespace:SampleWPF.ViewModels"
        xmlns:behaviors="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:rp="clr-namespace:Reactive.Bindings.Interactivity;assembly=ReactiveProperty.WPF"
        xmlns:converters="clr-namespace:SampleWPF.Bindings.Converters"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <viewmodels:MainWindowViewModel/>
    </Window.DataContext>
    <Window.Resources>
        <converters:HitResultToCursorConverter x:Key="HitResultToCursorConverter"/>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <ToolBar VerticalAlignment="Top">
            <RadioButton x:Name="DefaultButton" Width="32" Height="32" Content="↖" IsChecked="True" Command="{Binding DefaultPenCommand}" />
            <RadioButton x:Name="RectButton" Width="32" Height="32" Command="{Binding RectanglePenCommand}">
                <Rectangle Width="24" Height="16" Stroke="Black"/>
            </RadioButton>
            <RadioButton x:Name="OvalButton" Width="32" Height="32" Command="{Binding OvalPenCommand}">
                <Ellipse Width="24" Height="16" Stroke="Black"/>
            </RadioButton>
        </ToolBar>
        <skiaSharp:SKElement Grid.Row="1" 
                             PaintSurface="SKElement_PaintSurface" 
                             Cursor="{Binding HitResult.Value,Converter={StaticResource HitResultToCursorConverter}}">
            <behaviors:Interaction.Triggers>
                <behaviors:EventTrigger EventName="MouseMove">
                    <rp:EventToReactiveCommand Command="{Binding MouseMoveCommand}">
                        <converters:MouseEventReactiveConverter />
                    </rp:EventToReactiveCommand>
                </behaviors:EventTrigger>
                <behaviors:EventTrigger EventName="MouseDown">
                    <rp:EventToReactiveCommand Command="{Binding MouseDownCommand}">
                        <converters:MouseEventReactiveConverter />
                    </rp:EventToReactiveCommand>
                </behaviors:EventTrigger>
                <behaviors:EventTrigger EventName="MouseUp">
                    <rp:EventToReactiveCommand Command="{Binding MouseUpCommand}">
                        <converters:MouseEventReactiveConverter />
                    </rp:EventToReactiveCommand>
                </behaviors:EventTrigger>
            </behaviors:Interaction.Triggers>
        </skiaSharp:SKElement>

    </Grid>
</Window>

まとめ

まだ一部不完全な部分(PaintSurfaceイベントの扱いなど)もありますが、コードビハインドのイベントハンドラに処理をべた書きしていたものと比べて、だいぶ整理されて、保守しやすくなったのではないでしょうか。
これで心置きなく、新しい処理を追加していけますね。

次回

次回(予定)は、ドラッグ操作で図形を複数選択しよう
です。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3