Le souci
J'ai récemment du implémenter le dessin d'une grille (comme dans l'éditeur de Visual Studio par exemple) sur un contrôle de type Panel. Innocemment, mon premier essai a été de m'abonner à l'évènement OnPaint du contrôle, et d'ajouter le code suivant:
private void Foo_Paint(object sender, PaintEventArgs e)
{
using (Pen pen = new Pen(Color.Gray, 1))
for (int x = 10; x < e.ClipRectangle.Width; x += 10)
for (int y = 10; y < e.ClipRectangle.Height; y += 10)
e.Graphics.DrawRectangle(pen, new Rectangle(new Point(x, y), new Size(1, 1)));
}
Je lance l'application, j'ai bien une grille qui est dessinée. Souci: dès que la form est rafraichie (quand on la redimensionne, ou qu'on change les propriétés d'un de ses contrôles enfants), la grille est redessinée, ce qui entraine de violentes lenteurs de l'affichage, avec du bon gros flickering.
La solution pour éviter ce souci est d'utiliser la technique du double-buffering.
La solution
Nous allons implémenter cette stratégie du double-buffer en créant une nouvelle classe, qui prendra en paramètre le contrôle à utiliser, ce qui nous permettra de réutiliser facilement et à moindre frais le code. L'idée est d'utiliser les classes BufferedGraphicsContext et BufferedGraphics pour dessiner l'arrière-plan du contrôle dans un buffer, et quand le contrôle doit se redessiner, on applique le contenu du buffer directement dans le contrôle. Et c'est donc en évitant le dessin progressif de l'arrière-plan du contrôle que l'on va éviter le scintillement.
Mais voici tout de suite la première version de notre classe:
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
namespace Tools.GUI
{
public class DoubleBuffer : IDisposable
{
private readonly Control control;
private bool dirty;
private BufferedGraphicsContext bufferedGraphicsContext;
private BufferedGraphics bufferedGraphics;
public DoubleBuffer(Control control)
{
this.control = control;
bufferedGraphicsContext = new BufferedGraphicsContext();
CreateGraphicsBuffer();
this.control.SizeChanged += control_SizeChanged;
}
void control_SizeChanged(object sender, EventArgs e)
{
CreateGraphicsBuffer();
}
private void CreateGraphicsBuffer()
{
if (bufferedGraphics != null)
{
bufferedGraphics.Dispose();
bufferedGraphics = null;
}
if (bufferedGraphicsContext == null || control.DisplayRectangle.Width <= 0 || control.DisplayRectangle.Height <= 0)
return;
using (Graphics graphics = control.CreateGraphics())
bufferedGraphics = bufferedGraphicsContext.Allocate(graphics, control.DisplayRectangle);
Dirty = true;
}
private bool Dirty
{
get { return dirty; }
set
{
if (!value)
return;
dirty = true;
control.Invalidate();
}
}
public void Dispose()
{
if (bufferedGraphics != null)
{
bufferedGraphics.Dispose();
bufferedGraphics = null;
}
if (bufferedGraphicsContext != null)
{
bufferedGraphicsContext.Dispose();
bufferedGraphicsContext = null;
}
}
}
}
On commence par créer une nouvelle instance de la classe BufferedGraphicsContext, puis on crée l'instance de BufferedGraphics grâce à l'appel de la fonction CreateGraphicsBuffer, où l'on commence par supprimer l'instance existante, puis où l'on crée effectivement la nouvelle instance grâce à un appel à la fonction Allocate de BufferedGraphicsContext, qui prend en paramètre le Graphics du contrôle, ainsi que le rectangle correspondant à la surface affichée du contrôle. Vous aurez remarqué que l'on ne crée pas le buffer si le contrôle a une largeur ou une hauteur égale à zéro.
Comme on a un nouveau buffer, on met le flag Dirty à true, qui aura pour conséquence de forcer un ré-affichage du contrôle via l'appel de Invalidate() (dans le setter de Dirty). Dans le constructeur de la classe, on s'abonne également à l'évènement SizeChanged du contrôle, car nous devons toujours avoir un buffer qui ait les mêmes dimensions que le contrôle.
C'est bien beau, mais nous n'affichons toujours rien pour le moment. Nous allons corriger ça de suite, on commençant par nous abonner à l'évènement OnPaint du contrôle, dans le constructeur de la classe:
public DoubleBuffer(Control control)
{
this.control = control;
bufferedGraphicsContext = new BufferedGraphicsContext();
CreateGraphicsBuffer();
this.control.SizeChanged += control_SizeChanged;
this.control.Paint += control_Paint;
}
Nous allons ensuite définir la fonction control_Paint:
void control_Paint(object sender, PaintEventArgs e)
{
if (bufferedGraphics == null)
{
Draw(e.Graphics);
return;
}
if (Dirty)
{
Dirty = false;
Draw(bufferedGraphics.Graphics);
}
bufferedGraphics.Render(e.Graphics);
}
Le fonctionnement du double buffer se fait ici: si on n'a pas de buffer (par exemple, le contrôle a sa largeur ou sa hauteur qui est nulle) , on va dessiner le contenu de l'arrière-plan du contrôle normalement, en passant à la fonction Draw le Graphics du contrôle, auquel on accède grâce à la propriété Graphics de PaintEventArgs. Si par contre on a un buffer, on commence par regarder l'état du flag Dirty. S'il est à true, c'est que l'on doit redessiner le buffer, ce qui est fait en passant la propriété Graphics de la classe BufferedGraphics. Et pour terminer, on appelle la fonction Render de BufferedGraphics, qui va écrire le contenu du buffer directement dans l'objet Graphics donné en paramètre, c'est à dire dans le Graphics du contrôle, provoquant donc l'affichage du contrôle.
Nous faisons mention dans le code précédent d'une fonction Draw, que voici:
private void Draw(Graphics graphics)
{
if (control.ClientRectangle.Width <= 0 || control.ClientRectangle.Height <= 0)
return;
using (SolidBrush backBrush = new SolidBrush(control.BackColor))
graphics.FillRectangle(backBrush, control.ClientRectangle);
}
Tout ce que fait cette fonction pour le moment, c'est juste de remplir l'arrière-plan du contrôle avec la couleur définie par la propriété BackColor. Pas très intéressant donc. Il serait bien de pouvoir définir des fonctions à exécuter dans la fonction Draw, afin de personnaliser l'affichage, et de profiter du double buffering mis en place. Nous allons arriver à ça en utilisant une liste d'actions, qui seront exécutées à la fin de la fonction Draw. Les actions étant enregistrées par l'utilisateur de la class DoubleBuffer via une fonction RegisterPaintAction.
Nous allons commencer par ajouter un nouveau membre à notre classe:
private readonly List<Action<Graphics, Rectangle>> paintDelegates = new List<Action<Graphics, Rectangle>>();
Puis nous exécutons la liste des actions dans la fonction Draw:
private void Draw(Graphics graphics)
{
if (control.ClientRectangle.Width <= 0 || control.ClientRectangle.Height <= 0)
return;
using (SolidBrush backBrush = new SolidBrush(control.BackColor))
graphics.FillRectangle(backBrush, control.DisplayRectangle);
paintDelegates.ForEach(action => action(graphics, control.DisplayRectangle));
}
Et nous créons les fonctions d'enregistrement et de dés-enregistrement des actions (utile pour par exemple afficher ou pas la grille):
public void RegisterPaintAction(Action paintDelegate)
{
if (paintDelegate == null)
return;
paintDelegates.Add(paintDelegate);
Dirty = true;
}
public void UnregisterPaintDelegate(Action paintDelegate)
{
if (paintDelegates.Remove(paintDelegate))
Dirty = true;
}
On met le flag Dirty à true quand on ajoute une nouvelle action pour forcer un ré-affichage du contrôle immédiat.
Et voilà! Et pour utiliser tout ça, un petit exemple d'affichage de grille dans l'arrière-plan de la form:
using System.Drawing;
using System.Windows.Forms;
namespace WindowsFormsApplication1
{
public partial class Form1 : Form
{
private readonly DoubleBuffer doubleBuffer;
public Form1()
{
InitializeComponent();
doubleBuffer = new DoubleBuffer(this);
doubleBuffer.RegisterPaintAction((graphics, rectangle) =>
{
using (Pen pen = new Pen(Color.Gray, 1))
for (int x = 10; x < rectangle.Width; x += 10)
for (int y = 10; y < rectangle.Height; y += 10)
graphics.DrawRectangle(pen, new Rectangle(new Point(x, y), new Size(1, 1)));
});
}
}
}
C'est tout pour cette fois ;) N'hésitez pas à me laisser vos impressions ou vos retours sur ce code!
PS: je joins à cet article le code complet de la classe DoubleBuffer.