Flame Maker – Etape 5 – Affichage via Swing

Introduction

Le but de cette étape est de commencer l'interface utilisateur en réalisant deux composants graphiques : celui qui affiche la fractale, et celui qui affiche les parties affines des transformations. A la fin de cette étape, votre programme devrait ouvrir une fenêtre ressemblant à celle de la copie d'écran ci-dessous.

images/stage5.png

L'interface graphique de base

Pour réaliser l'interface graphique de Flame Maker, vous utiliserez la bibliothèque Java nommée Swing, présentée brièvement ci-dessous.

Swing

Introduction

Swing est la bibliothèque standard pour réaliser des interfaces graphiques en Java, et elle se base sur une bibliothèque plus ancienne nommée AWT (pour Abstract Window Toolkit). Ces deux bibliothèques sont assez grosses et complexes, et leur organisation laisse malheureusement parfois à désirer pour des raisons historiques.

Seules les notions de base nécessaires au projet sont présentées ici. Une bonne source d'information complémentaire est The Swing Tutorial (en anglais). Des références vers les parties pertinentes de ce texte sont données plus bas.

Composants

Une interface Swing est composée d'un certain nombre d'éléments, nommés composants (components), qui ont pour la plupart une représentation graphique et avec lesquels il est parfois possible d'interagir. Par exemple, les fenêtres, les menus, les listes déroulantes, les boutons, les champs textuels, etc. sont des composants. Dans Swing, les composants sont généralement modélisés par des classes qui héritent de la classe JComponent.

Swing offre un certain nombre de composants prédéfinis, comme ceux mentionnés dans le paragraphe précédent. Mais il est également possible d'en définir de nouveaux en héritant de la classe JComponent et en redéfinissant un certain nombre de méthodes.

Organisation hiérarchique

Les composants d'une interface sont organisés de manière hiérarchique, dans ce qu'on appelle en anglais la containment hierarchy. C'est-à-dire qu'il existe certains composants de base, principalement les fenêtres, qui peuvent contenir d'autres composants. Ces composants peuvent à leur tour en contenir d'autres, et ainsi de suite jusqu'aux composants terminaux.

On distingue donc généralement deux types de composants :

  1. Les conteneurs (containers), dont le but principal est de contenir d'autres composants. Généralement, leur représentation graphique est très simple voire inexistante. Les fenêtres sont un exemple de conteneur, un autre est le panneau (panel ou parfois pane), modélisé par la classe JPanel, très utilisé pour organiser les interfaces mais souvent invisible à l'écran car dénué de représentation graphique propre.
  2. Les composants terminaux, qui ne peuvent pas en contenir d'autres et qui terminent donc la hiérarchie. On les nomme parfois « feuilles » car ils se situent aux feuilles de l'arbre que forme la hiérarchie. Ces composants terminaux ont généralement une représentation graphique et il est parfois possible d'interagir avec eux. Les boutons sont un exemple de composant terminal, un autre est l'étiquette (label), modélisée par la classe JLabel.

Les conteneurs possèdent entre autres une méthode, add, qui permet de leur ajouter des composants fils. La position de ces composants dans le conteneur est déterminée de manière automatique par un gestionnaire de mise en page, décrit ci-dessous.

Référence : Using Top-Level Containers

Gestionnaires de mise en page

A chaque conteneur est attaché un gestionnaire de mise en page (layout manager), qui se charge de placer et de dimensionner les composants du conteneur dans celui-ci. Chaque gestionnaire de mise en page a une stratégie de placement des composants qui lui est propre. Ainsi, certains gestionnaires sont très simples et se contentent de placer les composants côte-à-côte, tandis que d'autres sont plus complexes et placent les composants de manière à satisfaire certaines contraintes.

Avec Swing, les gestionnaires de mise en page implémentent l'interface LayoutManager. Pour cette étape, vous utiliserez deux gestionnaires de mise en page concrets, BorderLayout et GridLayout.

Références : A Visual Guide to Layout Managers et Using Layout Managers.

Le gestionnaire GridLayout

Le gestionnaire de mise en page GridLayout découpe l'espace du conteneur auquel il est attaché en une grille composée d'un certain nombre de lignes et de colonnes. Cette grille occupe la totalité de l'espace du conteneur, et chaque cellule de la grille a la même taille que les autres. Le nombre de lignes et de colonnes de la grille sont des paramètres de ce type de gestionnaire de mise en page, et lui sont passés lors de sa construction. Les composants ajoutés sont placés dans les cellules successives de la grille, en commençant par celle située en haut à gauche.

L'image ci-dessous montre comment un gestionnaire de mise en page de type GridLayout placerait successivement des composants dans son conteneur, à supposer qu'on l'ait initialisé avec 2 lignes et 3 colonnes.

images/GridLayout.png

Mise en page de composants par un gestionnaire de type GridLayout

Référence : How to Use GridLayout

Le gestionnaire BorderLayout

Le gestionnaire de mise en page BorderLayout découpe l'espace du conteneur auquel il est attaché en 5 zones ayant chacune un nom, comme illustré dans la figure ci-dessous.

images/BorderLayout.png

Découpage d'un conteneur en zones par un gestionnaire BorderLayout

Pour ajouter un composant à un conteneur dont le gestionnaire de mise en page est une instance de BorderLayout, on utilise une variante de la méthode add, qui prend un argument supplémentaire donnant l'identité de la zone dans laquelle le composant doit être placé. La classe BorderLayout contient des champs statiques nommés comme les zones et contenant leur identité. Par exemple, le morceau de code suivant attache un BorderLayout à un conteneur puis y place le composant child dans la zone CENTER :

JComponent container = /* ... */;
JComponent child = /* ... */;

container.setLayout(new BorderLayout());
container.add(child, BorderLayout.CENTER);

Chacune des cinq zones peut contenir au plus un composant. Toutes les zones sont dimensionnées de manière à être assez grande pour le composant qu'elles contiennent, et les zones vides ont donc une taille nulle. S'il reste de la place dans le conteneur, la zone CENTER est agrandie jusqu'à ce que tout l'espace du conteneur soit occupé.

Référence : How to Use BorderLayout

Bordures

Certains conteneurs — en particulier les panneaux (JPanel) — peuvent être ornés d'une bordure. Il existe plusieurs sortes de bordures, mais les classes qui les modélisent implémentent toutes l'interface Border. On attache une bordure à un conteneur en appelant sa méthode setBorder, qui prend la bordure en argument.

Une particularité des bordures est que l'on ne les créé pas au moyen d'un énoncé new. Au lieu de cela, on passe par des méthodes de construction, qui sont des méthodes statiques de la classe BorderFactory. Par exemple, pour obtenir une bordure avec le titre Fractale du même genre que celle qui entoure la fractale dans notre programme, puis pour l'attacher au panneau fracPanel, on procède ainsi :

JPanel fracPanel = /* ... */;
Border fracBorder = BorderFactory.createTitledBorder("Fractale");
fracPanel.setBorder(fracBorder);

A noter qu'en dépit de la similarité des noms, il n'y a aucun rapport entre les bordures modélisées par l'interface Border et le gestionnaire de mise en page BorderLayout.

Référence : How to Use Borders

Programme principal

Introduction

Le programme principal, qui se charge de construire et démarrer l'interface utilisateur, est modélisé par les deux classes suivantes :

  • FlameMaker, qui est la classe principale du programme, c-à-d celle qui contient la méthode main.
  • FlameMakerGUI, qui contient la totalité du code de l'interface utilisateur graphique (graphical user interface ou GUI en anglais).

Comme toutes les classes et interfaces de cette étape, ces deux classes appartiennent au paquetage ch.epfl.flamemaker.gui. Leur fonctionnement exact est détaillé ci-dessous.

Classe principale

La classe FlameMaker ne contient rien d'autre que la méthode principale du programme, main. Cette méthode crée une instance de la classe FlameMakerGUI puis appelle sa méthode start afin de construire et démarrer l'interface utilisateur.

Pour une raison qui ne sera pas détaillée ici1, l'appel à cette méthode start ne peut se faire directement depuis la méthode main : il faut passer par un objet de type Runnable que l'on fournit à la méthode invokeLater de Swing. La méthode main a donc la forme suivante :

public static void main(String[] args) {
    javax.swing.SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                new FlameMakerGUI().start();
            }
        });
}

Classe de l'interface graphique

La classe FlameMakerGUI représente l'interface graphique du programme. Elle possède entre autres des champs privés contenant l'état du programme, à savoir : le bâtisseur de fractale (de type Flame.Builder), la couleur de fond, la palette, le cadre de dessin et la densité. Pour l'instant, tous ces champs sont initialisés avec des valeurs par défaut que vous êtes libres de choisir. Les copies d'écran données dans les énoncés ont été obtenues avec un bâtisseur pour la fractale Shark Fin, un fond noir, une palette interpolant entre le rouge, le vert et le bleu, le cadre centré en (-0.25,0) de largeur 5 et hauteur 4, et une densité de 50.

La classe FlameMakerGUI possède de plus la méthode start mentionnée plus haut, qui construit et démarre l'interface graphique. Son fonctionnement est décrit dans les sections suivantes.

Fenêtre principale

La première chose que la méthode start doit faire est de créer la fenêtre principale du programme et l'afficher à l'écran.

Dans Swing, les fenêtres sont des instances de la classe JFrame. Pour afficher une telle fenêtre à l'écran, il faut suivre les étapes suivantes :

  1. Créer une instance de JFrame en lui passant le titre de la fenêtre en argument.
  2. Configurer l'action à effectuer lorsque la fenêtre est fermée, au moyen de la méthode setDefaultCloseOperation. Généralement, on demande à ce que le programme principal soit terminé lorsque la fenêtre est fermée, ce qui se fait en passant la constante EXIT_ON_CLOSE à cette méthode.
  3. Ajouter du contenu à la fenêtre (voir ci-dessous).
  4. Dimensionner correctement la fenêtre en appelant sa méthode pack, qui s'assure que sa taille est appropriée à son contenu.
  5. Rendre la fenêtre visible en appelant la méthode setVisible avec la valeur vraie en argument.

Le morceau de programme ci-dessous suit ces étapes dans l'ordre, en ne mettant rien d'autre dans la fenêtre qu'une étiquette (une instance de JLabel) avec un texte de bienvenue.

JFrame frame = new JFrame("Flame Maker");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

JLabel label = new JLabel("  Bienvenue dans Flame Maker !  ");
frame.getContentPane().setLayout(new BorderLayout());
frame.getContentPane().add(label, BorderLayout.CENTER);

frame.pack();
frame.setVisible(true);

La fenêtre résultant de ce morceau de programme est présentée dans la figure ci-dessous.

images/stage5_step1.png

La fenêtre principale, presque vide

Contenu de la fenêtre

L'image ci-dessous illustre la hiérarchie des conteneurs pour cette étape. Le nom de la classe de chaque conteneur est indiquée sur celui-ci, suivi du nom de la classe de son gestionnaire de mise en page, entre parenthèses (BorderLayout étant parfois abrégé BorderL.).

images/containment_hierarchy.png

Hiérarchie des conteneurs de cette étape avec leur gestionnaire de mise en page

Le conteneur nommé « panneau de contenu » est créé automatiquement par Swing, raison pour laquelle il est grisé sur l'image. Quant au conteneur nommé « panneau supérieur », il n'est pas strictement nécessaire pour cette étape, car les deux panneaux du sommet pourraient également être placés directement dans le panneau de contenu de la fenêtre, mais il sera utile lors des étapes suivantes.

Attention, seuls les conteneurs sont représentés sur cette image ! Les deux composants que vous réaliserez plus bas et qui affichent respectivement les transformations affines et la fractale, seront placés chacun dans un des deux panneaux du sommet de l'image.

Pour écrire le code qui crée cette hiérarchie, il faut bien entendu construire des instances de JPanel pour les trois panneaux du sommet de l'image, en utilisant le constructeur sans arguments. Ensuite, il convient de leur attacher le bon gestionnaire de mise en page, au moyen de la méthode setLayout. Finalement, il faut construire la hiérarchie elle-même en plaçant les conteneurs les uns dans les autre, au moyen d'une des deux variantes de la méthode add, que l'on appelle sur le conteneur auquel on veut rajouter un composant :

  • void add(JComponent comp), qui ajoute le composant passé comme fils du composant récepteur.
  • void add(JComponent comp, Object constraint), qui ajoute le composant passé comme fils du composant récepteur, en passant le second argument à son gestionnaire de mise en page. Comme illustré plus haut, ce second argument est p.ex. utilisé par BorderLayout pour savoir dans quelle zone placer le composant.

Il faut de plus savoir que le panneau de contenu d'une fenêtre s'obtient au moyen de la méthode getContentPane.

Une fois la hiérarchie ci-dessus créée, en ajoutant des bordures avec titre (créées au moyen de BorderFactory.createTitledBorder comme illustré plus haut) et en plaçant des étiquettes (JLabel) à l'intérieur des deux panneaux aux feuilles de la hiérarchie, on obtient une fenêtre ressemblant à celle de l'image ci-dessous.

images/stage5_step2.png

La fenêtre principale avec des étiquettes bouche-trou

Composants sur mesure

Introduction

Swing offre un certain nombre de composants standards, mais il n'en existe évidemment aucun permettant d'afficher une fractale Flame ou un ensemble de transformations affines. Le second but de cette étape est donc d'écrire ces deux composants, puis de les placer dans les panneaux construits précédemment.

Chacun de ces composants est défini dans une sous-classe de JComponent. Cette classe possède un très grand nombre de méthodes, mais seules quelques-unes sont utiles ici, comme getWidth et getHeight qui permettent d'obtenir les dimensions du composant.

Certaines des méthodes de JComponent doivent être redéfinies dans les sous-classes pour mettre en œuvre le comportement spécifique du composant. Pour cette étape, les deux méthodes à redéfinir sont :

  • Dimension getPreferredSize(), qui retourne la dimension idéale pour le composant. Cette taille est utilisée par certains gestionnaires de mise en page (malheureusement pas tous) afin de s'assurer que le conteneur a une taille suffisante. Vous pouvez par exemple retourner une dimension de 200 par 100.
  • void paintComponent(Graphics g0), qui est appelée par Swing chaque fois que le composant doit être redessiné, p.ex. suite à un redimensionnement. L'argument de cette méthode est le contexte graphique, grâce auquel il est possible d'effectuer le dessin. Pour des raisons historiques, cet argument est déclaré comme ayant le type Graphics, mais en réalité la valeur passée est toujours de type Graphics2D, type qui offre beaucoup plus de méthodes que Graphics. Une méthode paintComponent typique commence donc généralement par faire un transtypage de cette valeur vers le type Graphics2D.

Le gros du travail pour terminer cette étape est donc d'écrire les méthodes paintComponent des deux composants spécifiques au projet. Notez que le système de coordonnées des composants a son origine dans le coin haut-gauche, et que l'axe des ordonnées est dirigé vers le bas.

Composant de la fractale

Le premier composant à réaliser, nommé p.ex. FlameBuilderPreviewComponent, est celui affichant la fractale en cours d'édition.

Le constructeur de ce composant prend en argument toutes les données nécessaires au dessin de la fractale, à savoir : le bâtisseur de la fractale, la couleur de fond, la palette, le cadre et la densité. A noter que la raison pour laquelle on passe le bâtisseur de fractale et pas la fractale elle-même à ce composant deviendra claire lors des étapes suivantes.

Le dessin de la fractale se fait dans la méthode paintComponent. On utilise pour cela une technique très similaire à celle de l'étape précédente. La différence est qu'au lieu de produire un fichier, il faut produire une image qu'on affiche ensuite dans le composant.

La production d'une image est simple : il faut commencer par créer une instance de la classe BufferedImage au moyen du constructeur prenant en arguments la taille de l'image et son type. Pour ce dernier, on utilise la constante TYPE_INT_RGB, qui déclare que chaque pixel de l'image est représenté par un entier de type int contenant les trois composantes couleur, encodées selon la technique décrite dans l'étape précédente. Une fois l'image créée, la couleur de chaque pixel peut être définie au moyen de la méthode setRGB. Lorsque tous les pixels de l'image ont été définis, celle-ci est prête à être affichée dans le composant Swing. Cela se fait aisément au moyen de la méthode drawImage de Graphics (en passant null comme observateur).

Une question qui se pose est de savoir quelle taille utiliser pour l'image. Idéalement, on aimerait qu'elle occupe la totalité du composant, donc lui donner la même taille que ce dernier semble être une bonne idée. Toutefois, en faisant cela, on risque de déformer passablement la fractale, le rapport largeur/hauteur du composant n'étant pas forcément le même que celui du cadre passé au constructeur.

Pour éviter ce problème et dessiner une fractale non déformée, deux solutions existent : soit on réduit la taille de l'image pour qu'elle ait le même rapport largeur/hauteur que le cadre reçu ; soit on augmente la taille du cadre pour qu'il ait le même rapport largeur/hauteur que le composant (et l'image). Cette seconde solution est préférable, car c'est celle qui utilise au mieux la surface du composant tout en garantissant que la totalité de la zone du plan couverte par le cadre est visible. Elle est aisée à mettre en œuvre au moyen de la méthode expandToAspectRatio de la classe Rectangle.

Composant des transformations affines

Le second composant à réaliser, nommé p.ex. AffineTransformationsComponent, est celui affichant les composantes affines des transformations Flame caractérisant la fractale.

Le constructeur de ce composant prend en argument le bâtisseur de la fractale et le cadre. Ce dernier est adapté de la même manière que pour le composant précédent. Cela garantit que si les deux composants ont la même taille et reçoivent le même cadre en argument alors ils afficheront exactement la même zone du plan.

Le composant des transformations affines met en évidence l'une des transformations affine, en la représentant en rouge. Pour ce faire, il possède un attribut, nommé p.ex. highlightedTransformationIndex, qui donne l'index de la transformation à mettre en évidence. Le composant est équipé de méthodes permettant de lire et de définir cet attribut.

Il va de soi que lorsque la valeur de cet attribut change, le composant doit être redessiné. Pour demander à ce qu'un composant soit redessiné, il suffit d'appeler sa méthode repaint et Swing se charge du reste.

Pour faciliter la réalisation de ce composant, il est recommandé de travailler dans le système de coordonnées du plan et de ne passer à celui du composant qu'au moment du dessin. Comme d'habitude, ce changement de coordonnées peut être représenté au moyen d'une transformation affine.

Le dessin de ce composant est consitué exclusivement de lignes. Pour les dessiner, on utilise d'abord la méthode setColor du contexte graphique pour définir la couleur de dessin, puis la méthode draw à qui on passe une instance de Line2D.Double. Attention, vous manipulez ici des couleurs AWT, c-à-d des instances de java.awt.Color. Prenez garde à bien importer cette classe Color là, et pas celle que vous avez développée pour l'étape précédente, inutile ici !

Grille

Une grille doit apparaître derrière les représentations des transformations affines, décrites plus bas.

Les lignes de la grille sont espacées d'une unité dans chaque direction, et il existe une ligne pour l'axe des abscisses et une pour l'axe des ordonnées. Les lignes de la grille sont de couleur gris clair (0.9, 0.9, 0.9), sauf celles correspondant aux deux axes, qui sont de couleur blanche pour qu'on puisse les distinguer.

La grille ne doit pas recouvrir les représentations des transformations affines, il faut donc la dessiner avant ceux-ci.

Transformations affines

Chaque transformation affine est représentée par son effet sur une paire de flèches. Avant transformation, la première flèche va de (-1, 0) à (1, 0) et la seconde de (0, -1) à (0, 1). Les pointes de chacune de ces flèches (toujours avant transformation) font 0.1 unités de côté, comme illustré dans la figure ci-dessous.

images/arrow_dimensions.png

Forme et dimension des pointes de flèches

Chaque paire de flèche est dessinée en noir, sauf celle correspondant à la transformation affine mise en évidence, dessinée en rouge. La paire mise en évidence doit toujours être visible, il faut donc prendre soin de la dessiner en dernier.

Résumé

Pour cette étape, vous devez :

  1. Ecrire les classe FlameMaker et FlameMakerGUI du paquetage ch.epfl.flamemaker.gui en fonction des spécifications ci-dessus.
  2. Ecrire la classe pour le composant affichant la fractale, puis vérifier qu'il fonctionne en en créant une instance et en la plaçant dans la fenêtre principale du programme.
  3. Ecrire la classe pour le composant affichant les composantes affines des transformations caractérisant la fractale, puis vérifier qu'il fonctionne de la même manière que précédemment.
  4. Terminer l'interface graphique en mettant des bordures autour des deux composants puis en vérifiant qu'ils occupent toujours chacun la moitié de la fenêtre du programme et qu'ils se redimensionnent lorsque la fenêtre est redimensionnée.

Références

L'introduction à Swing donnée ci-dessus est volontairement brève et ne couvre que les notions nécessaires à la réalisation de cette étape. Les personnes désirant en savoir un peu plus peuvent p.ex. lire The Swing Tutorial, en particulier les parties suivantes qui couvrent les concepts vus plus haut :

  1. How to Make Frames (Main Windows), qui présente les fenêtres (JFrame).
  2. How to Use Panels, qui présente les panneaux (JPanel).
  3. La leçon Laying Out Components Within a Container, qui présente la mise en page des composants dans un conteneur, en particulier les parties suivantes :

Finalement, le document 2D Graphics constitue une bonne introduction au dessin 2D en Java, même s'il explore beaucoup plus de sujets que ceux nécessaires ici.

Notes

1 La raison pour laquelle il faut passer par la méthode invokeLater est que la totalité du code qui crée ou modifie l'interface utilisateur doit s'exécuter dans le fil de gestion des événements (event dispatch thread). Les personnes ayant des notions de concurrence pourront en savoir plus en lisant la leçon Concurrency in Swing de The Swing Tutorial.

Michel Schinz – 2013-04-08 18:00