De nos jours, la mode est aux interfaces web et aux microservices. Les clients lourds n'ont pas vraiment la cote et c'est pour cela que j'ai décidé de consacrer un article à la création d'interfaces graphiques avec JavaFX 😄
Pour avoir un fil conducteur, nous allons créer une interface graphique qui affiche les labyrinthes que nous avons généré dans un article précédent. Avec l'advent of code (dont nous avons parlé de sa résolution à l'aide de l'IA récemment), à l'inverse de JavaFX, les labyrinthes sont à la mode ! Le but pour le moment sera simplement de générer un labyrinthe sur une grille rectangulaire et de l'afficher dans un canvas. Vous ne savez pas ce qu'est un canvas ? Pas de panique, suivez le guide !
Les frameworks disponibles
Faire le tour de l'ensemble des frameworks disponibles pour faire de l'UI remplirait un article complet. Voici donc une sélection de frameworks très populaires qui fonctionnent bien avec Kotlin.
- awt ou Abstract Window Toolkit est la couche de base utilisée par Java pour faire du rendu graphique. Il n'est pas recommandé de l'utiliser directement mais c'est possible et ça fonctionne plutôt bien. Attention, car il manque des fonctionnalités souvent utiles comme la gestion des arcs. Par ailleurs le fait que tout soit géré par des entiers rend parfois le résultat assez brouillon si vous devez faire du dessin comme c'est le cas pour nous.
- Swing a longtemps été la librairie de référence pour faire des clients lourds. Elle est maintenant dépréciée mais mérite tout de même d'être citée tellement elle est utilisée dans beaucoup de softs Java qui ne sont pas passés aux interfaces web.
- JavaFX est le dernier framework officiellement supporté par Java. Bien qu'il ne soit plus directement intégré dans le JDK et fasse maintenant l'objet d'une librairie séparée, c'est la solution la plus sûre à ce jour si vous devez faire du développement d'interface graphiques natives en Java ou l'un de ses dérivés. JavaFX propose notamment de séparer la présentation du comportement en utilisant des CSS.
- Compose Multiplatform est un projet lancé et soutenu par JetBrains qui promet de créer en une fois des interfaces web, lourdes, mobile, et même autres. Le framework se base notamment sur le compilateur Kotlin/JS qui permet d'écrire du code qui fonctionnera en natif et sur un navigateur. Même si l'idée est séduisante, la complexité ajoutée pour supporter le tout n'en fait pas l'outil idéal pour le sujet de cet article. Nous nous cantonnerons donc à JavaFX.
La hiérarchie de base d'une application
Une application JavaFX est composée des éléments suivants :
- Chaque application démarrée crée une Stage, essentiellement une fenêtre et il est possible d'en créer d'autres, dépendantes ou non (Une Stage dépendante d'une autre sera fermée automatiquement si la Stage parente est fermée). La Stage est en majeure partie gérée par le système d'exploitation et il est relativement difficile d'en modifier les propriétés. Par exemple, dans un Window Manager utilisant du tiling, modifier la taille d'une Stage n'a pas de sens.
- Chaque Stage affiche une Scene à chaque instant donné. À l'inverse d'une Stage, c'est le programme et donc le programmeur qui a le contrôle de la Scene. Il est du coup possible de changer sa taille ou son orientation par exemple. Une Scene est le plus grand conteneur de Nodes d'une application JavaFX.
- Chaque Scene contient un ou plusieurs Nodes, à commencer par le root Node. À titre tout à fait arbitraire, je vais classer les Nodes dans deux catégories distinctes : Les Panes et les Controls. Il existe en fait d'autres types de Nodes mais le but de cet article n'est pas de faire un cours exhaustif sur JavaFX.
- Les Panes définissent une manière d'arranger les Nodes. Voici quelques Panes utiles :
- VBox qui organise les Nodes verticalement
- HBox qui organise les Nodes horizontalement
- BorderPane qui contient cinq régions: Top, Left, Center, Right et Bottom; chaque région pouvant contenir un Node.
- Les Controls permettent à l'utilisateur d'interagir avec l'application. Nous verrons plusieurs exemples de Controls tout au long de cette série, voici quelques-uns des Controls les plus communs :
- Label, qui permet d'afficher du texte (en lecture seule)
- ComboBox, qui permet de choisir une valeur dans une liste
- Slider, qui permet de sélectionner une valeur dans un range défini
- Spinner, qui permet d'augmenter/diminuer facilement une valeur
- TextField et TextArea, qui permettent d'entrer du texte
Le Control Canvas
L'application étudiée dans cet article est des plus standard et contient :
- une Stage (celle créée pour nous par JavaFX lorsque nous lançons l'application),
- une Scene,
- un HBox qui contiendra un seul Control, que nous n'avons pas introduit ci-dessus; j'ai nommé ...
- un Canvas, qui est une sorte de tableau blanc sur lequel nous pouvons dessiner tout ce que nous voulons.
Par défaut, l'origine (le point (0, 0)) d'un Canvas se trouve en haut à gauche, les absisses (x) vont vers la droite et les ordonnées (y) vers le bas. Je dis par défaut car il est possible d'appliquer une transformation au Canvas, ce qui nous permet de choisir un autre système de coordonnées si on le souhaite. Nous n'allons pas nous amuser avec ça ici.
Les opérations de base sont stroke (tracer) et fill (remplir). Ces opérations se déclinent en plusieurs formes. Celles qui nous intéressent aujourd'hui sont strokeLine et fillRect. Je vous laisse deviner l'utilité de ces deux fonctions 😄
Pour des raisons de performances, nous utiliserons également la fonction clearRect qui permet d'effacer une région rectangulaire du Canvas. C'est utile car sinon, JavaFX va tracer toutes les opérations même si elles sont recouvertes, après avoir déplacé ou redimensionné la fenêtre par exemple.
Architecture de l'application
J'ai lu récemment Clean Architecture de l'oncle Bob. Vous aurez donc peut-être remarqué que j'ai fait attention à ne jamais faire dépendre le code de l'interface graphique. D'ailleurs, cette dernière a été totalement ignorée jusqu'à cet article. Voici donc quelques diagrammes UML qui présentent notre application d'un point de vue architectural.
Diagramme de composants
Nous parlons ici de composants au sens de l'architecture C4 de Simon Brown. C'est-à-dire qu'un composant est un ensemble de classes partageant une même interface. Nous n'affichons d'ailleurs que les interfaces pour le moment.
La classe Main accède à la plupart des composants. C'est son rôle de faire le lien entre les différentes parties de l'implémentation. Les interfaces Coordinate, Grid, GridWalker et GenerationAlgorithm ont été décrites dans l'article précédent. Résumons rapidement leurs responsabilités.
- Coordinate représente une coordonnée, dans un système quelconque. Pour le moment nous n'utilisons que Cartesian Coordinate pour représenter des coordonnées cartésiennes (x, y) mais il est prévu que cela change un jour. Je ne suis pas toujours un grand fan du principe YAGNI 😄
- Grid représente une grille. Elle est paramétrée par le type de coordonnées que cette grille utilise. Dans le cadre des grilles rectangulaires, nous utilisons des coordonnées cartésiennes. Deux implémentations ont été présentées auparavant : l'implémentation en graphe, où la grille gère les liens entre les noeuds et l'implémentation en cellules, où les liens sont déportés au niveau des coordonnées.
- GridWalker permet de générer des séquences de coordonnées sur une partie ou la totalité d'une grille. Cette interface permet notamment de ne pas s'inquiéter du nombre de dimensions sur lequel nous travaillons.
- GenerationAlgorithm applique un algorithme de création de labyrinthe à une grille.
Nous introduisons maintenant l'interface GridRenderer qui dessine une grille sur le contexte d'un Canvas. Elle propose deux méthodes clear() et render() qui doivent généralement être appelées alternativement.
Diagramme de classes - Grilles
Les classes abstraites CellGrid et GraphGrid ne fixent pas le type de coordonnée à utiliser et proposent les implémentations alternatives en cellules ou en graphe. Elles sont ensuite spécialisées par le type de coordonnée souhaité. Dans le cas des grilles rectangulaires, tout le code est refactoré dans l'interface RectangularGrid. Les classes CellRectangularGrid et GraphRectangularGrid ne contiennent que de l'héritage et un constructeur.
Diagramme de classes - Génération de labyrinthe
Nous avons pour le moment un total de quatre algorithmes de génération de labyrinthes. BinaryTree et SideWinder qui ont été explorés intensivement dans l'article précédent, OpenAll qui ajoute des liens entre toutes les coordonnées adjacentes et CloseAll qui laisse la grille totalement fermée. Ces deux derniers algorithmes peuvent se révéler utiles à des fins de debugging.
Diagramme de classes - Interface graphique
Pour finir, voici la hiérarchie de classes de GridRenderer. Pour dessiner une grille rectangulaire nous dessinons le fond puis les cellules. Chaque cellule est traitée séparément par la méthode drawCell.
Un peu de code
Nous n'allons pas donner tout le code ici (si vous êtes intéressé, une archive est disponible plus bas avec l'application complète). Mais nous voulons quand même mettre en valeur la manière d'assembler une application JavaFX et la manière de se servir d'un Canvas.
La classe principale
C'est elle qui assemble toutes les classes et qui démarre notre application. Les possibilités sont commentées et tous les nombres magiques peuvent être changés si vous voulez vous amuser. Quelques exemples de rendus sont donnés plus bas dans l'article. Comme d'habitude, le code et les commentaires sont en anglais.
class MazesFX : Application() {
override fun start(primaryStage: Stage) {
// Create rectangular grid using any implementations, Cell or Graph.
val grid: RectangularGrid = CellRectangularGrid(42, 42)
// val grid: RectangularGrid = GraphRectangularGrid(42, 42)
// Apply some generation algorithm
SideWinder { _: RectangularGrid -> RectangularGridWalker() }.on(grid)
// BinaryTree { _: RectangularGrid -> RectangularGridWalker() }.on(grid)
// OpenAll<CartesianCoordinate, RectangularGrid>().on(grid)
// CloseAll<CartesianCoordinate, RectangularGrid>().on(grid)
// We don't use the default renderer so that we can play with parameters.
val cellSize = 20.0
val canvas = Canvas(cellSize * grid.width, cellSize * grid.height)
RectangularGridRenderer(canvas.graphicsContext2D, grid, cellSize, 3.0).render()
// Simplest scene you can think of.
primaryStage.scene = Scene(StackPane(canvas))
primaryStage.show()
}
}
fun main() {
Application.launch(MazesFX::class.java)
}
La méthode render()
Voici pour finir la méthode drawCell de la classe RectangularGridRenderer.
Pour le moment, ce n'est qu'une question de style, mais si la série d'articles va aussi loin que je l'espère, cela aura un jour une utilité : la classe. RectangularGridRenderer a une propriété inset (initialisée dans le constructeur) qui indique quelle distance sépare deux murs adjacents du labyrinthe. Cela donne un effet intéressant au labyrinthe. Si on initialise cette propriété à zéro, on a le style de labyrinthe habituel où un seul trait sépare deux couloirs adjacents. Cela rend le code plus compliqué mais aussi plus intéressant.
val x = 1.0 * position.x * cellSize
val y = 1.0 * position.y * cellSize
if (grid.linked(position, position.north())) {
context.strokeLine(x + inset, y + cellSize - inset, x + inset, y + cellSize)
context.strokeLine(x + cellSize - inset, y + cellSize - inset, x + cellSize - inset, y + cellSize)
}
else {
context.strokeLine(x + inset, y + cellSize - inset, x + cellSize - inset, y + cellSize - inset)
}
if (grid.linked(position, position.south())) {
context.strokeLine(x + inset, y + inset, x + inset, y)
context.strokeLine(x + cellSize - inset, y + inset, x + cellSize - inset, y)
}
else {
context.strokeLine(x + inset, y + inset, x + cellSize - inset, y + inset)
}
if (grid.linked(position, position.west())) {
context.strokeLine(x, y + inset, x + inset, y + inset)
context.strokeLine(x, y + cellSize - inset, x + inset, y + cellSize - inset)
}
else {
context.strokeLine(x + inset, y + inset, x + inset, y + cellSize - inset)
}
if (grid.linked(position, position.east())) {
context.strokeLine(x + cellSize, y + inset, x + cellSize - inset, y + inset)
context.strokeLine(x + cellSize, y + cellSize - inset, x + cellSize - inset, y + cellSize - inset)
}
else {
context.strokeLine(x + cellSize - inset, y + inset, x + cellSize - inset, y + cellSize - inset)
}
Si votre première impression en voyant ce code est "beurk ! Quatre fois la même chose ! Pourquoi ce n'est pas mieux factorisé ?", n'hésitez pas à essayer de rendre le code plus compact tout en gardant la lisibilité intacte 😃
Conclusion
Pour finir, quelques exemples de labyrinthes générés avec l'application dans son état actuel. Je vous laisse deviner avec quel algorithme chacun a été fait 😎
Annexes
Pour télécharger le code, c'est ici !
Pour ceux qui se demanderaient à quoi ressemblent les hiérarchies des interfaces GridWalker et Coordinate dont nous avons parlé plus haut, les voici.