C# 初級講座
VB2019 Visual Studio 2022

第17回 マウスでお絵描き

2022/8/14

この記事が対象とする製品・バージョン

Visual Studio 2022 Visual Studio 2022 対象です。
Visual Studio 2019 Visual Studio 2019 対象です。
Visual Studio 2017 Visual Studio 2017 対象外ですが、参考になります。
Visual Studio 2015 Visual Studio 2015 対象外ですが、参考になります。
Visual Studio 2013 Visual Studio 2013 対象外ですが、参考になります。
Visual Studio 2012 Visual Studio 2012 対象外ですが、参考になります。
Visual Studio 2010 Visual Studio 2010 対象外ですが、参考になります。
Visual Studio 2008 Visual Studio 2008 対象外ですが、参考になります。
Visual Studio 2005 Visual Studio 2005 対象外ですが、参考になります。
Visual Studio.NET 2003 Visual Studio.NET 2003 × 対象外です。
Visual Studio.NET 2002 Visual Studio.NET (2002) × 対象外です。
  Visual Studio Code 対象外ですが、参考になります。

 

目次

 

1.お絵描きを題材にListを学ぶ

今回は、複数の値をまとめて扱う List (読み方:List=リスト) というクラスを扱います。

マウスでなぞった座標を次々とListに記録していき、その全部を線で結ぶことでマウスでなぞった部分が描画されます。

似たようなものとして前回「配列」を紹介しています。配列は後から値を追加するのが苦手なので、今回のように全部でいくつになるかわからないものを次々と追加していくのが苦手です。List はそれが得意です。

 

今回作成するプログラムは完成すると次のようになります。

※ところどころ線が細切れになるのは動画の問題です。実際はひと筆でつながっています。

 

このように色を選択してマウスで線を書くことができます。ただし、同時に描画できる線は1本だけです。マウスを話してもう1度書き始めると前の線は消えます。 これは、今回のプログラムでは座標を記録するのが線1本分だからです。

右上にあるSAVEボタンを押すと、描画した内容をpng形式の画像ファイルで保存できます。

 

 

2.コントロールの配置

2-1.プロジェクトの作成

まずは下記の設定で新規プロジェクトを作成してください。

プロジェクトテンプレート Windows フォーム アプリ
プロジェクト名 DrawPen
ソリューション名 DrawPen
フレームワーク .NET 6.0

 

2-2.コントロールの配置

フォームを少し大きめにサイズ変更して、その中にPictureBox(読み方:PictureBox=ピクチャーボックス)を1つ大きめに配置してください。

フォームの右上にはRadioButton(読み方:RadioButton=ラジオボタン または レイディオボタン)を3つ並べます。

RadioButtonはどれか1つを選択させるときに便利に使用できるコントロールです。似たようなものにCheckBox(読み方:CheckBox=チェックボックス)があります。CheckBoxは複数選択が可能です。

 

2-3.RadioButtonの配置と設定

RadioButtonの配置と設定は少し注意が必要なので先にやり方を書いておきます。

今回はこの3つのRadioButtonが1つのイベントハンドラーを共有するようにします。

まず、1つ目のRadioButtonをフォームの右上に配置してください。名前(name)は radioRed とします。文字はいらないのでTextプロパティを空にします。そしてBackColorプロパティをRedにします。RadioButtonのサイズは既定では自動的に決定されてしまうので AutoSizeプロパティをfalseにして自分でサイズを決定できるようにします。そして、マウス等でサイズと位置を決めます。

同様にして2つ目の radioBlue と 3つ目の radioGreenも配置します。

ここからがポイントです。まず、radioRedを選択し、プロパティウィンドウのイベント一覧で Click イベントを探します。

ここに自分で radioColors_Click と入力して Enter を押します。これで radioRed のクリックイベントハンドラーとして radioColors_Clickメソッドが生成されます。次にデザイン画面に戻り radioBlue を選択し、今度はプロパティウィンドウの Click イベントをドロップダウンして、 radioColors_Click を選択します。同様に radioGreen でも Clickイベントで radioColors_Click を選択します。

この操作でこの3つのRadioButtonは同じ radioColors_Click というイベントハンドラーを共有することになります。

 

2-4.コントロールのプロパティとイベント

プロパティとイベントは次の通り設定してください。この表には上記で設定したRadioButtonの設定も含めています。

コントロールの名前
(種類)
プロパティ/イベント 備考
イベント Form1
(Form)
イベント Text マウスお絵描き
イベント PictureBox1
(PitcureBox)
イベント Anchor Top,Bottom,Left,Right フォームの大きさの変更に対応します。
イベント MouseDown   ハンドラーを生成してください。
イベント MouseMove   ハンドラーを生成してください。
イベント Paint   ハンドラーを生成してください。
イベント radioRed
(RadioButton)
イベント AutoSize false  
イベント BackColor Red  
イベント Text   空にしてください。
イベント Click radioColors_Click ※本文参照
イベント radioBlue
(RadioButton)
イベント AutoSize false  
イベント BackColor Blue  
イベント Text   空にしてください。
イベント Click radioColors_Click ※本文参照
イベント radioGreen
(RadioButton)
イベント AutoSize false  
イベント BackColor Green  
イベント Text   空にしてください。
イベント Click radioColors_Click ※本文参照
イベント btnSave
(Button)
イベント Anchor Top,Right  
イベント Text SAVE  
イベント Click   ハンドラーを生成してください。

 

2-5.Anchorプロパティ

btnSaveのAnchorプロパティでは、上と右を選択した状態にしてください。プロパティウィンドウで Top,Right と表示されれば正しいです。

これの意味は、フォームに対するbtnSaveの上側の距離と右側の距離が常に一定であるということです。

図に書くと、下のようにTopとRightの距離が常に一定であるということです。

マウスでフォームのサイズを変更しても、TopとRightの距離は変わりません。結果としてbtnSaveボタンの位置が移動します。

Anchorとは船が流されないようにする「錨(いかり)」のことです。TopとRightが錨で固定されているイメージなのです。

このようにAnchorプロパティをうまく使うとこの設定だけでフォームのサイズ変更時にコントロールを自動的に適切な位置に配置することができます。

レイアウトによってはAnchorプロパティだけではなく、Dockプロパティを使用する場合もあります。Dockプロパティについては機会があれば別途説明します。(そのような機会があるかまだわかりません。)

 

3.プログラム

3-1.プログラム

準備が整ったら次の通りプログラムしてください。このコメント含めて70行ほどのプログラムが今回のすべてです。コピー&貼り付けするのではなく自分でキーボードから入力することをお勧めします。コピペするのと自分で入力するのとでは学習効果が全然違います。

namespace DrawPen
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        //マウスでなぞった座標を記録します。
        List<Point> stroke = new List<Point>();

        private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
        {
            //新しい座標の記録を開始します。これまでの記録は失われます。
            stroke = new List<Point>();
        }

        private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
        {
            if (e.Button != MouseButtons.None)
            {
                //なにかマウスボタンを押している場合、
                stroke.Add(e.Location); //現在の座標を記録します。
                System.Diagnostics.Debug.WriteLine($"記録されている座標の数は {stroke.Count} 個");
                pictureBox1.Invalidate(); //pictureBox1を描画します。
            }
        }

        private void pictureBox1_Paint(object sender, PaintEventArgs e)
        {
            Draw(e.Graphics); 
        }

        private void Draw(Graphics g)
        {
            //背景を黒にします。
            g.Clear(Color.Black);

            if (stroke.Count >= 2)
            {
                //2つ以上座標が記録されている場合線で結びます。
                Pen p = new Pen(penColor, 5); //幅5で選択されている色のペンを作成します。 
                g.DrawLines(p, stroke.ToArray());
            }
        }

        private void btnSave_Click(object sender, EventArgs e)
        {
            //pictureBox1の描画領域と同じサイズのビットマップをメモリ上に確保します。
            var image = new Bitmap(pictureBox1.ClientRectangle.Width, pictureBox1.ClientRectangle.Height);
            //メモリのビットマップに対して描画を行うGraphicsクラスのインスタンスを生成します。
            Graphics g = Graphics.FromImage(image);
            //描画を実行します。
            Draw(g);

            //メモリ上のビットマップをファイルに保存します。
            image.Save(@"C:\temp\drawpen.png", System.Drawing.Imaging.ImageFormat.Png);
        }

        //選択されている色
        Color penColor;

        private void radioColors_Click(object sender, EventArgs e)
        {
            //クリックしたRadioButtonの背景色を選択されている色として保存します。
            penColor = (sender as RadioButton)!.BackColor;
        }
    }
}

 

3-2.全体的な構造

細かいプログラムに注目する前に、このプログラムの全体的な構造を説明しておきます。

このでの説明は大雑把なものです。この後で実際のプログラムを交えて細かいところも説明します。

このプログラムは、マウスで線を書きますから、マウスを移動した時に発生する MouseMove イベント (読み方:MouseMove=マウスムーブ)が入口になります。

MouseMoveイベントでは、その時のマウスの座標を変数 stroke に追加してから、pictureBox1のInvalidateメソッドを呼び出します。これにより、pictureBox1は再描画必要であるという判定になるので、Paintイベントが発生します。

Paintイベント内ではDrawメソッドを呼び出すだけです。Drawメソッドは変数 stroke に記録されている全座標を線で結んで描画します。

この一連の流れをpictureBox上をマウスが移動するたびに実行します。ちょっとマウスが移動するだけで何十回もこの処理が実行されます。ある程度移動すると何百回・何千回と実行されることになります。この程度の処理はコンピューターには大したことはないので遠慮することはありません。記録される座標の数も何十個・何百個になります。

 

 

4.座標の記録

4-1.Listによる座標の保持

座標を記録するために変数 stroke を宣言しています。

//マウスでなぞった座標を記録します。
List<Point> stroke = new List<Point>();

ここが今回一番説明したいところです。

複数の座標を記録したいので1つのPoint型の変数とするわけにはいきません。前回説明した配列を使えば、複数の座標を記録できますが、今回のように全部で座標がいくつになるかわからない場合は、配列は使いにくくお勧めできません。

そこで登場するのがListクラス(読み方:List=リスト)です。

Listを使うと複数の値を1つの変数で保持することができます。その値がint型ならば、List<int> と記述し、string型なら List<string> と記述します。

List<int> values1 = new List<int>();
List<string> values2 = new List<string>();

この < > を使って型を指定する記述方法により1つのListクラスが様々な型の要素に対応できます。この < > の機能を「ジェネリック」と呼びます。ジェネリックにより指定される型を「型パラメーター」と呼びます。

ジェネリックのおかげで、intのList や stringのList、DateTimeのList や PointのList など好きな型のListが作れます。もし、ジェネリック機能がなければ intListクラスや stringList クラスなど、それぞれを別のクラスとするようなことになりがちで、とても扱いにくいものになってしまいます。(実際2005年にジェネリックが登場する前はこのような専用のクラスがいくつかありました。)

 

メソッド内の変数(=ローカル変数)であれば型推論を使って次のように宣言することもできます。

var values1 = new List<int>();
var values2 = new List<string>();

今回はクラスのフィールドとしての変数なので型推論は使用できません。

 

Listに値を追加するには Addメソッド(読み方:Add=アド)を使用します。Listに追加されている値を取得するには[ ] を使ってインデックスを指定しています。インデックスは 0 から始まり、追加した順番に割り当てられます。

簡単な例を紹介しておきます。

List<int> numbers = new List<int>();
numbers.Add(3);
numbers.Add(45);
numbers.Add(-123);

int x = numbers[1];
System.Diagnostics.Debug.WriteLine(x); //45

 

4-2.MouseDownイベントでの座標のクリア

このプログラムではMouseDownイベント(読み方:MouseDown=マウスダウン)ハンドラー内でstrokeに新しいList<Point>のインスタンスを設定しています。

private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
{
    //新しい座標の記録を開始します。これまでの記録は失われます。
    stroke = new List<Point>();
}

MouseDownイベントは、マウスのボタンを押すと発生します。たとえば、マウスの右ボタンや左ボタンです。中央ボタンなどその他のボタンでも発生します。

マウスのボタンを押すたびに新しいインスタンスが生成されるので、それまでに記録していた座標はすべてアクセスできなくなります。

 

 

4-3.MouseMoveイベントでの座標の記録

MouseMoveイベントのプログラムは次の通りになっています。

private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
{
    if (e.Button != MouseButtons.None)
    {
        //なにかマウスボタンを押している場合、
        stroke.Add(e.Location); //現在の座標を記録します。
        System.Diagnostics.Debug.WriteLine($"記録されている座標の数は {stroke.Count} 個");
        pictureBox1.Invalidate(); //pictureBox1を描画します。
    }
}

最初のif文はマウスのボタンが何も押されていないかどうかを判断しています。マウスのボタンが何も押されていない場合は、マウスを移動しても座標を記録せず描画行いません。

ほとんどのイベントハンドラーでは第2引数の e に、そのイベントで役立つ情報がフレームワークから渡されてきます。

MouseMoveイベントハンドラーでは第2引数は MouseEventArgs型です。

これの Button プロパティ(読み方:Button=ボタン) を調べるとこのイベントが発生した時マウスのどのボタンが押されていたのか、いなかったのかわかります。

Buttonプロパティの値は次のMouseButtons列挙型の1つまたは組み合わせになります。

このプログラムでは何でも良いから押されているか押されていないかだけを知りたいので MouseButtons.None ではない ことを if文の条件にしています。

 

マウスの座標も e.Location で取得できます。これをそのまま stroke.Add に渡します。肝心の座標の記録はこれだけです。

次の行では、その時点で記録されている座標の数をデバッグ出力に出力します。プログラマーはこれを見ながら記録されている座標の数を確認することができます。これだけの目的なのでこの行はなくても良いです。

Debug.WriteLineが出力される場所がわからない場合は、この記事を参照してください。

 

最後にpictureBox1.Invalidateを呼び出して、pictureBox1の再描画を促します。結果としてこの直後にPaintイベントが発生し、pictureBoxの描画内容が更新されます。

 

5.色の記録

5-1.sender

描画処理の前に、RadioButtonを使って色を選択する仕組みを説明しておきます。

まず、このプログラムでは選択されている色を penColor という変数で記録するようになっています。

//選択されている色
Color penColor;

penColorに色を設定するのは、radioColors_Click イベントハンドラーです。

private void radioColors_Click(object sender, EventArgs e)
{
    //クリックしたRadioButtonの背景色を選択されている色として保存します。
    penColor = (sender as RadioButton)!.BackColor;
}

コントロールを配置するときに説明したように、3つあるRadioButtonのどれをクリックしても共通してこのradioColors_Clickイベントハンドラーが呼び出されるようになっています。

イベントハンドラーの第1引数 sender には、そのイベントを発生させたコントロールのインスタンスがフレームワークから渡されてくることになっています。

3つのRadioButtonが何色を表しているかは BackColor プロパティに設定されているので、次のようなプログラムを書けば良いだけなのですが…が、…

private void radioColors_Click(object sender, EventArgs e)
{
    //クリックしたRadioButtonの背景色を選択されている色として保存します。
    penColor = sender.BackColor;
}

これはダメです。エラーになります。

なぜかという、sender が object型で宣言されているからです。object型はどのような値でも格納できる根源的な型です。どのような値でも格納できる代わりに、使用できるメンバーが非常に限定されています。

私たちは、sender が RadioButton であることを知っているので、ここからBackColorプロパティを呼び出したいのですが、C#は、sender を object型と認識しているので BackColorプロパティというものは存在していないとみなしています。

 

5-2.as演算子

この問題を解決するために、sender の型 が RadioButton であることをプログラムで明示します。それが (sender as RadioButton) です。この as演算子 (読み方:as=アズ)を使うことで、「RadioButtonとしてのsender」を表現することができます。

このように記述しておけば C# も ここでは sender を RadioButton型 として扱うと理解するので、RadioButtonが持っているメンバーにアクセスできるようになります。つまり次のようにプログラムできます。

private void radioColors_Click(object sender, EventArgs e)
{
    //クリックしたRadioButtonの背景色を選択されている色として保存します。
    penColor = (sender as RadioButton).BackColor;
}

ちなみに、プログラマーが何かの待ちがで本当はRadioButton型でないものに対しこのような記述をした場合、エラーが発生するわけではなく、sender as RadioButton は 値が存在しないことを示す null (ヌル) という特別な値になります。このプログラムではそのままBackColorプロパティを呼び出そうとしており、ここで例外が発生します。nullからは何のメンバーも呼び出すことはできないのです。

 

5-3.null免除演算子

さて、これで一応問題は解決しますがVisual Studioはまだ、この部分に緑色の波線を表示して次のように警告します。

CS8602 null 参照の可能性があるものの逆参照です。

これの意味は、本当に sender は RadioButton 何ですか?もし、違ったら null になっちゃいますけど大丈夫ですか? という意味です。

この警告が表示されていても実行できるので放置するという選択肢もありますが、私は気になる性質なので、次のように ! を付けてこの警告を消します。

private void radioColors_Click(object sender, EventArgs e)
{
    //クリックしたRadioButtonの背景色を選択されている色として保存します。
    penColor = (sender as RadioButton)!.BackColor;
}

! はその直前の要素が null ではないことが確実であると C# に伝えるための演算子です。null免除演算子 または null抑制演算子 と呼びます。

これで、C# も沈黙します。

なお、! は C# の警告を消すだけです。具体的な機能はありません。何かの間違いで null になってしまった場合は実行時に例外が発生します。

 

6.描画処理

描画処理は単純です。

private void pictureBox1_Paint(object sender, PaintEventArgs e)
{
    Draw(e.Graphics); 
}

private void Draw(Graphics g)
{
    //背景を黒にします。
    g.Clear(Color.Black);

    if (stroke.Count >= 2)
    {
        //2つ以上座標が記録されている場合線で結びます。
        Pen p = new Pen(penColor, 5); //幅5で選択されている色のペンを作成します。 
        g.DrawLines(p, stroke.ToArray());
    }
}

記録されている座標の数が2個以上なければ間を線で結ぶことはできませんから if 文で条件判断をしています。

これがないと、座標が1個しか記録されていないときこの後呼び出す DrawLines でエラーになります。

あとは、変数penColorに記録されている色を使って、幅5のペンを作り出し、DrawLinesメソッドで全座標を線で結ぶだけです。DrawLinesメソッドの第2引数には座標の配列を渡す必要がありますが、List型は ToArrayメソッド(読み方:ToArray=トゥーアレイ)を使って簡単に配列に変換できるので特に苦労しません。

なお、逆に配列からは ToList というメソッド(読み方:ToList=トゥーリスト)を呼び出すことができ、これを使うと配列をListに変換できます。

 

7.画像ファイルへの保存

最後に画像をファイルに保存する処理です。

フレームワークにはメモリ上の画像を扱う Bitmapというクラス(読み方:Bitmap=ビットマップ)があります。

private void btnSave_Click(object sender, EventArgs e)
{
    //pictureBox1の描画領域と同じサイズのビットマップをメモリ上に確保します。
    var image = new Bitmap(pictureBox1.ClientRectangle.Width, pictureBox1.ClientRectangle.Height);
    //メモリのビットマップに対して描画を行うGraphicsクラスのインスタンスを生成します。
    Graphics g = Graphics.FromImage(image);
    //描画を実行します。
    Draw(g);

    //メモリ上のビットマップをファイルに保存します。
    image.Save(@"C:\temp\drawpen.png", System.Drawing.Imaging.ImageFormat.Png);
}

このBitmapクラスを準備して、まずメモリ上に画像を描画し、最後にBitmapクラスが持つSaveメソッド(読み方:Save=セイブ)を使ってこれをファイルに保存します。

Windows フォーム アプリで使われる画像描画機能 GDI+ は基本的な思想としてPictureBoxであろうが、メモリ上の画像であろうが、プリンターであろうが、Graphicsクラスを使って描画するという点で共通しています。

描画対象をどのように準備して、Graphicsクラスのインスタンスをどのように取得するかはそれぞれ異なりますが、Graphicsクラスに対する命令は共通です。

たとえば、PictureBoxの場合、一例を上げると次の通りです。

PictureBoxの準備:フォームにPictureBoxを貼り付けます。

Graphicsクラスのインスタンスの取得方法:Paintイベントの第2引数のGraphicsプロパティ

 

メモリ上の画像(Bitmap)の場合、一例を上げると次の通りです。

Bitmapの準備:Bitmapクラスのコンストラクターを呼び出します。

Graphicsクラスのインスタンスの取得方法:Graphics.FromImageメソッドを使用します。

 

私たちが記述したDrawメソッドは引数がGraphicsクラスになっており、それがPictureBoxを対象にしたものか、Bitmapを対象にしたものか区別しません。

このような定義になっています。

private void Draw(Graphics g)
{
    …
}

ですから、Graphicsクラスのインスタンスを取得した後の処理は Drawメソッドに任せてしまうことができます。

すばらしいですね。pictureBox1に描画するときも、メモリ上の画像に描画するときも描画する内容は同じなので同じメソッドが使えれば手間が省けます。

DrawメソッドをPaintイベントハンドラーから独立しておいたことでこのような再利用がやりやすくなっているわけです。

 

 

8.練習問題

問1.Listと配列の特徴を書き出してみました。次の中からListに当てはまるものをすべて選択して「回答する」をクリックしてください。

要素の型が指定できる
要素の型を指定できない
要素の数を指定が必須
要素の数は指定しない
要素の追加は簡単
要素の追加は面倒
インデックスで要素にアクセス
ジェネリックを使う

 

 

 

問2.Listを配列に変換するために使用するのはどのメソッドでしょうか?
ToArray
ToList
ToString
問3.次のプログラムで ■ に当てはまるのはどれでしょうか?
var values = new ■;
values.Add("ABC");
values.Add("XYZ");
System.Diagnostics.Debug.WriteLine(values[0]);
int[2]
List[2]
List<int>()

 

次の回では、ちょっとこれまで学習したことを振り返ってみます。

C#初級講座講座 次の回へ