这次我们终于摆脱了丑陋的黑框框控制台,进入了Windows图形界面编程的时代。

先从一个比较简单的程序入手吧!

——高仿MSPaint画板程序

前期准备

实验要求

2.4 构造属于你的专属画图程序,可参考系统自带的绘图板;

需求分析

对于画图程序,自然少不了一个交互界面。所以从前熟悉的命令台窗口就发挥不了用处了,我们需要踏入一块新的领域——窗体应用。

对于一个最基础的画板来说,我们需要实现:

  • 图片的打开
  • 图片的保存
  • 简单的画笔工具
  • 简单的橡皮工具
  • 清屏功能

额外可以实现的功能:

  • 直线工具
  • 椭圆形工具
  • 矩形工具
  • 更改画笔颜色
  • 更改画笔粗细

话不多说,直接进入正题。

实验环境

虽然Visual Studio和VS Code都可以用来编写C#程序,但由于微软的Visual Studio提供了图形化界面,使得我们可以更轻易地绘制窗体,所以本次实验我们选用VS2019来进行操作。

在实验开始之前,先创建一个新项目,并选择Windows窗体应用(.NET Framework) ,点击创建。

实验过程

控件选择

对于画板的主题,由于要进行图片的显示与修改,我们选择PictureBox控件,并使用Graphics类来进行图像的绘制。

而对于功能选择按钮,最好的选择自然就是Button控件,不必多说。

定义变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Graphics graphics;          //画板
Bitmap paintImg; //绘画图像
int pWidth; //图像宽度
int pHeight; //图像高度
bool isPainting = false; //判断是否正在画图
int startXPos; //画图的起始坐标
int startYPos; //画图的起始坐标
Btns btn = Btns.Pencil; //当前操作
Color drawColor=Color.Black;//笔刷颜色
float drawWidth = 2f; //笔刷粗细

enum Btns
{
Pencil,
Line,
Circle,
Rectangle,
Eraser
}

首先我们将需要用到的变量在代码顶部进行声明:

用isPainting变量来判断是否正在画图,使得我们可以在鼠标移动的时候选择是否需要画下线条。

用枚举类型Btns的变量btn来判断当前按下的按钮是什么操作,从而决定画下的是什么线条。

其余变量应该比较容易理解,就在此略过。

创建画布

1
2
3
4
5
6
7
8
9
10
11
private void MainForm_Load(object sender, EventArgs e)
{
pWidth = Painter.Width;
pHeight = Painter.Height;

//创建一个空白画布
paintImg = new Bitmap(pWidth, pHeight);
graphics = Graphics.FromImage(paintImg);
graphics.Clear(Color.White);
Painter.Image = paintImg;
}

首先创建一个位图paintImg,用来记录画下的图案,并将其在PictureBox(这里名为Painter)上显示,同时将graphics画板设置为paintImg图片,然后设置为空白。

自由绘图

在画板上绘图的过程大体可以分为按下鼠标→鼠标移动→弹起鼠标三个过程,我们只要在按下鼠标的时候将isPainting设置为true,然后在鼠标移动的过程中,每一个瞬间都画下一个点,再在弹起鼠标后将isPainting归为false,就能完成画图的过程,十分容易。接下来我们对这三步分别分析:

按下鼠标

1
2
3
4
5
6
7
8
9
private void Painter_MouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
isPainting = true;
startXPos = e.X;
startYPos = e.Y;
}
}

这部分较为容易,不多赘述。

鼠标移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void Painter_MouseMove(object sender, MouseEventArgs e)
{
if(isPainting)
{
Graphics currGra = Graphics.FromImage(paintImg);
Pen pen = new Pen(drawColor, drawWidth);

pen.StartCap = LineCap.Round;
pen.EndCap = LineCap.Round;
currGra.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; //抗锯齿

currGra.DrawLine(pen, startXPos, startYPos, e.X, e.Y);
startXPos = e.X;
startYPos = e.Y;

//将临时图像存入画板
Painter.Image = paintImg;
currGra.Dispose();
}
}

在这部分中,首先创建一个临时的画布,以便我们进行绘制,然后创建一个Pen类型的画笔pen。但此时画笔画出来的图像不是连贯的,所以我们需要对画笔进行一定的优化,首先将它的StartCapEndCap都设置为圆角LineCap.Round,使得两次绘制连续。然后将其SmoothingMode设置为System.Drawing.Drawing2D.SmoothingMode.AntiAlias,这部分得主要作用是抗锯齿,让线条更为平滑。

在做完基本设置后,就可以开始画线了,通过DrawLine函数,在起始点与当前点之间画上一条线,然后将起始点设置为当前点,方便下一次的绘制。

最后将当前图像存回画板,就完成了线条的绘制。

弹起鼠标

1
2
3
4
5
6
7
8
9
10
private void Painter_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
isPainting = false;

startXPos = 0;
startYPos = 0;
}
}

没什么特殊的地方,不谈。

绘制图形

绘制图形其实大体与自由绘制没什么区别,但是多了一项实时预览。这个实时预览的功能有两种方法可以实现:

  1. 在每次移动的时候画下图形,并记录下当前图形的坐标,然后在下一个图形绘制之前用背景色将这次的图形覆盖掉,模拟实时预览的功能。这样写起来自然比较容易,但问题在于背景色不一定是纯色,如果是不规则的背景,就无法处理。
  2. 每次移动时创建一个临时画布,并将图形画在这个临时画布上,并且在移动到下一个位置的时候,将该临时画布删除。这样的好处就是不会对原画布进行更改,也就不用考虑删除上一次图形的问题了。在这里我选用第二种方法。
1
2
3
4
5
6
7
8
9
10
//创建一个临时的图片来绘制
Bitmap tempImg = new Bitmap(pWidth, pHeight);
tempImg = (Bitmap)paintImg.Clone();
currGra = Graphics.FromImage(tempImg);

//{在此处绘制图形}
//如:currGra.DrawLine(pen, startXPos, startYPos, e.X, e.Y);

//将Painter视图的图像设置为这个临时图像
Painter.Image = tempImg;

代码的过程与上述描述相同,便不过多解释。

这边再额外介绍一下椭圆形与矩形的绘制:与直线绘制函数DrawLine较为不同,椭圆绘制函数DrawEllipse与矩形绘制函数DrawRectangle的参数是(笔刷,起始点X,起始点Y,宽度,高度)。由于宽度和高度一般为正数,这就意味着如果起始点的X大于当前点的X(Y也同理),我们就无法直接用(起始点X-当前点X)来得出宽度,需要我们进行分类讨论:

1
2
3
4
5
6
7
8
9
10
if (e.X < startXPos)
{
if(e.Y < startYPos) currGra.DrawRectangle(pen, e.X, e.Y, startXPos - e.X, startYPos - e.Y);
else currGra.DrawRectangle(pen, e.X, startYPos, startXPos - e.X, e.Y - startYPos);
}
else
{
if(e.Y < startYPos) currGra.DrawRectangle(pen, startXPos, e.Y, e.X - startXPos, startYPos - e.Y);
else currGra.DrawRectangle(pen, startXPos, startYPos, e.X - startXPos, e.Y - startYPos);
}

清空画板

其实清空画板的主体函数我们已经在前文遇到过了——在创建画布的部分,已经实现了将画布设置为空白的操作,这里只要将其重复一遍即可。

1
2
3
4
5
6
7
private void ClearBtn_Click(object sender, EventArgs e)
{
Graphics currGra = Graphics.FromImage(paintImg);
currGra.Clear(Color.White);
Painter.Image = paintImg;
currGra.Dispose();
}

打开/保存图像

聊完了基础的绘图操作,我们的主干部分也大体完成了,剩下的就是些细枝末节的操作了。

首先是打开与保存图像的操作,这两个操作的实现原理基本相同,所以放在一起介绍。

打开图像可以直接调用C#的控件OpenFileDialog,同样,保存图像可以调用SaveFileDialog。由于我们处理的是图像文件,所以把DefaultExt(默认扩展名)一项设置为PNG;把Filter(文件筛选器)设置为PNG(*.png)|*.png|JPEG(*.jpg;*.jpeg)|*.jpg

然后将两个对应按钮的点击函数分别设置为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//打开图片
private void OpenBtn_Click(object sender, EventArgs e)
{
if(openFileDialog.ShowDialog()==DialogResult.OK)
{
paintImg = (Bitmap)Image.FromFile(openFileDialog.FileName);
Painter.Image = paintImg;
}
}

//保存图片
private void SaveBtn_Click(object sender, EventArgs e)
{
if(saveFileDialog.ShowDialog()==DialogResult.OK)
{
paintImg.Save(saveFileDialog.FileName, ImageFormat.Jpeg);
}
}

更改笔刷样式

这个过程看起来很高大上,其实也可以调用C#自带的控件。ColorDialog(颜色选择器)可以用来弹出自带的颜色选择窗口,并且将颜色值返回。我们只要将这个返回的颜色值赋给drawColor变量即可。而对于笔刷粗细,本身只是一个float值,就更加容易,直接从文本框中获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//颜色选择
private void ColorBtn_Click(object sender, EventArgs e)
{
ColorDialog colorDialog = new ColorDialog();
colorDialog.FullOpen = true;
colorDialog.Color = drawColor;
if(colorDialog.ShowDialog()==DialogResult.OK)
{
drawColor = colorDialog.Color;
ColorBtn.BackColor = colorDialog.Color;
}
}

//粗细选择
private void WidthBtn_Click(object sender, EventArgs e)
{
float width = (float)Convert.ToInt32(WidthText.Text);
if (width != 0) drawWidth = width;
}

至此,我们的代码部分已经全部完成,画板已经可以正常运行。

测试一下,一切功能正常。

结语

这次实验主要让我熟悉了Windows窗体应用的基本操作,由于以前较少接触过窗体应用,所以还是踩了不少坑。所幸最后还是顺利完成了实验,虽然只完成了部分画图操作,且还有很多的优化空间,但是学习了大多数C#控件的操作与使用,总体来说较为满意。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace LinyxPainter
{
public partial class MainForm : Form
{
#region 定义变量
Graphics graphics; //画板
Bitmap paintImg; //绘画图像
int pWidth; //图像宽度
int pHeight; //图像高度
bool isPainting=false; //判断是否正在画图
int startXPos; //画图的起始坐标
int startYPos; //画图的起始坐标
Btns btn = Btns.Pencil; //当前操作
Color drawColor=Color.Black;//笔刷颜色
float drawWidth = 2f; //笔刷粗细

enum Btns
{
Pencil,
Line,
Circle,
Rectangle,
Eraser
}
#endregion

public MainForm()
{
InitializeComponent();
}

private void MainForm_Load(object sender, EventArgs e)
{
pWidth = Painter.Width;
pHeight = Painter.Height;

//创建一个空白画布
paintImg = new Bitmap(pWidth, pHeight);
graphics = Graphics.FromImage(paintImg);
graphics.Clear(Color.White);
Painter.Image = paintImg;
}

private void Painter_MouseDown(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
isPainting = true;
startXPos = e.X;
startYPos = e.Y;

}
}

private void Painter_MouseMove(object sender, MouseEventArgs e)
{
if(isPainting)
{
#region 绘制临时图像
Graphics currGra = Graphics.FromImage(paintImg);
Pen pen = new Pen(drawColor, drawWidth);
pen.StartCap = LineCap.Round;
pen.EndCap = LineCap.Round;
currGra.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; //抗锯齿
if (btn == Btns.Pencil||btn==Btns.Eraser) //自由绘制类型
{
switch (btn)
{
case Btns.Pencil:
currGra.DrawLine(pen, startXPos, startYPos, e.X, e.Y);
break;
case Btns.Eraser:
pen.Color = Color.White;
pen.Width = 30f;
currGra.DrawLine(pen, startXPos, startYPos, e.X, e.Y);
pen.Color = drawColor;
pen.Width = drawWidth;
break;
}
startXPos = e.X;
startYPos = e.Y;
//自由类型 可以直接将临时图像存入画板
Painter.Image = paintImg;
}
else if(btn==Btns.Line||btn==Btns.Circle||btn==Btns.Rectangle) //图形绘制类型
{
//创建一个临时的图片来绘制
Bitmap tempImg = new Bitmap(pWidth, pHeight);
tempImg = (Bitmap)paintImg.Clone();
currGra = Graphics.FromImage(tempImg);

switch (btn)
{
case Btns.Line:
currGra.DrawLine(pen, startXPos, startYPos, e.X, e.Y);
break;
case Btns.Circle:
currGra.DrawEllipse(pen, startXPos, startYPos, e.X - startXPos, e.Y - startYPos);
break;
case Btns.Rectangle:
if (e.X < startXPos)
{
if(e.Y < startYPos) currGra.DrawRectangle(pen, e.X, e.Y, startXPos - e.X, startYPos - e.Y);
else currGra.DrawRectangle(pen, e.X, startYPos, startXPos - e.X, e.Y - startYPos);
}
else
{
if(e.Y < startYPos) currGra.DrawRectangle(pen, startXPos, e.Y, e.X - startXPos, startYPos - e.Y);
else currGra.DrawRectangle(pen, startXPos, startYPos, e.X - startXPos, e.Y - startYPos);
}
break;
}
//将Painter视图的图像设置为这个临时图像
Painter.Image = tempImg;
}
currGra.Dispose();
#endregion
}
}

private void Painter_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
isPainting = false;

#region 画最后一笔
Graphics currGra = Graphics.FromImage(paintImg);
Pen pen = new Pen(drawColor, drawWidth);
pen.StartCap = LineCap.Round;
pen.EndCap = LineCap.Round;
currGra.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; //抗锯齿

switch (btn)
{
case Btns.Line:
currGra.DrawLine(pen, startXPos, startYPos, e.X, e.Y);
break;
case Btns.Circle:
currGra.DrawEllipse(pen, startXPos, startYPos, e.X - startXPos, e.Y - startYPos);
break;
case Btns.Rectangle:
if (e.X < startXPos)
{
if (e.Y < startYPos) currGra.DrawRectangle(pen, e.X, e.Y, startXPos - e.X, startYPos - e.Y);
else currGra.DrawRectangle(pen, e.X, startYPos, startXPos - e.X, e.Y - startYPos);
}
else
{
if (e.Y < startYPos) currGra.DrawRectangle(pen, startXPos, e.Y, e.X - startXPos, startYPos - e.Y);
else currGra.DrawRectangle(pen, startXPos, startYPos, e.X - startXPos, e.Y - startYPos);
}
break;
default:
break;
}
Painter.Image = paintImg;
#endregion

startXPos = 0;
startYPos = 0;
}
}

#region 基础操作按钮

private void OpenBtn_Click(object sender, EventArgs e)
{
if(openFileDialog.ShowDialog()==DialogResult.OK)
{
paintImg = (Bitmap)Image.FromFile(openFileDialog.FileName);
Painter.Image = paintImg;
}
}

private void SaveBtn_Click(object sender, EventArgs e)
{
if(saveFileDialog.ShowDialog()==DialogResult.OK)
{
paintImg.Save(saveFileDialog.FileName, ImageFormat.Jpeg);
}
}

private void ClearBtn_Click(object sender, EventArgs e)
{
Graphics currGra = Graphics.FromImage(paintImg);
currGra.Clear(Color.White);
Painter.Image = paintImg;
currGra.Dispose();
}

#endregion

#region 枚举当前笔刷
private void PencilBtn_Click(object sender, EventArgs e)
{
btn = Btns.Pencil;
}

private void LineBtn_Click(object sender, EventArgs e)
{
btn = Btns.Line;
}

private void CircleBtn_Click(object sender, EventArgs e)
{
btn = Btns.Circle;
}

private void RectBtn_Click(object sender, EventArgs e)
{
btn = Btns.Rectangle;
}

private void EraserBtn_Click(object sender, EventArgs e)
{
btn = Btns.Eraser;
}
#endregion

#region 更改笔刷样式
private void ColorBtn_Click(object sender, EventArgs e)
{
ColorDialog colorDialog = new ColorDialog();
colorDialog.FullOpen = true;
colorDialog.Color = drawColor;
if(colorDialog.ShowDialog()==DialogResult.OK)
{
drawColor = colorDialog.Color;
ColorBtn.BackColor = colorDialog.Color;
}
}

private void WidthBtn_Click(object sender, EventArgs e)
{
float width = (float)Convert.ToInt32(WidthText.Text);
if (width != 0) drawWidth = width;
}

#endregion

private void saveFileDialog_FileOk(object sender, CancelEventArgs e)
{

}

private void openFileDialog_FileOk(object sender, CancelEventArgs e)
{

}
}
}