Programmation Java Swing/Créer un composant

La bibliothèque Swing propose une riche collection de composants répondant aux besoins les plus courants concernant la création d'une interface graphique. Cependant, certaines applications peuvent avoir besoin de composants spécifiques, qu'il soit basé sur un composant existant (comme une case à cocher à trois états par exemple), ou qu'il ne soit basé sur aucun composant existant. C'est par exemple le cas d'un feu tricolore utilisé dans un jeu sur la circulation routière, qui sera développé dans ce chapitre par étapes.

Création de la classe modifier

La première version de la classe ci-dessous définit les couleurs utilisées et l'état courant du feu tricolore. La méthode statique principale crée une fenêtre pour tester le composant. Le composant n'est pas visible pour le moment avec cette première version du code.

package org.wikibooks.fr.swing.component;

import java.awt.*;

import javax.swing.*;

/**
 * Composant de feu tricolore.
 * @author fr.wikibooks.org
 */
public class FeuTricolore extends JComponent
{
	// Couleurs du feu
	private static final Color// R    V    B
		FEU_ETEINT = new Color( 32,  32,  32),
		FEU_VERT   = new Color(  0, 180,   0),
		FEU_ORANGE = new Color(255, 160,   0),
		FEU_ROUGE  = new Color(255,   0,   0);

	static enum EtatFeu
	{
		ETEINT,
		VERT,
		ORANGE,
		ROUGE
	};

	private EtatFeu etat_courant = EtatFeu.ETEINT;

	public FeuTricolore()
	{
		Dimension d = new Dimension(40,120);
		setMinimumSize(d);
		setPreferredSize(d);
		setBackground(Color.BLACK);
		setForeground(Color.DARK_GRAY);
	}

	// ...

	// Méthode main utilisée pour tester le composant dans une fenêtre
	public static void main(String[] args)
	{
		// Créer la fenêtre
		JFrame f = new JFrame("Feu tricolore");
		f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);

		// Personaliser le panneau du contenu
		JPanel p_content = new JPanel();
		p_content.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
		f.setContentPane(p_content);

		// Ajouter un feu tricolore
		p_content.add(new FeuTricolore());
		
		f.pack();
		f.setVisible(true);
	}
}

État du composant modifier

L'état du composant est représenté par une énumération de type EtatFeu

static enum EtatFeu
{
	ETEINT,
	VERT,
	ORANGE,
	ROUGE
};

private EtatFeu etat_courant = EtatFeu.ETEINT;

On ajoute également des accesseurs permettant de lire et écrire l'état courant du feu :

/**
 * Obtenir l'état courant du feu.
 * @return L'état courant du feu.
 */
public EtatFeu getEtatCourant()
{
	return etat_courant;
}

/**
 * Définir l'état courant du feu.
 * @param etat_courant Le nouvel état courant du feu.
 */
public void setEtatCourant(EtatFeu etat_courant)
{
	this.etat_courant = etat_courant;
	repaint();
}

La méthode de changement d'état appelle la méthode repaint() pour mettre à jour l'affichage aussitôt.

Dessin du composant modifier

Le dessin d'un composant Swing est effectué par la méthode publique void paint(Graphics g). Cette méthode délègue le travail à trois autres méthodes protégées appelées dans l'ordre suivant :

void paintComponent(Graphics g)
Affiche le contenu du composant lui-même.
void paintBorder(Graphics g)
Affiche les bords du composant dans la zone définie par les marges de type Insets.
void paintChildren(Graphics g)
Affiche les composants contenus dans celui-ci, par appel à leur méthode paint.

Cet ordre assure que les composants contenus soit affichés au-dessus du composant. Pour définir l'apparence du composant, une sous-classe ne doit redéfinir que la méthode void paintComponent(Graphics g). Celle-ci doit éviter de dessiner dans la zone définie par les marges de type Insets qui est réservée aux bords (Border).

 

Il existe aussi une méthode void paintComponents(Graphics g) permettant de redessiner les composants contenus, au lieu du composant. Si le composant n'affiche rien, vérifiez qu'il n'y a pas de 's' à la fin du nom de la méthode. Cette erreur peut arriver facilement avec un IDE proposant la complétion automatique.

Le code ci-dessous ajoute la méthode de dessin du feu tricolore. Elle utilise un tableau à deux dimensions définissant la couleur de chaque feu pour chaque état possible.

// Couleur des feux selon l'état courant
private Color[][] COULEUR_FEUX = // [état][feu]
{
	{ FEU_ETEINT, FEU_ETEINT, FEU_ETEINT }, // ETEINT
	{ FEU_ETEINT, FEU_ETEINT, FEU_VERT   }, // VERT
	{ FEU_ETEINT, FEU_ORANGE, FEU_ETEINT }, // ORANGE
	{ FEU_ROUGE,  FEU_ETEINT, FEU_ETEINT }, // ROUGE
};

@Override
protected void paintComponent(Graphics g)
{
	Dimension d = getSize();
	// Le fond
	g.setColor(getBackground());
	g.fillRect(0, 0, d.width, d.height);

	// Dimension d'un feu selon la taille du composant
	int fw = d.width, fh = d.height/3;
	// ...de forme carrée :
	if (fw>fh) fw=fh; else fh = fw;

	int dm = fw*3/4, // Diamètre : 75% de la taille disponible
		ecart = fw-dm, // Décalage de 25% = 100%-75%
		// Position centrée avec décalage
		x = (d.width  - fw   + ecart)/2,
		y = (d.height - 3*fh + ecart)/2;

	// Les trois feux de haut en bas
	Color[] couleurs = COULEUR_FEUX[etat_courant.ordinal()];
	for(int i=0 ; i<3 ; i++,y+=fh)
	{
		g.setColor(couleurs[i]);
		g.fillOval(x, y, dm, dm);
		g.setColor(getForeground());
		g.drawOval(x, y, dm-1, dm-1); // dm-1 pour ne pas déborder
	}
}

Gestion des évènements modifier

Dans l'état actuel du code, le feu affiche l'état courant initial éteint. Cette section définit le code pour changer l'état courant sur réception d'un évènement de clic de souris.

On ajoute la méthode qui met l'état suivant selon l'état courant :

protected void cycleEtatCourant()
{
	switch(etat_courant)
	{
	case ETEINT: setEtatCourant(EtatFeu.ROUGE);  break;
	case VERT:   setEtatCourant(EtatFeu.ORANGE); break;
	case ORANGE: setEtatCourant(EtatFeu.ROUGE);  break;
	case ROUGE:  setEtatCourant(EtatFeu.VERT);   break;
	}
}

Cette méthode est appelée par le gestionnaire de clic de souris ajouté au composant dans le constructeur de la classe :

addMouseListener(new MouseAdapter()
{
	@Override
	public void mouseClicked(MouseEvent e)
	{
		cycleEtatCourant();
	}
});

Améliorations modifier

La classe peut être améliorée sur divers points abordés dans cette section.

Changement d'état modifier

 
Les 3 feux tricolores orchestrés par un gestionnaire d'état

Afin que le feu ait un comportement plus proche de la réalité, le changement d'état du feu devrait se faire sans intervention de l'utilisateur, au bout d'un certain temps dépendant de l'état courant.

Tout d'abord, afin d'éviter de réafficher le composant trop souvent, la méthode de changement d'état est modifiée comme ci-dessous :

public void setEtatCourant(EtatFeu etat_courant)
{
	// Seulement si changement d'état
	if (etat_courant != this.etat_courant)
	{
		this.etat_courant = etat_courant;
		repaint();
	}
}

On supprime donc le changement d'état par clic de souris, c'est à dire le code ajouté dans la section « Gestion des évènements » :

  • Supprimer la méthode cycleEtatCourant(),
  • Et supprimer l'appel à la méthode addMouseListener dans le constructeur.

La gestion d'un croisement implique plusieurs feux, affectés à différentes voies de circulation (à double sens ou non). Il est donc préférable que le changement d'état d'un feu soit géré par une nouvelle classe afin de synchroniser les feux du carrefour. Il faut donc déplacer l'énumération dans une classe à part :

package org.wikibooks.fr.swing.component;

public enum EtatFeu
{
	ETEINT,
	VERT,
	ORANGE,
	ROUGE
}

Ensuite, il faut créer une nouvelle classe qui sera nommée GestionnaireFeu, qui va gérer une liste de feux par voie de circulation, définir la voie de circulation active (cycle rouge-vert-orange-rouge) les autres étant inactives (feu rouge), et définir un thread mettant à jour le cycle courant :

package org.wikibooks.fr.swing.component;

import java.util.*;

/**
	Gestionnaire de l'état des feux tricolores d'un carrefour.
	<h3>Fonctionnement</h3>
	Une seule voie est active à la fois, les autres
	voies ont le feu au rouge.
	La voie active effectue un cycle où le feu prend
	successivement les couleurs suivantes :<ul>
	<li>rouge pendant marge_duree_rouge cycles élémentaires,</li>
	<li>vert pendant duree_vert cycles élémentaires,</li>
	<li>orange pendant duree_orange cycles élémentaires,</li>
	<li>rouge pendant marge_duree_rouge cycles élémentaires.</li>
	</ul>
	À la fin de ce cycle, la voie devient inactive,
	et la voie suivante devient active.
 * @author fr.wikibooks.org
 */
public class GestionnaireFeu
{
	private int duree_cycle_elementaire = 1000; // ms

	/** Couleur de feu courante pour la voie active. */
	private EtatFeu etat_voie_active = EtatFeu.ROUGE; // Valeur initiale pour la sécurité
	/** Index de la voie active. */
	private int voie_active = 0;
	/** Liste des feux pour chaque voie. */
	private ArrayList<FeuTricolore>[] feux_par_voie;

	private final int duree_vert, duree_orange, marge_duree_rouge;
	private final int total_cycle, total_period;

	private final Object lock = new Object();

	private void boucleCycleFeux()
	{
		try
		{
			long time = System.currentTimeMillis(), t;
			int index_cycle = 0;
			for(;;)
			{
				// Mise à jour de l'état
				voie_active = index_cycle / total_cycle;
				int n = index_cycle % total_cycle;
				if (n < marge_duree_rouge) etat_voie_active = EtatFeu.ROUGE;
				else
				{
					n -= marge_duree_rouge;
					if (n < duree_vert) etat_voie_active = EtatFeu.VERT;
					else
					{
						n -= duree_vert;
						if (n < duree_orange) etat_voie_active = EtatFeu.ORANGE;
						else etat_voie_active = EtatFeu.ROUGE;
					}
				}
				
				// Mise à jour des feux
				synchronized(lock)
				{
					for(int voie=0 ; voie<feux_par_voie.length; voie++)
					{
						EtatFeu etat = voie==voie_active ? etat_voie_active : EtatFeu.ROUGE;
						for(FeuTricolore feu : feux_par_voie[voie])
							feu.setEtatCourant(etat);
					}
				}

				// Cycle élémentaire suivant
				index_cycle = (index_cycle+1) % total_period;

				// Attendre jusqu'à l'heure du prochain cycle élémentaire
				time += duree_cycle_elementaire;
				t = System.currentTimeMillis();
				if (t<time) Thread.sleep(time - t);
			}
		}
		catch(InterruptedException ex)
		{ /* Thread interrompu */ }
	}

	public void add(int voie, FeuTricolore feu)
	{
		if (voie<0 || voie>=feux_par_voie.length)
			throw new IllegalArgumentException("Voie non valide : "+voie);
		if (feu == null)
			throw new NullPointerException("Pas de feu tricolore spécifié");

		synchronized(lock)
		{
			feux_par_voie[voie].add(feu);
			EtatFeu etat = voie==voie_active ? etat_voie_active : EtatFeu.ROUGE;
			feu.setEtatCourant(etat);
		}
	}

	public GestionnaireFeu()
	{
		this(2);
	}

	public GestionnaireFeu(int nombre_de_voies)
	{
		this(nombre_de_voies, 8, 2, 1);
	}

	/**
	 * Construire un gestionnaire de feux.
	 * @param nombre_de_voies Nombre de voies de circulation.
	 * @param duree_vert Durée du feu au vert en nombre de cycles élémentaires (1 seconde par défaut).
	 * @param duree_orange Durée du feu au orange en nombre de cycles élémentaires (1 seconde par défaut)
	 * @param marge_duree_rouge Marge de durée du feu au rouge en nombre de cycles élémentaires (1 seconde par défaut)
	 */
	public GestionnaireFeu(int nombre_de_voies,
		int duree_vert, int duree_orange, int marge_duree_rouge)
	{
		if (nombre_de_voies<2 ||
			duree_vert<1 ||
			duree_orange<1 ||
			marge_duree_rouge<0)
			throw new IllegalArgumentException("Un argument est invalide");

		this.duree_vert = duree_vert;
		this.duree_orange = duree_orange;
		this.marge_duree_rouge = marge_duree_rouge;
		this.total_cycle = 2*marge_duree_rouge + duree_vert + duree_orange;
		this.total_period = this.total_cycle * nombre_de_voies;

		feux_par_voie = new ArrayList[nombre_de_voies];
		for(int i=0 ; i<feux_par_voie.length ; i++)
			feux_par_voie[i] = new ArrayList<FeuTricolore>();

		// Le thread mettant à jour les feux en tâche de fond :
		Thread th = new Thread(new Runnable()
		{
			@Override
			public void run()
			{ boucleCycleFeux(); }
		}, "Gestion des feux tricolores");
		th.setDaemon(true); // Ne maintient pas l'application en vie.
		th.start();
	}
}

La méthode statique de lancement permettant de montrer trois feux sur trois voies de circulation différentes est la suivante :

public static void main(String[] args)
{
	// Créer la fenêtre
	JFrame f = new JFrame("Feu tricolore");
	f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);

	// Personnaliser le panneau du contenu
	JPanel p_content = new JPanel();
	p_content.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
	p_content.setLayout(new FlowLayout(FlowLayout.CENTER, 20, 20));
	f.setContentPane(p_content);

	int nb_voies = 3;
	GestionnaireFeu gestionnaire = new GestionnaireFeu(nb_voies);
	// Ajouter un feu tricolore par voie
	for(int i=0 ; i<nb_voies ; i++)
	{
		FeuTricolore feu = new FeuTricolore();
		p_content.add(feu);
		gestionnaire.add(i, feu);
	}
	
	f.pack();
	f.setVisible(true);
}

Les cycles des feux sur les trois voies pour les temps par défaut sont résumés dans le tableau ci-dessous :

Voie 1 active Voie 2 active Voie 3 active
Voie 1
Voie 2
Voie 3

Qualité de l'affichage du composant modifier

Le dessin circulaire du composant montre un crénelage causé par le tracé pixel par pixel. Il est possible d'améliorer l'affichage en utilisant l'antialiasing, en modifiant légèrement le code.

Tout d'abord, il faut définir les propriétés de rendu dans un dictionnaire utilisant des clés de la classe RenderingHints. La partie de code ci-dessous est à ajouter dans la classe FeuTricolore, et montre les clés modifiables. Celles sur l'affichage du texte (KEY_TEXT_ANTIALIASING, KEY_FRACTIONALMETRICS) ne sont pas nécessaires pour cette classe mais illustrent les possibilités.

protected static final HashMap<Key, Object> QUALITY_HINTS =
	new HashMap<RenderingHints.Key, Object>();
static
{
	QUALITY_HINTS.put(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
//	QUALITY_HINTS.put(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_GASP);
	QUALITY_HINTS.put(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
	QUALITY_HINTS.put(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
	QUALITY_HINTS.put(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
	QUALITY_HINTS.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
	QUALITY_HINTS.put(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_NORMALIZE);
	QUALITY_HINTS.put(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}

On peut aussi améliorer le tracé en utilisant une plus grande épaisseur de trait. Les paramètres de trait sont gérés par la classe abstraite Stroke, dont la classe BasicStroke est une implémentation.

private Stroke STYLE_TRAIT = new BasicStroke(2); // Épaisseur : 2 pixels
 
Les 3 feux tricolores avec une meilleure qualité de rendu

Il ne reste plus qu'à utiliser ces éléments dans la méthode paintComponent. Cependant ils ne sont utilisables qu'avec une instance de la classe Graphics2D, qui est la classe réelle de l'objet Graphics passé à la méthode. Il faut donc convertir l'objet pour définir les options de rendu et le style des traits.

@Override
protected void paintComponent(Graphics graphics)
{
	Graphics2D g = (Graphics2D)graphics;
	Dimension d = getSize();
	g.addRenderingHints(QUALITY_HINTS);
	g.setStroke(STYLE_TRAIT);

// ... la suite reste inchangée, identique à la version précédente.
}

Préservation du contexte graphique modifier

La méthode paint étant implémentée par appels à d'autres méthodes, elle leur passe le même contexte graphique de classe Graphics2D. Cela signifie que le dernier changement de paramètre (couleur, police de caractère, style de trait, région clip, ...) est celui par défaut pour la méthode appelée après. Il est donc recommandé de restaurer les valeurs d'origine avant de terminer. Cependant, cela implique de lire la valeur au début de la méthode pour la restaurer à la fin, ce qui complique le codage vu le nombre possible de paramètres auquel cela s'applique.

La solution est de créer un contexte dérivé de celui passé en paramètre, et de ne plus utiliser l'original ensuite. Il faudra cependant libérer les ressources prises par le nouveau contexte en appelant sa méthode dispose() à la fin.

@Override
protected void paintComponent(Graphics graphics)
{
	Graphics2D g = (Graphics2D)graphics.create();
	try
	{
		Dimension d = getSize();
		g.addRenderingHints(QUALITY_HINTS);
		g.setStroke(STYLE_TRAIT);

	// ... la suite reste inchangée, identique à la version précédente, indentée d'un cran.
	}
	finally
	{
		g.dispose();
	}
}

Orientation du composant modifier

Le feu tricolore utilisé dans une simulation de circulation réaliste avec vue de dessus d'un carrefour devrait pouvoir être positionné horizontalement de haut en bas et inversement, ou verticalement de droite à gauche et inversement.


Génération d'évènements spécifiques modifier

Dans une utilisation réaliste du composant, le feu tricolore utilisé pour simuler la circulation devrait être capable de notifier ses changements d'états aux automobilistes pour qu'ils puissent s'arrêter ou redémarrer.