WPF で MVVM なサークル描画プログラム

MSDN の記事「ビットマップとピクセル ビット」でクリックした場所に円を描くサンプルを見かけて、「これを MVVM なプログラムで実現しようとするとどうなるかな」という興味から作ってみました 😀 作るついでに、定型の円を描くのではなくて、クリックした場所を中心に、ドラッグした位置の高さと幅を持つ楕円を描くようにしてみました 😉

実行した画面は次のようになります。

実行画面
実行画面

画像には出ていませんが、ドラッグ中には枠線だけの楕円が表示されて、どんな感じの楕円が描画されるか把握できるようにしてあります。

MVVM なプログラムということで、マウス操作時の座標をビューモデルに知らせることが必要になります。この部分はマウス操作イベントをトリガーとして、マウス座標を引数とするコマンドを起動するアクションとして実装しています。

それでは、プログラムです。
「WPF アプリケーション」なプロジェクトを作成します(プロジェクト名は DrawCircle としています)。
トリガとアクションを利用するので System.Windows.Interactivity と Microsoft.Expression.Interactions への参照を追加します(Visual Studio 2013 Update 1 では Blend SDK を入れなくても入っているはず)。

  • C:\Program Files\Microsoft SDKs\Expression\Blend\.NETFramework\v4.5\Libraries\Microsoft.Expression.Interactions.dll
  • C:\Program Files\Microsoft SDKs\Expression\Blend\.NETFramework\v4.5\Libraries\System.Windows.Interactivity.dll

まずは、マウス座標を引数とするコマンド実行アクションから。
プロジェクトに「Behaviors」フォルダを追加し、Behaviors フォルダに「MouseCoordinatesAction」クラスを作成します。

using System.Windows;
using System.Windows.Interactivity;
using System.Windows.Input;

namespace DrawCircle.Behaviors
{
    /// <summary>
    /// マウス座標を引数とするコマンド実行アクション
    /// </summary>
    public class MouseCoordinatesAction : TriggerAction<DependencyObject>
    {
        public static readonly DependencyProperty CommandProperty = DependencyProperty.Register(
            "Command", typeof(ICommand), typeof(MouseCoordinatesAction), new UIPropertyMetadata(null)
            );
        public ICommand Command
        {
            get { return (ICommand)GetValue(CommandProperty); }
            set { SetValue(CommandProperty, value); }
        }

        protected override void Invoke(object parameter)
        {
            var eventArgs = parameter as MouseEventArgs;
            var element = AssociatedObject as IInputElement;
            if (Command == null || eventArgs == null || element == null) return;

            var position = eventArgs.GetPosition(element);
            if (Command.CanExecute(position))
            {
                Command.Execute(position);
            }
        }
    }
}

アクションをキックするイベント トリガは、ビューの XAML で記述します。

次にビューモデルですが、MVVM パターンなので、お決まりのコマンド処理とプロパティチェンジイベントの発火を提供する ViewModelBase クラスは別記事の ViewModelBase で書いたものを使っています(引数を受け取るコマンドが必要になるので、ソースコードをダウンロードしてください)。別プロジェクトにして作成した DLL を参照設定で追加するなり、プロジェクト内にソースを取り入れるなりしてください(次のコードは DLL を参照する前提で書いています)。

プロジェクトに「ViewModels」フォルダを追加し、ViewModels フォルダに「MainViewModel」クラスを作成します。

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;

using MakCraft.ViewModels;

namespace DrawCircle.ViewModels
{
    class MainViewModel : ViewModelBase
    {
        private const int WIDTH = 1200;
        private const int HEIGHT = 800;
        private RenderTargetBitmap _original;
        private List<DrawingVisual> _drawingVisuals = new List<DrawingVisual>();
        private List<DrawingVisual> _workingVisuals = new List<DrawingVisual>();
        private Point _starting;
        private bool _isDraged = false;

        public MainViewModel()
        {
            initImage();
        }

        private RenderTargetBitmap _bitmap;
        // ビューの Image コントロールの Source プロパティへバインドする
        public RenderTargetBitmap Bitmap
        {
            get { return _bitmap; }
            private set
            {
                _bitmap = value;
                base.RaisePropertyChanged(() => Bitmap);
            }
        }

        public void Render()
        {
            // 表示しているイメージを初期状態にリセット
            resetImage();
            // DrawingVisual の追加履歴順にイメージに描画
            _drawingVisuals.ForEach(n => _bitmap.Render(n));
            // 追加しようとして操作中の枠線を描画
            _workingVisuals.ForEach(n => _bitmap.Render(n));
        }

        // イメージのリセット コマンド
        private void resetOperationExecute()
        {
            _drawingVisuals.Clear();
            resetImage();
        }
        private ICommand _resetOperationCommand;
        public ICommand ResetOperationCommand
        {
            get
            {
                if (_resetOperationCommand == null)
                {
                    _resetOperationCommand = new RelayCommand(resetOperationExecute);
                }
                return _resetOperationCommand;
            }
        }

        // マウスの左ボタン押下時のコマンド
        private void imageMousePushExecute(Point position)
        {
            _starting = position;
            _isDraged = true;
        }
        private ICommand _imageMousePushCommand;
        public ICommand ImageMousePushCommand
        {
            get
            {
                if (_imageMousePushCommand == null)
                {
                    _imageMousePushCommand = new RelayCommand<Point>(imageMousePushExecute);
                }
                return _imageMousePushCommand;
            }
        }

        // マウス左ボタンドラッグ時のコマンド
        private void imageMouseMoveExecute(Point position)
        {
            // ドラッグ状態ではないマウス移動の場合はリターン
            if (!_isDraged) return;

            _workingVisuals.Clear();
            var pen = new Pen(new SolidColorBrush(Colors.Black), 2);
            var dv = new DrawingVisual();
            var x = Math.Abs(_starting.X - position.X);
            var y = Math.Abs(_starting.Y - position.Y);
            using (var dc = dv.RenderOpen())
            {
                dc.DrawEllipse(null, pen, _starting, x, y);
            }
            _workingVisuals.Add(dv);
            Render();
        }
        private ICommand _imageMouseMoveCommand;
        public ICommand ImageMouseMoveCommand
        {
            get
            {
                if (_imageMouseMoveCommand == null)
                {
                    _imageMouseMoveCommand = new RelayCommand<Point>(imageMouseMoveExecute);
                }
                return _imageMouseMoveCommand;
            }
        }

        // マウス左ボタンのリリース時のコマンド
        private void imageMouseReleaseExecute(Point position)
        {
            _isDraged = false;

            _workingVisuals.Clear();

            var rand = new Random();
            var brush = new SolidColorBrush(
                Color.FromRgb((byte)rand.Next(256), (byte)rand.Next(256), (byte)rand.Next(256)));
            var dv = new DrawingVisual();
            var x = Math.Abs(_starting.X - position.X);
            var y = Math.Abs(_starting.Y - position.Y);
            using (var dc = dv.RenderOpen())
            {
                dc.DrawEllipse(brush, null, _starting, x, y);
            }
            _drawingVisuals.Add(dv);
            Render();
        }
        private ICommand _imageMouseReleaseCommand;
        public ICommand ImageMouseReleaseCommand
        {
            get
            {
                if (_imageMouseReleaseCommand == null)
                {
                    _imageMouseReleaseCommand = new RelayCommand<Point>(imageMouseReleaseExecute);
                }
                return _imageMouseReleaseCommand;
            }
        }

        private void initImage()
        {
            _original = new RenderTargetBitmap(WIDTH, HEIGHT,
                (Double)DeviceHelper.PixelsPerInch(Orientation.Horizontal),
                (Double)DeviceHelper.PixelsPerInch(Orientation.Vertical),
                PixelFormats.Default);
            resetImage();
        }

        // イメージを初期状態にリセット
        private void resetImage()
        {
            Bitmap = (RenderTargetBitmap)_original.Clone();
        }
    }

    // 以下の DPI 設定の取得は MSDN よくある質問 フォーラム「RenderTargetBitmap の使い方」より抜粋
    // http://social.msdn.microsoft.com/Forums/ja-JP/df0c59a1-f7c0-4591-9285-eeabc252a608/rendertargetbitmap-?forum=wpffaqja
    internal class DeviceHelper
    {
        /// <summary>
        /// システムから画面の DPI 設定を取得します。
        /// </summary>
        /// <param name="orientation"></param>
        /// <returns></returns>
        public static Int32 PixelsPerInch(Orientation orientation)
        {
            Int32 capIndex = (orientation == Orientation.Horizontal) ? 0x58 : 90;
            using (DCSafeHandle handle = UnsafeNativeMethods.CreateDC("DISPLAY"))
            {
                return (handle.IsInvalid ? 0x60 : UnsafeNativeMethods.GetDeviceCaps(handle, capIndex));
            }
        }
    }

    internal sealed class DCSafeHandle : Microsoft.Win32.SafeHandles.SafeHandleZeroOrMinusOneIsInvalid
    {
        private DCSafeHandle() : base(true) { }

        protected override Boolean ReleaseHandle()
        {
            return UnsafeNativeMethods.DeleteDC(base.handle);
        }
    }

    [System.Security.SuppressUnmanagedCodeSecurity]
    internal static class UnsafeNativeMethods
    {
        [DllImport("gdi32.dll", CharSet = CharSet.Auto, ExactSpelling = true)]
        public static extern Boolean DeleteDC(IntPtr hDC);

        [DllImport("gdi32.dll", CharSet = CharSet.Auto, ExactSpelling = true)]
        public static extern Int32 GetDeviceCaps( DCSafeHandle hDC, Int32 nIndex);

        [DllImport("gdi32.dll", EntryPoint = "CreateDC", CharSet = CharSet.Auto)]
        public static extern DCSafeHandle IntCreateDC(String lpszDriver,
            String lpszDeviceName, String lpszOutput, IntPtr devMode);

        public static DCSafeHandle CreateDC(String lpszDriver)
        {
            return UnsafeNativeMethods.IntCreateDC(lpszDriver, null, null, IntPtr.Zero);
        }
    }
}

特に複雑なことはしていないので、見れば分かるかと思います。
描画は RenderTargetBitmap オブジェクトへ行っていて、生成時に作成したものをオリジナルとして保持し、描画にはコピーしたものを使っています。ユーザー操作で確定したビジュアル オブジェクトと操作中(枠線描画中)のビジュアル オブジェクトをそれぞれ List<DrawingVisual> のオブジェクトで保持して、状態が変更(新たなビジュアル オブジェクトの確定やドラッグ操作による枠線位置の変更)になったらオリジナルからコピーしなおしたうえでビジュアル オブジェクトの再描画を行っています。

ImageMousePushCommand, ImageMouseMoveCommand, ImageMouseReleaseCommand は、引数を受け取るコマンドとして、ImageMouseReleaseCommand の場合は new RelayCommand<Point>(imageMouseReleaseExecute) として生成しています(詳しくは「引数を受け取るコマンド」を見てください)。

なお、RenderTargetBitmap オブジェクト生成の際の DPI パラメータには、OS 設定値をセットするようにしてあります(ソース中にコメントしてあるとおり、MSDN よくある質問 フォーラム「RenderTargetBitmap の使い方」より抜粋したものです)。

最後にビューです。

<Window x:Class="DrawCircle.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:b="clr-namespace:DrawCircle.Behaviors"
        xmlns:vm="clr-namespace:DrawCircle.ViewModels"
        Title="MainWindow" SizeToContent="WidthAndHeight"
        WindowStartupLocation="CenterScreen">
    <Window.DataContext>
        <vm:MainViewModel />
    </Window.DataContext>
    
    <StackPanel>
        <TextBlock Text="DrawingCircle" FontSize="18" Margin="8" HorizontalAlignment="Center" />
        
        <Border BorderThickness="1" BorderBrush="Bisque" Margin="4">
            <Image Stretch="None" Margin="4" Source="{Binding Bitmap}">
                <i:Interaction.Triggers>
                    <i:EventTrigger EventName="MouseLeftButtonDown">
                        <b:MouseCoordinatesAction
                            Command="{Binding ImageMousePushCommand}" />
                    </i:EventTrigger>
                    <i:EventTrigger EventName="MouseMove">
                        <b:MouseCoordinatesAction
                            Command="{Binding ImageMouseMoveCommand}" />
                    </i:EventTrigger>
                    <i:EventTrigger EventName="MouseLeftButtonUp">
                        <b:MouseCoordinatesAction
                            Command="{Binding ImageMouseReleaseCommand}" />
                    </i:EventTrigger>
                </i:Interaction.Triggers>
            </Image>
        </Border>
        
        <Button
            Content="リセット" HorizontalAlignment="Right" Padding="6 4" Margin="8" MinWidth="100"
            Command="{Binding ResetOperationCommand}" />
    </StackPanel>
</Window>

Image 要素の子として Interaction.Triggers 要素を置き、さらにその子として EventTrigger 要素を EventName 属性の値 MouseLeftButtonDown, MouseMove, MouseLeftButtonUp の3種類分設置しています。アクションには MouseCoordinatesAction をセットし、Command に ImageMousePushCommand, ImageMouseMoveCommand, ImageMouseReleaseCommand をバインドしています。

これで、ビルドすればプログラムが動きます 😉


WPF で MVVM なサークル描画プログラム」への2件のフィードバック

    1. そうですね。ご指摘のとおり、モデル部分がありません。
      サークル描画を行うアプリケーションを構築する際に、MVVM パターンで実装を行う場合に利用できる描画用の部品というところです。
      あくまで部品の例示が目的なので、モデル部分を構築する必要性を感じなかったというところです。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です