🤔 Para Refletir :
"Água mole em pedra dura tanto bate até que fura... Bem, exceto se você alterar as configurações de resistência elemental do seu projeto!"
- Moge

Criando um paint simples em C#

HermesPasser Masculino

Duque
Membro
Membro
Vícios e amores servem para preencher o vazio
Juntou-se
23 de Março de 2017
Postagens
836
Bravecoins
92
Introdução
Como vão, hoje irei fazer uma breve introdução à biblioteca System.Drawing do .NET através da criação de um simples paint em C#. Usarei como base um vídeo que gravei uns meses atrás e que teve alguns (muitos) problemas de produção.

O código fonte se encontra em meu github aqui.

Aproveitem que a [member=895]Mayleone[/member] está on fire escrevendo vários tutoriais introdutórios sobre C# e aprenda o básico se não souber.

1 - Criação do projeto
Primeiramente vamos criar o projeto.
01.png

Não esqueçam de colocar Visual C#/Windows Forms.
02.png

2 - Criação da interface
Primeiro aumentem o tamanho do formulário, depois adicione um panel que será onde ficará a imagem.
03.png

04.png

Acionemos agora um Flow Layout Panel para que nossas ferramentas fiquem organizadas horizontalmente com a mesma margem sem a necessidade de fazê-lo manualmente para cada nova inserção.
05.png

06.png

Agora, adicionemos as PictureBox, jogue a primeira no panel e clique na seta em “Encaixar no Contêiner Pai” para que ela ocupe todo o Panel.
07.png

A próxima será a PictureBox onde ficará a cor selecionada, para esta coloquei um tamanho de 50x50.
08.png

Agora, vamos adicionar as caixas de diálogo, usaremos a ColorDialog, a OpenFileDialog e a SaveFileDialog.
09.png

Adicione quatro botões no Flow Layout Panel, eles serão o botão de mudar de cor, limpar imagem, caneta e balde de tinta.
10.png

Agora adicione um MenuStrip e adicione os seguintes itens nele: “Salvar como...” e “Carregar”.
11.png

3 – Código da interface
Selecione a PictureBox que está dentro do panel e então vá em suas propriedades/eventos e procure pelos eventos do mouse. Adicione eventos para:  MouseDown, MouseMove e MouseUp. Respectivamente os nomeei como pictureBox1_MouseDown, pictureBox1_MouseMove e pictureBox1_MouseUp.
12.png

Agora abra o código do formulário as bibliotecas que usaremos:
Código:
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
System.Collections.Generic é responsável por prover tipos de lista que serão mais úteis que as arrays normais como Stack.
System.Drawing é responsável por tudo relacionado desenho e manipulação de imagens;
System.Threading é responsável pela concorrência (threads);
E System.Windows.Forms trabalha com a interface gráfica.

E então adicione o enumerador ToolBox, ele será usado para saber qual ferramenta está selecionada. Por agora teremos pen (caneta) e paintBucket (balde de tinta).
Enumeradores são representações de algo, geralmente por meio de um inteiro. Eles servem para deixar o código mais legível, por exemplo se você usar verificações de string você pode escrever errado ou se você usar inteiros para representar estados em um laço, pode ficar difícil de saber o que cada número representa.
Código:
enum ToolBox
{
     pen, paintBucket
}

E adicione estes atributos dentro da classe:
Código:
private ToolBox toolBox = ToolBox.pen;

private Color colorA = Color.Black;

private int prevX;
private int prevY;
private bool canDraw       = false;

private Thread thread = null;
O toolBox referência o enumerador criado acima, ele inicialmente recebe a caneta (pen);
colorA será a cor selecionada (note que tem um “A” em seu nome pois pode-se adicionar uma cor secundaria “B” como em outros programas) e ela inicialmente recebe a cor preta;
prevY e prevX guardarão a  posição anterior do mouse quando clicado na imagem;
canDraw diz se pode ou não desenhar algo na tela com a caneta e similares, ele inicialmente recebe false;
E finalmente, thread será a thread paralela a thread da interface e será usada para rotinas de desenho (de outra forma o loop principal da interface seria interrompido enquanto algo é desenhado e poderia aparecer a mensagem “não respondendo” no programa). Inicialmente recebe null.

Agora de duplo clique nos dois itens que adicionou no MenuStrip para adicionar o evento Click neles. Os métodos que foram adicionados aqui ficaram com os nomes de saveAsToolStripMenuItem_Click e loadToolStripMenuItem_Click.
Faça o mesmo com os quatro botões. Aqui deixe seus nomes como button1_Click, button2_Click, button3_Click e button4_Click.
O evento Click ocorre quando você clica no detentor dele, no caso, os botões e itens.

Já que os métodos dos botões e do MenuStrip usam códigos que serão criados na próxima secção então deixarei para criar a lógica deles lá.

4 - Rotinas de desenho
Agora vamos mexer nas rotinas do mouse que foram feitas acima, a começar pelo pictureBox1_MouseDown:
O evento MouseDown é ativado quando você clica em alguma área do PictureBox, ao pressionar do clique, não ao soltar.
Código:
private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
{
	prevX = e.X;
	prevY = e.Y;
	canDraw = true;

	switch (toolBox)
	{
		case ToolBox.paintBucket:
			break;
	}
}
O parâmetro “e” é do tipo MouseEventArgs, este tipo contém as coordenadas em que a imagem foi clicada. Então prevX recebe o atributo “X” enquanto prevY recebe o atributo “Y” do mesmo. Logicamente estes referem-se as coordenadas x e y.
Logo após canDraw recebe true para que o programa sabe que a partir daqui ele pode começar a desenhar com qualquer ferramenta que exija movimento do mouse e a utilização dos outros dois eventos.
O switch verifica qual é a ferramenta selecionada e o único caso adicionado é o do balde de tinta pois das nossas ferramentas ele é o único que somente no MouseDown, já que ele somente precisa saber onde ele começará a pintar, sua lógica será adicionada mais para frente já que sua dificuldade é avançada.

Agora, pictureBox1_MouseMove:
O evento MouseMove é acionado toda vez que o mouse se movimente por cima do PictureBox.
Código:
private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
{
	switch (toolBox)
	{
		case ToolBox.pen:
			if (!canDraw)
				break;

			Bitmap b = pictureBox1.Image as Bitmap;
			pictureBox1.Image = Pen(b, e);
}

	prevX = e.X;
	prevY = e.Y;
}
Basicamente se o mouse for movido ele irá procurar a ferramenta selecionada (no caso a única disponível é a caneta) e irá chamar o método dela (que será criado mais a frente).
No caso da caneta, se ela estiver selecionada e canDraw for false então ele simplesmente sai do switch sem desenhar nada. Caso contrário ele pegará a Image (imagem) da pictureBox1 (a que está dentro do panel e aquela em que vamos desenhar) e vamos fazer um casting  (que é basicamente uma conversão de tipo, o “as bitmap” no código) para converte-la em uma bitmap, que é mais prática de se manipular, e armazená-la na variável “b”. Logo após isso nós fazemos que a própria Image da pictureBox1 seja atualizada pelo retorno do método Pen passando o parâmetro “b”, que é a bitmap ainda não manipulada e “e” que é o MouseEventArgs e que dará as coordenadas atuais do mouse.
Após isso preX e prevY serão atualizados com os valores atuais de “e” (ou seja, as posições anteriores serão atualizadas com a posição atual).

E por último, pictureBox1_MouseUp:
O evento MouseUp será acionado quando o botão deixar de ser pressionado.
Código:
private void pictureBox1_MouseUp(object sender, MouseEventArgs e)
{
	canDraw = false;          
}
A única coisa que ele faz é definir canDraw como false, assim fazendo o programa saber quando ele deve parar de desenhar na tela com a caneta e similares (já que o MouseMove sempre será chamado e consequentemente a caneta também se ela estiver selecionada, e por isso que colocaremos uma condição posteriormente).

E finalmente chegamos ao método Pen:
Antes de tudo é importante dizer que dois dos tipos usados aqui em baixo (Pen e Graphics) implementam IDisposable que precisa que seus recursos sejam limpos da memória usando o método Dispose, mas usando eles em um bloco de código “using” podemos omiti-lo.
Código:
private Bitmap Pen(Bitmap bmp, MouseEventArgs e)
{
	using (var pen = new Pen(colorA, 1))
	{
		using (var g = Graphics.FromImage(bmp))
		{
			g.DrawLine(pen, prevX, prevY, e.X, e.Y);
		}
	}
	return bmp;
}
Este método cria uma Pen (caneta), que existem na biblioteca System.Drawing e a iniciar um a cor de colorA e com espessura 1, ela ficará armazenada na variável local pen. Após isso instanciamos um Graphics usando o nosso bitmap e então desenhamos uma linha usando a pen (criada acima), prevX e prevY como ponto inicial da linha e e.X e e.Y como ponto final da linha e então nosso bitmap será retornada.
Note que em momento nenhum neste método foi necessário um Bitmap, o método FromImage usa uma Image, mas se precisarmos voltar a mexer aqui é bom ter um tipo mais flexível.

“Espere um pouco Hermes, a pictureBox1 está com o atributo imagem nulo já que não atribuímos nada a ele anteriormente, então isso não vai jogar uma exceção na nossa cara? ”
Bem percebido meu jovem maker, por isso que iremos adicionar uma imagem a ela no construtor da classe:
Código:
public Form1()
{
	InitializeComponent();
	pictureBox1.Image = CreateEmptyBmp(Color.White, panel1.Width, panel1.Height);
	pictureBox2.Image = CreateEmptyBmp(colorA, pictureBox2.Width, pictureBox2.Height);
}
Este método CreateEmptyBmp, que será criado abaixo, retorna uma imagem vazia inteiramente de uma cor e com um certo tamanho.
No caso, atribuímos uma imagem branca com o tamanho do panel para a pictureBox1, e uma com a cor armazenada em colorA e com tamanho da pictureBox2 para imagem da pictureBox2 (a que mostra a cor selecionada).

CreateEmptyBmp:
Código:
        private Bitmap CreateEmptyBmp(Color c, int width, int height)
        {
            Bitmap bmp = new Bitmap(width, height);

            using (var g = Graphics.FromImage(bmp))
            {
                g.Clear(c);
            }

            return bmp;
        }
Esse código cria um novo Bitmap com o width e height que foram passados como parâmetro, e então é instanciado um Graphics usando este bitmap vazia e então seu método Clear é chamado passando a Color “c” como parâmetro, isso faz a Bitmap inteira seja pintada desta cor e por fim, ele retorna esse Bitmap.

Agora que o método CreateEmptyBmp foi explicado podemos seguir adiante e ver o código dos métodos Click. Começaremos com os itens do MenuStrip primeiro:

saveAsToolStripMenuItem_Click:
Código:
private void saveAsToolStripMenuItem_Click(object sender, EventArgs e)
{
	if (saveFileDialog1.ShowDialog() != DialogResult.OK)
		return;

	pictureBox1.Image.Save(saveFileDialog1.FileName);
	saveFileDialog1 = new SaveFileDialog();
}
O saveFileDialog1 é nossa instancia de SaveFileDialog, e seu método ShowDialog mostra a caixa de diálogo para salvar. Quando você coloca um nome, escolhe um local e aperta o botão salvar, este método retorna o valor “OK” do enumerador DialogResult, então nós fazemos a checagem se o valor retornado é qualquer coisa que não seja “OK” e se for então ele retorna e o método para.
Caso ele tenha clicado em salvar então nós chamamos método Save da Image do pictureBox1 e passamos como parâmetro o atributo FileName da saveFileDialog1 que nos retorna o caminho e nome onde salvamos o arquivo na caixa de diálogo. E por fim fazemos saveFileDialog1 receber uma nova instancia para que seus valores sejam zerados.

loadToolStripMenuItem_Click:
Código:
private void loadToolStripMenuItem_Click(object sender, EventArgs e)
{
	if (openFileDialog1.ShowDialog() != DialogResult.OK)
		return;

	Image img = Image.FromFile(openFileDialog1.FileName);
	pictureBox1.Image = img;
	pictureBox1.Width = img.Width;
	pictureBox1.Height = img.Height;
	panel1.AutoScrollMinSize = new Size(img.Width, img.Height);
	openFileDialog1 = new OpenFileDialog();	
}
Inicialmente fazemos a mesma checagem de antes só que com openFileDialog1 agora.
Então carregamos a imagem do computador usando o método FromFile de Image com o atributo FileName de openFileDialog1, então atribuímos essa imagem para a imagem da pictureBox1, outra coisa que devemos fazer é definir a largura e altura do pictureBox1 como a mesma da imagem carregada, assim fazemos que a imagem não seja cortada. É importante que a imagem esteja encaixada no Panel pois de outra forma ela também pode ficar cortada.
AutoScrollMinSize vai fazer com que a barra de rolagem do panel1 vá até o final da imagem, assim sendo se ela for maior que o panel1 você poderá usar a barra de rolagem para ver e manipular a parte que não está visível dela. O atributo AutoScrollMinSize do panel1 recebe um Size, então nós criamos um novo Size usando o tamanho da imagem carregada. Nós fazemos isso ao invés de alterar o tamanho do Panel para que ele não fique maior que o Form (formulário).

Voltando para o construtor, vamos adicionar essas linhas:
Código:
saveFileDialog1.Filter = "Image files (*.gif, *.bmp, *.jpg, *.jpeg, *.jpe, *.jfif, *.png) | *.gif; *bmp; *.jpg; *.jpeg; *.jpe; *.jfif; *.png";
openFileDialog1.Filter = "Image files | *.gif; *bmp; *.jpg; *.jpeg; *.jpe; *.jfif; *.png";
Este atributo Filter (filtro) delimita quais os tipos que essas caixas de diálogos podem manipular.
No primeiro caso vemos que a string é separada em duas pelo caractere “|”, do lado esquerdo é o texto que será exibido na caixa de diálogo e o outro lado são os formatos suportados.
13.png

O quadrado vermelho na imagem mostra o texto que fica do lado esquerdo da string do filtro.

Agora vamos falar com os métodos dos botões.
Primeiro o método do botão de mudar a cor, o button1_Click:
Código:
private void button1_Click(object sender, EventArgs e)
{
	if (colorDialog1.ShowDialog() != DialogResult.OK)
		return;

	colorA = colorDialog1.Color;      
	pictureBox2.Image = CreateEmptyBmp(colorA, pictureBox2.Width, pictureBox2.Height);
}
Primeiro nós fazemos aquela checagem anterior novamente com o colorDialog1, depois nós atribuímos a variável colorA o valor do atributo Color de colorDialog1, que é onde fica armazenado a cor que você escolhe ao abrir o ShowDialog.
E por fim, pintamos a imagem da pictureBox2 usando o método CreateEmptyBmp passando como parâmetro a colorA e o tamanho da própria pictureBox2.

Botão de limpar a imagem, o button2_Click:
Código:
private void button2_Click(object sender, EventArgs e)
{
	pictureBox1.Image = CreateEmptyBmp(Color.White, panel1.Width, panel1.Height);
}
Esse método atribui a imagem de pictureBox1 uma imagem em branco com o tamanho do panel1 por meio do CreateEmptyBmp.

O button3_Click é muito simples, ele atribui a pen para nossa toolBox.
Código:
private void button3_Click(object sender, EventArgs e)
{
	toolBox = ToolBox.pen;
}

O button4_Click faz o mesmo, mas adicionando o paintBucket.
Código:
private void button4_Click(object sender, EventArgs e)
{
	toolBox = ToolBox.paintBucket;
}

E por fim, vamos falar da nossa última ferramenta antes de mexer com concorrência, o balde de tinta:
Código:
private Bitmap PaintBucket(Bitmap bmp, Color pC, Point point)
{
	Stack<Point> stack = new Stack<Point>();
	stack.Push(point);

	while (stack.Count > 0)
	{
		Point p = stack.Pop();

		if (p.X >= 0 && p.X < bmp.Width && p.Y >= 0 && p.Y < bmp.Height)
		{
			if (bmp.GetPixel(p.X, p.Y) != pC)
				continue;

			bmp.SetPixel(p.X, p.Y, colorA);
			stack.Push(new Point(p.X + 1, p.Y));
			stack.Push(new Point(p.X - 1, p.Y));
			stack.Push(new Point(p.X, p.Y + 1));
			stack.Push(new Point(p.X, p.Y - 1));
		}
	}
	return bmp;
}
Originalmente esse método deveria usar recursividade, mas devido ao limite de chamadas recursivas do .NET uma exceção era chamada, então vamos usar uma técnica que emula essa recursividade, preparem o coração pois a explicação será um saco.
Começando com os parâmetros, temos nosso Bitmap bmp como que é onde que será desenhado, seguindo temos um Color pC que armazena a cor anterior que será substituída pela cor de colorA. Por fim temos Point (ponto) point que guarda dois valores, “X” e “Y”, seu valor passado será onde na tela que foi clicado e será o ponto de origem para trocar todos os pixels da cor de pC para a colorA.
Iniciando nosso método, criamos uma Stack de Points. Uma Stack (pilha) é um tipo de array que só se pode tirar e remover elementos do topo, neste caso somente elementos do tipo Point podem ser adicionados. Usamos o método Push para adicionar nosso Point p inicial na stack (stack nossa variável, não Stack tipo). Então entramos no while que ficará rodando enquanto está pilha não estiver vazia.
A primeira coisa que fazemos dentro do laço é criamos uma variável temporária “p” do tipo Point que receberá o Point removido da stack pelo método Pop, seguindo isso tem a checagem que verifica se esse Point em “X” e “Y” não é maior que o tamanho da imagem, ou menor que zero já que dessa forma não tem como desenhar na imagem pois os valores estariam fora do limite da matriz dela.
Já dentro do condicional, usa-se o método GetPixel do Bitmap para pegar o pixel com os valores do Point “p”, esse método não está disponível em Image e por isso que usamos Bitmap. Com este pixel em mãos, verificamos se ele tem uma cor diferente do pC, se for então pulamos para a próxima interação do laço usando o continue. Já se o pixel ter a mesma cor mesmo que o pC então logo em baixo esse pixel é pintado com a cor de colorA nessa mesma posição usando o método SetPixel, outro somente disponível no Bitmap.
Em seguida, nós adicionamos para a stack Points que estão em volta deste Point“p” fazendo que o laço rode até que todos os Points que contenha a mesma cor que pC estejam pintados com a colorA, e com a checagem de cor já vista em cima nós resolvemos o problema de ele pintar algo com outra cor.
E por fim, retornamos o Bitmap alterado.


5 - Concorrência
Agora só precisamos chamar o método PaintBucket em nosso switch no pictureBox1_MouseDown, mas a execução do PaintBucket é lenta e isso travará nossa interface até que o método termine já que a interface e o método rodam no mesmo thread, por isso precisamos chamar outro thread para executar esse método em paralelo. No começo do tutorial nós criamos essa propriedade.
Código:
case ToolBox.paintBucket:
	
	thread = new Thread(() =>
	{
			flowLayoutPanel1.Enabled = false;
			Bitmap b = pictureBox1.Image as Bitmap;
			Color c = b.GetPixel(e.X, e.Y);
			pictureBox1.Image = PaintBucket(b, c, new Point(e.X, e.Y));
			flowLayoutPanel1.Enabled = true;
	});
	thread.Start();
	break;
Já a primeira instrução do case ele já atribui uma nova Thread para a propriedade thread, seguindo nos desativamos o flowLayoutPanel1 que é onde estão nossas ferramentas, já que o processo é lento e não queremos tentar desenhar na tela com a caneta enquanto o balde de tinta tentar pintar e se embola com os pixels que foram alterados em outro thread. Logo após nós criamos um Bitmap “b” e atribuímos a imagem da pictureBox1 para ela, criamos um Color “c” que recebe o pixel do Bitmap “b” na posição da PictureBox em que foi clicado (lembre-se do MouseEventArgs “e”) e atribuimos para a imagem da pictureBox1 o retorno de PaintBucket com o Bitmap “b”, a Color “c” e um novo Point com os valores de onde foi clicado passados como parâmetro. Já chegando na última linha desse thread, flowLayoutPanel1 novamente será ativado para que você possa interagir com ele.
Então iniciamos esse thread com o método Start.

“Mas Hermes, ocorreu uma exceção aqui ao entrar dentro do thread”
Meu jovem maker, isso ocorre, pois, a nosso Thread não pode alterar componentes do Thread da interface gráfica, por isso criaremos um método que faz isso para nós:
Código:
private void UIThread(MethodInvoker code)
{
	if (this.InvokeRequired) this.Invoke(code);
	else code.Invoke();
}
Este método basicamente verifica a interface está requisitando o Invoke e se estiver então ele chama o Invoke de nosso formulário passando nosso código como parâmetro, senão ele chama o Invoke do próprio code. Não irei aqui explicar como o Invoke funciona, leiam esse artigo se quiserem entender melhor.

Voltando para o nosso pictureBox1_MouseDown adicionemos chamadas para o recém-criado UIThread passando delegates com o código da interface.
Código:
case ToolBox.paintBucket:	

	thread = new Thread(() =>
	{
		UIThread(delegate { flowLayoutPanel1.Enabled = false; });
		Bitmap b = pictureBox1.Image as Bitmap;
		Color c = b.GetPixel(e.X, e.Y);
		UIThread(delegate 
		{		
			pictureBox1.Image = PaintBucket(b, c, new Point(e.X, e.Y));
			flowLayoutPanel1.Enabled = true;
		});
	});
	thread.Start();
	break;

14.PNG

E com isso nós encerramos essa introdução, estarei no aguardo de questionamentos e possíveis correções.
 
Voltar
Topo Inferior