Interfaces graphiques en Java
par Philippe Chassignet

 Login :  Mot de passe :

Introduction

Dans ce TD, nous allons expérimenter l'écriture d'une application Java conçue autour de son interface graphique. Nous verrons successivement comment :

Il s'agit d'un TD très ouvert dont la réalisation demandera une part importante de lecture de la documentation et d'expérimentation.

Le fil directeur sera la réalisation d'une application qui permettra de tracer des courbes définies par des équations, soit de la forme y = f(x), soit de la forme ( x = f(t), y = g(t) ). Les fonctions f et g seront définies par le biais de l'interface, c'est-à-dire qu'il va s'agir de chaînes de caractères dont l'interprétation sera confiée à CalculiX.
Comme ces équations pourront faire intervenir des paramètres, il faudra prévoir l'introduction de ces paramètres par le biais de l'interface graphique. On aura bien sûr aussi quelques « boutons » pour des actions comme tracer, arrêter, quitter, …

Il existe principalement deux bibliothèques graphiques pour Java : AWT et Swing. On utilisera plutôt Swing qui est conçue par dessus AWT et qui offre beaucoup plus de possibilités, mais vous aurez à vous référer à deux documentations.

Préparation

Pour commencer, suivant la procédure habituelle,

Un exemple simple

Une interface graphique Java prend l'apparence d'une ou plusieurs fenêtres graphiques. Nous utiliserons un JFrame de Swing comme fenêtre et voici donc la documentation de JFrame comme point d'entrée.

Un objet JFrame est la racine d'un arbre de composants (classe abstraite java.awt.Component), c'est aussi un conteneur (classe java.awt.Container) particulier. Il existe de très nombreuses implémentations de composants et de conteneurs. Les composants sont faits pour être placés dans des conteneurs. Les conteneurs sont eux-mêmes des composants et peuvent ainsi constituer des nœuds internes de l'arbre.
Il existe des exceptions comme JFrame qui est un conteneur, donc un composant, mais qui ne peut pas être placé dans un autre conteneur. L'exception n'est pas spécifiée par le typage mais elle se traduit par une erreur à l'exécution.
Il y aussi des composants qui ne sont pas des conteneurs et qui donc seront forcément des feuilles de l'arbre.

Un arbre minimal

Voici un exemple défini par ces 3 classes (données dans l'archive td18.zip) :

public class Main {
  public static void main(String[] args) {
    new MaFenetre();
    // new MaFenetre(); // et deux si on veut
    System.out.println(Thread.currentThread() + " en fin de main()");
  }
}
import javax.swing.JFrame;

public class MaFenetre {
  public MaFenetre() {
    JFrame frame = new JFrame("Un titre");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // comportement modifiable
    // on place ici la definition de l'arbre de composants
    frame.add(new MonDessin()); // un raccourci
    // frame.getContentPane().add(new MonDessin()); // forme exacte
    // ...
    // on termine generalement ainsi
    frame.pack();
    frame.setVisible(true);
    // pour l'exemple
    System.out.println(Thread.currentThread() + " en fin de MaFenetre()");
  }
}
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import javax.swing.JPanel;

@SuppressWarnings("serial")
public class MonDessin extends JPanel {
  public MonDessin() {
    this.setPreferredSize(new Dimension(100, 100));
    this.setBackground(Color.WHITE);
    this.setOpaque(true);
  }

  public void paintComponent(Graphics g) {
    super.paintComponent(g);
    g.fillOval(0, 0, this.getWidth(), this.getHeight());
    System.out.println(Thread.currentThread() + " en fin de paintComponent()");
  }
}

Dans cet exemple, l'arbre est donc réduit à une racine et une feuille.
Pour comprendre le fonctionnement du programme, il faut savoir que le constructeur de MaFenetre est exécuté par le thread principal, celui qui exécute la fonction main de tout programme Java. Le constructeur de MaFenetre termine rapidement, ainsi donc que main et le thread principal. Le relais est pris automatiquement par le thread de l'AWT qui fait vivre l'interface graphique. On le voit sur cet exemple en faisant varier la taille de la fenêtre, ce qui provoque automatiquement des appels à paintComponent.

Pour éviter d'éventuels problèmes de synchronisation, il est préférable de confier la construction de l'interface au thread de l'AWT. Ainsi, on préserve un bon principe qui est que ce thread est le seul à toucher aux objets de l'interface.
Pour cela on utilise la méthode statique SwingUtilities.invokeLater(Runnable todo). On peut instancier un objet de type Runnable « à la volée » comme seule instance d'une classe anonyme :

    Runnable r = new Runnable() {
      public void run() {
        …
      }
    };

Soit, en regroupant en un seul trait :

    SwingUtilities.invokeLater(new Runnable() {
      public void run() {
        …
      }
    });

Expérimentez la construction de deux fenêtres avec un seul Runnable commun, puis avec un Runnable par fenêtre.

Déposez Main.java.

« Multi-threading »

On considère maintenant le cas où le code pour produire le dessin va prendre beaucoup de temps, par exemple avec la classe MonDessin2 donnée dans td18.zip.
Le thread de l'AWT reste alors bloqué dans paintComponent et il ne peut plus faire vivre l'interface graphique. Notamment, un clic dans le bouton de fermeture de la fenêtre reste sans effet.

La solution est alors de lancer le calcul de dessin dans un autre thread et paintComponent se contente d'actualiser l'écran avec le dessin déjà calculé. Un moyen relativement simple est d'utiliser une image tampon. Le nouveau thread dessine dans cette image et le thread de l'AWT affiche cette image lorsqu'il exécute paintComponent.

Pour cela,

  1. on déclare l'image tampon comme un champ Image buffer; de notre JPanel,
  2. pour construire l'image, on doit attendre que le JPanel soit affiché ; un moyen est de différer cette action au premier passage dans paintComponent,
  3. on peut alors lancer le nouveau thread,
  4. paintComponent utilise g.drawImage pour recopier le contenu de l'image à l'écran.
Le code de paintComponent est donc le suivant :

  public void paintComponent(Graphics g) {
    super.paintComponent(g);
    if (buffer == null) {
      buffer = this.createImage(WIDTH, HEIGHT);
      new Thread(painter).start();
    }
    g.drawImage(buffer, 0, 0, null);
  }

Il vous reste à mettre en place le Runnable painter comme un champ du JPanel, par le biais d'une classe membre anonyme :

  private Runnable painter = new Runnable() { … };

Son code reprend l'ancien code de paintComponent. Pour obtenir un objet Graphics, on utilisera buffer.getGraphics(), attention plusieurs appels à getGraphics() peuvent renvoyer des objets différents.
Le dernier problème à régler est de provoquer un appel à paintComponent, depuis le painter. On peut pour cela écrire MonDessin2.this.repaint();MonDessin2.this est la syntaxe pour désigner l'objet MonDessin2 depuis painter et repaint() est sa méthode pour poster un appel à paintComponent. On fera cela périodiquement, par exemple tous les 100000 points calculés.

Déposez MonDessin2.java.

Utilisation d'un SwingWorker

L'utilisation d'un SwingWorker constitue une alternative plus propre dans la mesure où le nouveau thread n'aura pas à appeler directement repaint mais communiquera son avancement par l'envoi de messages.

Sur un modèle assez similaire à l'exercice précédent, on déclarera un SwingWorker<Void, Void> en définissant sa méthode doInBackground. Le lancement se fait par sa méthode execute. Au lieu d'appeler MonDessin2.this.repaint, le SwingWorker appellera sa propre méthode setProgress, avec un argument bien choisi.

Pour récupérer les messages postés par setProgress, il faut installer un PropertyChangeListener, ce qui peut se faire depuis le constructeur de MonDessin2, et c'est le code de ce PropertyChangeListener, exécuté par le thread de l'AWT, qui fera les appels à repaint.

Déposez MonDessin2.java.

Notre application

Construire l'arbre des composants

Pour l'instant, nous nous attachons seulement à définir l'aspect visuel de notre interface. Même si certains composants vont avoir déjà un certain comportement réactif prédéfini, nous ne nous intéressons pas encore à définir des comportements particuliers.

Un conteneur possède un gestionnaire de placement de ses composants de type LayoutManager. Pour un JFrame, il s'agit par défaut d'un BorderLayout qui gère seulement 5 composants placés aux positions désignées par les constantes NORTH, SOUTH, EAST, WEST et CENTER.
On peut facilement changer le LayoutManager d'un conteneur, mais il est généralement encore plus simple d'utiliser le type conteneur prédéfini pour utiliser un certain LayoutManager.

Dans notre JFrame (classe Traceur fournie), nous allons donc placer :

Ce conteneur de type Box va regrouper les composants qui permettent de paramétrer la courbe :

On doit obtenir, par exemple (le texte message ici est défini par le programme, les textes dans les autres boîtes ont été entrés au clavier) :

Déposez les fichiers que vous avez modifiés.

Attacher des actions aux composants

Pour Swing, une action est un objet qui hérite de la classe AbstractAction. On peut généralement instancier un tel objet « à la volée » comme seule instance d'une classe anonyme, par exemple :

    AbstractAction a = new AbstractAction() {
      public void actionPerformed(ActionEvent arg0) {
        System.exit(0);
      }
    };

Ensuite, pour attacher cette action au bouton bQuit, on peut tenter dans un premier temps bQuit.add(a). Le résultat n'est pas tout à fait celui escompté car ceci efface le titre du bouton (une action permet de paramétrer entièrement le bouton). Il faut en fait passer le titre au constructeur de l'action et, en regroupant, on peut définir le bouton et son action en un seul trait :

    JButton bQuit = new JButton(new AbstractAction("Quit") {
      public void actionPerformed(ActionEvent arg0) {
        System.exit(0);
      }
    });

et comme il n'est pas utile de conserver la référence sur ce bouton, on peut encore condenser en … add(new JButton(…));.

On procèdera de manière similaire pour que le bouton "New" ouvre un nouveau JFrame.

Le bouton "Plot" demande plus de travail car il devra lancer le tracé de la courbe. Pour l'instant, on va seulement s'intéresser à récupérer les paramètres du tracé et, pour vérifier, on les affichera par des appels à System.out.println :

Par exemple, on affichera :

mode (f(t),g(t))
X = sin (A*t+D)
Y = sin (B*t)
3 parametres :
A = 0.0100001
B = 0.02
D = 2.0

Déposez les fichiers que vous avez modifiés.

Introduction de CalculiX

Le principe général de l'action du bouton "Plot" est le suivant :

La classe Calculix.Util fournit les quelques méthodes suffisantes pour cela. Cette version de CalculiX traite les nombres flottants et reconnaît les fonctions mathématiques usuelles, ainsi que l'opérateur ^ pour la puissance. Les constantes e et pi sont déjà définies dans l'environnement Util.INITIAL.
Les deux première étapes peuvent être réalisées par le thread de l'interface, la dernière doit être confiée à un SwingWorker.

Déposez les fichiers que vous avez modifiés.