C# Windows Formsでお絵描きアプリを作る|MouseイベントとPaint処理をやさしく解説

広告

Windows Formsで「マウスをドラッグすると、円・長方形・直線を描ける」小さなお絵描きアプリを作ります。

この題材では、MouseDownMouseMovePaintなどのイベント、Invalidate()による再描画、Graphicsを使った図形描画をまとめて学べます。後半では、元の学習コードを生かしながら、不要なusingの整理や、PenSolidBrushを安全に破棄する方法も確認します。

C# Windows Forms DrawAppで、Palletの設定からMouseDown・MouseMove・Invalidate・Paintへ進む描画処理の流れ
DrawAppで図形を選択し、マウス操作から再描画へ進む流れ

このアプリでできること

画面は、絵を描くForm1と、図形・色・線の太さを選ぶPalletの2つで構成します。

  1. パレットで「円」「長方形」「直線」のどれかを選ぶ
  2. 必要に応じて色とペンの太さを設定する
  3. Form1上でマウスの左ボタンを押す
  4. そのままドラッグすると、現在位置まで図形が描かれる

データの流れは次のようになります。

MouseDownで始点を保存 → MouseMoveで終点を更新 → Invalidate()で再描画を依頼 → Paintで図形を描画

画面に用意するコントロール

Form1は描画用のフォームです。フォームのイベントを次のメソッドへ接続します。

イベント イベントハンドラー
Paint DrawFigures
MouseDown MousePressed
MouseMove MouseDragged

Palletには、次のコントロールを配置します。

コントロール 役割 コードで使う名前の例
Button 円を選ぶ circleButton
Button 長方形を選ぶ rectButton
Button 直線を選ぶ lineButton
Button 色を選ぶ colorButton
TextBox ペンの太さを入力する penSizeBox

各ボタンのClickイベントも、後述する対応メソッドへ接続してください。

元コードの全体像

Form1.cs

using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;

namespace DrawApp
{
    public partial class Form1 : Form
    {
        private Point startPos;
        private Point endPos;
        private readonly Pallet pallet;

        public Form1()
        {
            InitializeComponent();

            pallet = new Pallet();
            pallet.Show();
        }

        private void DrawFigures(object sender, PaintEventArgs e)
        {
            e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;

            int type = pallet.GetFigureType();
            Color color = pallet.GetColor();
            int penSize = pallet.GetPenSize();

            int width = endPos.X - startPos.X;
            int height = endPos.Y - startPos.Y;

            if (type == 1)
            {
                using (SolidBrush brush = new SolidBrush(color))
                {
                    e.Graphics.FillEllipse(
                        brush, startPos.X, startPos.Y, width, height);
                }
            }
            else if (type == 2)
            {
                using (SolidBrush brush = new SolidBrush(color))
                {
                    e.Graphics.FillRectangle(
                        brush, startPos.X, startPos.Y, width, height);
                }
            }
            else if (type == 3)
            {
                using (Pen pen = new Pen(color, penSize))
                {
                    e.Graphics.DrawLine(pen, startPos, endPos);
                }
            }
        }

        private void MousePressed(object sender, MouseEventArgs e)
        {
            startPos = new Point(e.X, e.Y);
            endPos = startPos;
        }

        private void MouseDragged(object sender, MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left)
            {
                endPos = new Point(e.X, e.Y);
                Invalidate();
            }
        }
    }
}

Pallet.cs

using System;
using System.Drawing;
using System.Windows.Forms;

namespace DrawApp
{
    public partial class Pallet : Form
    {
        private int figureType;

        public Pallet()
        {
            InitializeComponent();
            figureType = 1;
        }

        public int GetFigureType()
        {
            return figureType;
        }

        public int GetPenSize()
        {
            if (int.TryParse(penSizeBox.Text, out int size) && size > 0)
            {
                return size;
            }

            return 1;
        }

        public Color GetColor()
        {
            return colorButton.BackColor;
        }

        private void CircleButtonClicked(object sender, EventArgs e)
        {
            figureType = 1;
        }

        private void RectButtonClicked(object sender, EventArgs e)
        {
            figureType = 2;
        }

        private void LineButtonClicked(object sender, EventArgs e)
        {
            figureType = 3;
        }

        private void ColorButtonClicked(object sender, EventArgs e)
        {
            using (ColorDialog colorDialog = new ColorDialog())
            {
                if (colorDialog.ShowDialog() == DialogResult.OK)
                {
                    colorButton.BackColor = colorDialog.Color;
                }
            }
        }
    }
}

元コードから大きな動作は変えず、使っていない名前空間を削除し、描画に使うオブジェクトをusingで破棄する形に整えています。

Form1とPalletの役割分担

このアプリでは、2つのフォームが別々の仕事を担当します。

クラス 担当する仕事
Form1 マウス座標を受け取り、図形を描く
Pallet 図形の種類、色、ペンの太さを管理する

Form1が色や太さの入力欄を直接操作するのではなく、GetFigureType()GetColor()GetPenSize()を通して現在の設定を受け取ります。

このように役割を分けると、「描く処理」と「設定を選ぶ処理」が混ざりにくくなります。後からパレットの見た目を変更するときも、描画処理への影響を小さくできます。

1. MouseDownで描き始めの座標を保存する

private void MousePressed(object sender, MouseEventArgs e)
{
    startPos = new Point(e.X, e.Y);
    endPos = startPos;
}

MouseDownイベントは、フォーム上でマウスボタンが押されたときに発生します。

e.Xe.Yには、そのときのマウス座標が入っています。ここではその座標をstartPosへ保存し、図形の始点にしています。

endPosにも同じ座標を入れておくと、以前描いた図形の終点が一瞬使われることを防げます。

2. MouseMoveでドラッグ中の終点を更新する

private void MouseDragged(object sender, MouseEventArgs e)
{
    if (e.Button == MouseButtons.Left)
    {
        endPos = new Point(e.X, e.Y);
        Invalidate();
    }
}

MouseMoveイベントは、マウスが動くたびに発生します。ただし、マウスを動かしただけで図形を更新すると、左ボタンを押していないときも描画されてしまいます。

そこでe.Button == MouseButtons.Leftを使い、左ボタンを押しながら動かしているときだけ終点を更新します。

3. InvalidateとPaintは「再描画の依頼」と「実際の描画」

endPos = new Point(e.X, e.Y);
Invalidate();

Invalidate()は、その場で直接図形を描く命令ではありません。「画面を描き直す必要があります」とWindows Formsへ知らせるメソッドです。

再描画が必要になるとPaintイベントが発生し、DrawFiguresが呼ばれます。

この役割分担が大切です。

  • MouseDown:始点を記録する
  • MouseMove:終点を更新し、再描画を依頼する
  • Paint:現在の始点と終点を使って描く

MouseMoveの中で直接描く方法もありますが、ウィンドウが隠れて再表示されたときなどに描画が消えやすくなります。Windows Formsでは、基本的にPaintイベントの中で描画するのが安全です。

4. SmoothingModeで輪郭を滑らかにする

e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;

e.Graphicsは、フォームへ線や図形を描くための道具です。

SmoothingMode.AntiAliasを設定すると、曲線や斜め線のギザギザが目立ちにくくなります。円や直線を描く今回のアプリでは、見た目の違いを確認しやすい設定です。

5. 円と長方形は始点・幅・高さで描く

int width = endPos.X - startPos.X;
int height = endPos.Y - startPos.Y;

始点から終点までの横方向の差がwidth、縦方向の差がheightです。

円はFillEllipse、長方形はFillRectangleで塗りつぶします。

e.Graphics.FillEllipse(brush, startPos.X, startPos.Y, width, height);
e.Graphics.FillRectangle(brush, startPos.X, startPos.Y, width, height);

ここでいうFillEllipseは、指定した長方形の内側に楕円を描くメソッドです。幅と高さが同じなら円、異なれば楕円になります。

6. 直線は始点と終点を結ぶ

using (Pen pen = new Pen(color, penSize))
{
    e.Graphics.DrawLine(pen, startPos, endPos);
}

DrawLineは、始点と終点を1本の線で結びます。

Penには色と太さを設定できます。円と長方形はSolidBrushで内側を塗りつぶしますが、直線には面積がないためPenを使います。

7. ColorDialogで色を選ぶ

using (ColorDialog colorDialog = new ColorDialog())
{
    if (colorDialog.ShowDialog() == DialogResult.OK)
    {
        colorButton.BackColor = colorDialog.Color;
    }
}

ColorDialogを使うと、Windows標準の色選択画面を表示できます。

利用者が「OK」を押したときだけ、選ばれた色をcolorButton.BackColorへ保存します。GetColor()はこの背景色を返すため、ボタン自体が現在色の見本にもなります。

8. TryParseでペンの太さを安全に受け取る

if (int.TryParse(penSizeBox.Text, out int size) && size > 0)
{
    return size;
}

return 1;

TextBoxの内容は文字列です。そのため、ペンの太さとして使うにはintへ変換する必要があります。

int.Parseは数字以外が入力されると例外を発生させます。一方、int.TryParseは変換できたかどうかをtrueまたはfalseで返します。

この例では、正の整数ならその値を返し、空欄・文字・0以下の場合は標準値の1を返します。入力ミスがあってもアプリが停止しません。

9. PenとSolidBrushをusingで破棄する理由

PenSolidBrushColorDialogは、使い終わったときに後片付けが必要なオブジェクトです。

using (SolidBrush brush = new SolidBrush(color))
{
    e.Graphics.FillEllipse(brush, startPos.X, startPos.Y, width, height);
}

usingブロックを抜けるとDispose()が呼ばれ、使用していたリソースが解放されます。Paintはドラッグ中に何度も呼ばれるため、小さなお絵描きアプリでも意識しておきたいポイントです。

なお、PaintEventArgsから受け取ったe.Graphicsはフォーム側が管理しています。自分で作成したものではないため、ここでDispose()してはいけません。

元コードで起こり得る問題

教材としては分かりやすいコードですが、次の点には注意が必要です。

状況 起こること
左や上へドラッグする 幅や高さが負になり、円や長方形を期待どおり描けない
パレットを閉じる Form1は残るが、設定を変更できなくなる
フォームが再描画される 現在の1図形だけが描かれ、過去の図形は保存されない
太さに0以下を入力する Penに適さない値になる
PenBrushを破棄しない 描画を繰り返すほどリソースを消費する可能性がある

今回のコードは「マウスイベントから描画までの流れ」を学ぶことが中心です。複数の図形を残すには、図形の種類・座標・色・太さをオブジェクトとしてListへ保存し、Paintですべて描き直す設計へ発展させます。

改善1:どの方向へドラッグしても描けるようにする

元コードでは、右下へドラッグしたときに正の幅と高さになります。左上などへドラッグした場合にも対応するには、描画用の長方形を正規化します。

private Rectangle GetDrawingRectangle()
{
    int x = Math.Min(startPos.X, endPos.X);
    int y = Math.Min(startPos.Y, endPos.Y);
    int width = Math.Abs(endPos.X - startPos.X);
    int height = Math.Abs(endPos.Y - startPos.Y);

    return new Rectangle(x, y, width, height);
}

描画側では次のように使います。

Rectangle area = GetDrawingRectangle();

e.Graphics.FillEllipse(brush, area);
e.Graphics.FillRectangle(brush, area);

Math.Minで左上の座標を求め、Math.Absで幅と高さを正の値にしています。これでドラッグする向きに関係なく描画できます。

改善2:図形の種類をenumで表す

123だけでは、数字の意味をコードから読み取りにくいという問題があります。列挙型enumを使うと、役割を名前で表せます。

public enum FigureType
{
    Circle,
    Rectangle,
    Line
}

たとえば、figureType = FigureType.Circle;のように書けます。コード量は少し増えますが、条件分岐の意味が分かりやすくなり、数字の指定ミスも減らせます。

さらに一歩:複数の図形を残すには

現在のstartPosendPosは、それぞれ1組の座標しか覚えられません。そのため、新しい図形を描くと前の図形の情報は上書きされます。

複数の図形を残す場合は、次の情報を1つにまとめます。

  • 図形の種類
  • 始点と終点
  • ペンの太さ

そして、図形オブジェクトをListへ追加し、Paintイベントでリスト内の図形を順番に描画します。

ここまで進むと、今回学んだイベント処理に加えて、クラス設計、継承やポリモーフィズムの練習題材にもできます。

複数の図形を残す実装例を見る

まず、1つの図形に必要な情報をまとめるクラスを作ります。

public class DrawFigure
{
    public int Type { get; set; }
    public Point Start { get; set; }
    public Point End { get; set; }
    public Color Color { get; set; }
    public int PenSize { get; set; }
}

Form1に図形を保存するリストを追加し、MouseUpで完成した図形を登録します。

private readonly List<DrawFigure> figures = new List<DrawFigure>();

private void MouseReleased(object sender, MouseEventArgs e)
{
    if (e.Button != MouseButtons.Left)
    {
        return;
    }

    endPos = e.Location;

    figures.Add(new DrawFigure
    {
        Type = pallet.GetFigureType(),
        Start = startPos,
        End = endPos,
        Color = pallet.GetColor(),
        PenSize = pallet.GetPenSize()
    });

    Invalidate();
}

Paintイベントでは、保存されている図形を先頭から順番に描き直します。

private void DrawFigures(object sender, PaintEventArgs e)
{
    e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;

    foreach (DrawFigure figure in figures)
    {
        DrawStoredFigure(e.Graphics, figure);
    }
}

private void DrawStoredFigure(Graphics graphics, DrawFigure figure)
{
    int x = Math.Min(figure.Start.X, figure.End.X);
    int y = Math.Min(figure.Start.Y, figure.End.Y);
    int width = Math.Abs(figure.End.X - figure.Start.X);
    int height = Math.Abs(figure.End.Y - figure.Start.Y);
    Rectangle area = new Rectangle(x, y, width, height);

    if (figure.Type == 1)
    {
        using (SolidBrush brush = new SolidBrush(figure.Color))
        {
            graphics.FillEllipse(brush, area);
        }
    }
    else if (figure.Type == 2)
    {
        using (SolidBrush brush = new SolidBrush(figure.Color))
        {
            graphics.FillRectangle(brush, area);
        }
    }
    else if (figure.Type == 3)
    {
        using (Pen pen = new Pen(figure.Color, figure.PenSize))
        {
            graphics.DrawLine(pen, figure.Start, figure.End);
        }
    }
}

最後に、フォームのMouseUpイベントをMouseReleasedへ接続してください。画面が隠れたり最小化されたりしても、Paintが呼ばれるたびにリスト内の図形が復元されます。

初学者向け練習問題

練習1:枠線だけの円と長方形を描く

FillEllipseFillRectangleの代わりに、DrawEllipseDrawRectangleを使ってみましょう。SolidBrushではなくPenが必要です。

解答例を見る
using (Pen pen = new Pen(color, penSize))
{
    if (type == 1)
    {
        e.Graphics.DrawEllipse(pen, startPos.X, startPos.Y, width, height);
    }
    else if (type == 2)
    {
        e.Graphics.DrawRectangle(pen, startPos.X, startPos.Y, width, height);
    }
}

塗りつぶし用のSolidBrushを、枠線用のPenへ置き換えます。

練習2:ペンの太さに上限を付ける

たとえば1から50までだけを有効にし、それ以外は1を返すようにGetPenSize()を変更してみましょう。

解答例を見る
public int GetPenSize()
{
    if (int.TryParse(penSizeBox.Text, out int size)
        && size >= 1 && size <= 50)
    {
        return size;
    }

    return 1;
}

整数へ変換でき、さらに1〜50の範囲に入っている場合だけ入力値を採用します。

練習3:パレットに現在の図形名を表示する

円・長方形・直線のボタンを押したとき、Labelへ現在の選択を表示してみましょう。

解答例を見る
private void CircleButtonClicked(object sender, EventArgs e)
{
    figureType = 1;
    currentFigureLabel.Text = "円";
}

private void RectButtonClicked(object sender, EventArgs e)
{
    figureType = 2;
    currentFigureLabel.Text = "長方形";
}

private void LineButtonClicked(object sender, EventArgs e)
{
    figureType = 3;
    currentFigureLabel.Text = "直線";
}

PalletへLabelを追加し、名前をcurrentFigureLabelにしておきます。

練習4:複数の図形を残す

MouseUpイベントで完成した図形をListへ追加し、Paintで全件を描画してみましょう。

解答例を見る
private readonly List<DrawFigure> figures = new List<DrawFigure>();

private void MouseReleased(object sender, MouseEventArgs e)
{
    if (e.Button != MouseButtons.Left)
    {
        return;
    }

    endPos = e.Location;

    figures.Add(new DrawFigure
    {
        Type = pallet.GetFigureType(),
        Start = startPos,
        End = endPos,
        Color = pallet.GetColor(),
        PenSize = pallet.GetPenSize()
    });

    Invalidate();
}

public class DrawFigure
{
    public int Type { get; set; }
    public Point Start { get; set; }
    public Point End { get; set; }
    public Color Color { get; set; }
    public int PenSize { get; set; }
}

Paintイベントではfiguresforeachで繰り返し、各要素の情報を使って描画します。MouseUpイベントとMouseReleasedの接続も必要です。

練習5:全消去ボタンを作る

保存した図形のListを空にしてInvalidate()を呼び、キャンバスを描き直してみましょう。

解答例を見る
private void ClearButtonClicked(object sender, EventArgs e)
{
    figures.Clear();
    Invalidate();
}

Clear()で保存済みの図形をすべて削除し、Invalidate()で画面を描き直します。

まとめ

今回のお絵描きアプリでは、Windows Formsのイベント駆動と描画処理の基本を学びました。

  • MouseDownで始点を保存する
  • MouseMoveで終点を更新する
  • Invalidate()で再描画を依頼する
  • Paintイベントで円・長方形・直線を描く
  • SmoothingMode.AntiAliasで輪郭を滑らかにする
  • ColorDialogで色を選ぶ
  • TryParseでペンの太さを安全に変換する
  • PenSolidBrushusingで破棄する
  • Form1Palletで描画と設定の役割を分ける

小さなアプリですが、入力、状態の管理、再描画というGUIアプリの大切な流れが詰まっています。まずは現在の1図形を描く仕組みを理解し、その後で複数図形の保存やenumへの置き換えへ進むと、無理なく発展させられます。

訪問数 4 回, 今日の訪問数 4回

広告

C#

Posted by hidepon