Etape 10 – Interface graphique de base
1 Introduction
Le but de cette étape est d'écrire la première version de l'interface graphique, qui permet d'afficher une carte isochrone à l'écran. A la fin de cette étape, la fenêtre principale de votre programme devrait ressembler à la copie d'écran ci-dessous.
Figure 1 : Première version de l'interface graphique
Cette interface est réalisée au moyen de la bibliothèque Swing, qu'il convient de décrire brièvement.
1.1 Swing
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 conception 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 sous-classes 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 :
- 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. - 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
. Seuls deux gestionnaires de mise en page seront utilisés pour ce projet : FlowLayout
et BorderLayout
.
Le gestionnaire FlowLayout
est l'un des gestionnaires les plus simples qui soit. Il place ses composants fils côte-à-côte et de gauche à droite.
Le gestionnaire BorderLayout
découpe quant à lui l'espace du conteneur auquel il est attaché en 5 zones ayant chacune un nom, comme illustré dans la figure ci-dessous.
Figure 2 : 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 de type 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érences : A Visual Guide to Layout Managers, Using Layout Managers, How to use FlowLayout et How to Use BorderLayout.
2 Mise en œuvre Java
La mise en œuvre Java de cette étape consiste à d'abord écrire la classe TileMapComponent
, un composant Swing capable d'afficher une carte en tuiles, et ensuite à compléter le squelette de la classe IsochroneTL
que nous vous fournissons.
2.1 Classe TiledMapComponent
La classe TiledMapComponent
du paquetage ch.epfl.isochrone.gui
, publique et finale, est un composant Swing capable d'afficher une carte en tuiles, ces dernières étant fournies par un ou plusieurs fournisseurs de tuiles.
Ce composant possède deux attributs modifiables :
- le niveau de zoom de la carte à afficher, compris entre 10 et 19 (inclus) et dont la valeur initiale est spécifiée lors de la construction,
- la liste des fournisseurs fournissant les tuiles de la carte, initialement vide.
Lorsque la liste des fournisseurs contient plus d'un élément, les tuiles des différents fournisseurs sont affichées l'une par-dessus l'autre, dans l'ordre des founisseurs. Le premier fournisseur fournit donc le fond de carte, et ses tuiles sont généralement opaques, tandis que les suivants fournissent des couches à superposer au fond de carte, et leurs tuiles sont généralement partiellement transparentes.
Comme tout composant Swing, TiledMapComponent
est une sous-classe de JComponent
. Plusieurs des nombreuses méthodes de cette classe peuvent (ou doivent) être redéfinies pour mettre en œuvre le comportement spécifique du composant. Pour TiledMapComponent
, les deux méthodes à redéfinir sont :
Dimension getPreferredSize()
, qui retourne la taille « idéale » du composant. Ici, cette taille est simplement celle de la carte du monde au niveau de zoom courant, comme expliqué plus bas.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 typeGraphics
, mais en réalité la valeur passée est toujours de typeGraphics2D
, type qui offre beaucoup plus de méthodes queGraphics
. Une méthodepaintComponent
typique commence donc généralement par faire un transtypage de cette valeur vers le typeGraphics2D
.
Le système de coordonnées de TiledMapComponent
est simplement le système de coordonnées OSM au niveau de zoom courant. Comme cela a été expliqué dans l'énoncé de l'étape 1, la carte du monde au niveau de zoom \(z\) est un carré de \(2^{z+8}\) pixels de côté. Contrairement à ce que l'on pourrait penser, créer un composant aussi grand ne pose pas de problème, car les parties invisibles des composants ne sont jamais dessinées, et aucune mémoire ne leur est allouée. C'est heureux, car au niveau de zoom 19, l'image de la carte du monde nécessite plus de 72 péta-octets de mémoire, un million de fois plus que la quantité dont disposent les ordinateurs actuels.
Lorsque la méthode paintComponent
est appelée, elle a la possibilité (et ici, l'obligation !) de déterminer la partie du composant qui est visible, et ne dessiner que cette partie. Pour ce faire, il suffit d'appeler la méthode getVisibleRect
, qui retourne le rectangle visible du composant, dans son propre système de coordonnées. Il est facile d'utiliser ce rectangle pour déterminer l'ensemble des tuiles visibles, et de n'obtenir que celles-ci des fournisseurs de tuiles.
A noter que lorsque le niveau de zoom ou les fournisseurs de tuiles changent, il faut provoquer un redessin du composant. Pour ce faire, un simple appel à la méthode repaint
du composant suffit.
2.2 Classe IsochroneTL
La classe IsochroneTL
du paquetage ch.epfl.isochrone.gui
est la classe principale du programme. Elle se charge de construire l'interface graphique et d'afficher la fenêtre du programme à l'écran, avec laquelle l'utilisateur peut ensuite interagir.
Etant donné que la construction de l'interface graphique demande de bonnes connaissances Swing, un squelette de la classe IsochroneTL
vous est exceptionnellement fourni. Ce squelette se trouve dans une archive Zip — à importer en suivant la procédure habituelle — contenant également une image du logo des tl, qui doit apparaître dans l'interface.
Une fois la classe TiledMapComponent
écrite, vous devriez pouvoir exécuter le squelette de la classe IsochroneTL
fourni et une fenêtre semblable à celle de la figure ci-dessous devrait s'afficher à l'écran. Notez que le squelette fourni ne passe aucun fournisseur de tuiles au composant TiledMapComponent
, raison pour laquelle aucune carte n'est affichée à l'écran. Cela dit, le constructeur de la classe IsochroneTL
stocke dans la variable bgTileProvider
un fournisseur pour le fond de carte, et il devrait donc être trivial de modifier le code pour le passer au composant.
Figure 3 : Squelette d'interface
Pour cette étape, le squelette fourni doit être complété en trois étapes :
- le constructeur doit être terminé afin de créer un fournisseur de tuile pour une carte isochrone dont les paramètres sont ceux donnés par les variables privées et statiques de la classe (p.ex. la date doit être
INITIAL_DATE
, l'heure de départINITIAL_DEPARTURE_TIME
, etc.), le transformer pour rendre ses tuiles transparentes à 50% et finalement le passer, accompagné du fournisseur pour le fond de carte, au composant d'affichage de la carte, - la méthode
createCenterPanel
doit être complétée afin de permettre le déplacement de la carte au moyen de la souris, - la méthode
createCenterPanel
doit être complétée afin de permettre le zoom dans la carte au moyen de la molette de la souris.
Une fois la première étape terminée, la fenêtre de votre programme devrait ressembler à celle donné dans l'introduction. Lorsque les deux étapes suivantes seront terminées, il devrait être possible d'intéragir avec cette carte exactement comme avec les cartes de sites comme OpenStreetMap ou Google Maps.
La première étape ne sera pas décrite plus en détail, mais les deux dernières le sont ci-dessous, après une description de la hiérarchie des conteneurs de cette étape.
Hiérarchie des conteneurs
L'image ci-dessous illustre la hiérarchie des conteneurs construite par la classe IsochroneTL
fournie. Les conteneurs fils sont généralement placés au-dessus de leur conteneur parent, sauf dans un cas : les deux composants du sommet (copyrightPanel
et viewPort
) sont en fait les deux des fils du même composant layeredPane
, mais ils sont superposés, comme expliqué plus bas.
Figure 4 : Hiérarchie des conteneurs pour cette étape
Dans cette image, la classe de chaque composant est indiquée sur celui-ci, et la classe de son éventuel gestionnaire de mise en page est indiqué en-dessous, entre parenthèses. Les noms des variables du code contenant les différents conteneurs sont quant à eux donnés sur le côté.
A la base de la hiérarchie se trouve la fenêtre principale du programme (frame
). Cette fenêtre possède un panneau, nommé panneau de contenu (content pane), créé automatiquement par Swing, raison pour laquelle il est grisé sur le dessin. Ce panneau peut s'obtenir au moyen de la méthode getContentPane
de la fenêtre principale.
Le panneau de contenu a un gestionnaire de mise en page de type BorderLayout
dont la zone centrale (CENTER
) est occupée par le panneau central (centerPanel
). C'est ce panneau qui contient les composants affichant la carte et le texte de copyright.
L'unique fils du panneau central est un panneau en couches de type JLayeredPane
(layeredPane
). Un tel panneau superpose ses fils au lieu de les placer côte-à-côte comme les autres panneaux. Il est utile ici pour superposer le texte de copyright à la carte, de manière à ce que cette dernière apparaisse en transparence derrière ce premier.
Le panneau en couches a donc deux fils :
- le panneau de copyright, qui ne contient rien d'autre qu'une étiquette (instance de
JLabel
) composé du logo des tl et du texte de copyright, et - l'aire d'affichage de la carte, de type
JViewPort
, qui montre une (petite) portion rectangulaire du composant affichant la carte, de typeTiledMapComponent
.
Comme cela a été expliqué plus haut, le composant affichant la carte est (potentiellement) très grand. Pour cette raison, il n'est pas directement placé dans la hiérarchie comme fils du panneau en couches. Au lieu de cela, il est placé dans ce que l'on nomme une aire d'affichage (viewport en anglais), modélisée par la classe JViewPort
de Swing.
Le composant JViewPort
référence un autre composant, appelé sa vue (view), qui lui fournit le contenu à afficher à l'écran. Toutefois, étant donné que la vue est généralement (beaucoup) plus grande que le contenu affiché (et affichable) à l'écran, seule une portion rectangulaire de celle-ci est montrée dans l'aire d'affichage. La taille de cette portion est égale à celle de l'aire d'affichage, et sa position dans la vue est déterminée par un attribut de l'aire d'affichage (c-à-d une instance de JViewPort
). En modifiant la position de cette vue, on peut faire défiler la vue dans l'aire d'affichage, et permettre ainsi à l'utilisateur de voir, morceau par morceau, les parties de la vue qui l'intéressent. Tout programme affichant une vue de taille importante et permettant de s'y déplacer par scrolling — p.ex. un traitement de texte, un tableur ou un visualiseur de cartes — est ainsi basé sur cette notion d'aire d'affichage.
L'image ci-dessous illustre le rapport entre la vue, ici le composant de type TiledMapComponent
qui affiche (potentiellement) la carte du monde entier, et l'aire d'affichage, ici le composant Swing JViewPort
qui ne montre qu'une petite portion de cette vue.
Figure 5 : Relation entre JViewPort
et TiledMapComponent
Déplacement
Une fois que la carte est affichée avec succès à l'écran, il convient de donner à l'utilisateur la possibilité de déplacer — au moyen de la souris — la partie de la carte affichée dans l'aire d'affichage. Ainsi, lorsque l'utilisateur maintient pressé le bouton gauche de la souris tout en la déplaçant, le point de la carte qui se trouve sous le pointeur doit suivre celui-ci.
Pour réagir à certains événements, comme la pression d'un bouton de la souris ou son déplacement, Swing fournit la notion d'auditeur (listener), très proche de la notion d'observateur du patron Observer. Un auditeur est un objet implémentant une certaine interface que l'on attache à un composant et dont les méthodes sont appelées lorsque certains événements se produisent sur ce composant.
Par exemple, pour réagir au clic du bouton gauche de la souris effectué à une position quelconque sur le composant layeredPane
, il est possible d'ajouter le code suivant à la fin de la méthode createCenterPanel
:
layeredPane.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { System.out.println("bouton pressé à la position " + e.getLocationOnScreen()); } });
Chaque fois que le bouton gauche de la souris est pressé alors que celle-ci se trouve à l'intérieur du composant layeredPane
, la méthode mousePressed
de l'auditeur (anonyme) ci-dessus est appelée et reçoit en argument un objet MouseEvent
décrivant l'événement qui s'est produit. Ainsi, il est possible de connaître la position du pointeur de la souris dans le système de coordonnées de l'écran en appelant la méthode getLocationOnScreen
comme illustré ci-dessus.
Pour mettre en œuvre le déplacement de la carte, l'idée est d'ajouter deux auditeurs au composant layeredPane
.
Le premier détecte les pressions du bouton gauche de la souris et mémorise à ce moment deux informations : la position de la souris, obtenue p.ex. au moyen de la méthode getLocationOnScreen
de l'événement, et la position de la carte dans l'aire d'affichage, obtenue au moyen de la méthode getViewPosition
de JViewPort
.
Le second auditeur détecte quant à lui les déplacements de la souris avec le bouton gauche pressé et, à chacun d'entre-eux, il ajuste la position de la carte dans l'aire d'affichage, au moyen de la méthode setViewPosition
de JViewPort
, afin que la carte suive la souris.
Les deux auditeurs sont des sous-classes anonymes de MouseAdapter
. Le premier redéfinit la méthode mousePressed
et est ajouté au composant layeredPane
au moyen de sa méthode addMouseListener
, comme illustré plus haut. Le second redéfinit la méthode mouseDragged
et est ajouté au composant layeredPane
au moyen de la méthode addMouseMotionListener
(attention, ce n'est pas la même méthode que pour l'auditeur précédent !).
Zoom
En plus de la possibilité de déplacer la carte, il faut également fournir à l'utilisateur la possibilité de changer l'échelle de celle-ci. Cela se fait au moyen de la molette de la souris, de manière à ce que le point situé sous le pointeur ne bouge pas. Par exemple, si l'utilisateur visualise une carte montrant le Learning Center de l'EPFL et place le pointeur sur le coin sud ouest du bâtiment avant d'actionner la molette pour changer d'échelle, le coin sud ouest du Learning Center doit toujours rester sous le pointeur de la souris.
Pour détecter les mouvements de la molette de la souris, Swing permet l'ajout d'un auditeur de type MouseWheelListener
à un composant, via sa méthode addMouseWheelListener
. La méthode mouseWheelMoved
de cet auditeur reçoit en argument un événement de type MouseWheelEvent
dont la méthode getWheelRotation
permet de connaître le nombre de crans dont la molette a été tournée. Le signe de cette valeur donne le sens de rotation.
A chaque cran doit correspondre un niveau de zoom, et un nombre de crans positif doit provoquer une diminution du niveau de zoom, et inversément. Ainsi, si le niveau de zoom actuel est 15 et getWheelRotation
retourne -2, le nouveau niveau de zoom doit être 17.
Pour garantir que le point sous le pointeur de souris ne bouge pas lors du changement d'échelle, il faut s'assurer que la position de ce point dans l'aire d'affichage ne change pas. Pour ce faire, les méthodes suivantes sont utiles :
getPoint
deMouseWheelEvent
, qui permet d'obtenir la position du pointeur de la souris au moment où la molette a été tournée, dans le système de coordonnées du composant (icilayeredPane
, étant donné que c'est celui auquel l'auditeur est attaché),convertPoint
deSwingUtilities
(méthode statique), qui permet de convertir un point entre les systèmes de coordonnées de deux composants.
2.3 Test
Il est très difficile de tester le résultat de cette étape automatiquement, mais relativement simple de vérifier visuellement que la carte affichée est similaire à celle donnée en exemple plus haut, et que le déplacement et le changement d'échelle se comportent correctement.
3 Résumé
Pour cette étape, vous devez :
- Ecrire la classes
TiledMapComponent
et compléter la classeIsochroneTL
fournie selon les spécifications ci-dessus. - Tester votre programme.
- Documenter la totalité des entités publiques que vous avez définies.