Сегодня мы поговорим о приложениях, созданных с помощью библиотеки JavaFX. Эта библиотека появилась в 2007 году, она предназначена для проектирования приложений с графическим интерфейсом — как Desktop-приложений (работающих на обычном компьютере), так и Web-приложений (работающих в браузере). Библиотека была призвана заменить GUI-библиотеку предыдущего поколения — Swing.
Первые годы JavaFX существовала отдельно от основной платформы Java, и язык для её программирования назывался JavaFX Script. В 2014 году библиотека вошла в состав JDK 1.8 и получила полноценную поддержку со стороны языка Java. В 2018 году при выпуске JDK 11 JavaFX убрали из состава JDK и выделили в отдельную библиотеку.
Библиотека JavaFX входит в состав JDK 1.8, 9 и 10, поэтому для этих JDK программы на Java и Kotlin могут, в принципе, использовать её без подключения дополнительных зависимостей; загрузить JDK 1.8 можно отсюда. При использовании JDK 11 и более поздней необходимо явное подключение JavaFX в виде загруженного пакета, Maven-зависимости или Gradle-зависимости. Данный проект использует JDK 14 и использует библиотеку javafx версии 15.0.1 (см. файл pom.xml).
Для программ на Kotlin существует удобный DSL (Domain Specific Language = предметно-ориентированный язык) tornadofx — см. также его GitHub-репозиторий. Этот DSL может быть подключен к программам на Kotlin как дополнительная библиотека — подробности о подключении дополнительных библиотек см. в 7-м разделе; опять-таки можно использовать Maven- или Gradle-зависомость, примеры есть на GitHub в README.
Domain Specific Language позволяет, в частности, описывать структуру JavaFX-сцен в более прозрачной для программиста форме. Вот так, например, выглядит типичное описание диалога с помощью tornadofx (пример взят из игры 4 в ряд).
init {
title = "Four-in-Row"
with (dialogPane) {
headerText = "Choose players"
buttonTypes.add(ButtonType("Start Game", ButtonBar.ButtonData.OK_DONE))
buttonTypes.add(ButtonType("Quit", ButtonBar.ButtonData.CANCEL_CLOSE))
content = hbox {
vbox {
label("Yellow")
togglegroup {
bind(yellowPlayer)
radiobutton("Human") {
isSelected = true
}
radiobutton("Computer")
}
}
spacer(Priority.ALWAYS)
vbox {
label("Red")
togglegroup {
bind(redPlayer)
radiobutton("Human")
radiobutton("Computer") {
isSelected = true
}
}
}
}
}
}
Прочитав этот фрагмент кода, мы можем узнать:
-
Диалог называется Choose Players
-
Диалог имеет две кнопки, позволяющие его покинуть — Start Game(OK) и Quit(CANCEL)
-
Основная часть диалога — горизонтальная секция (hbox == horizontal box), состоящая, в свою очередь, из двух вертикальных секций (vbox == vertical box)
-
Левая вертикальная секция помечена (label) как Yellow и включает в себя группу из двух радио-кнопок (radiobutton) — из этих кнопок пользователь всегда сможет выбрать только одну. По умолчанию из кнопок Human и Computer выбрана Human
-
Правая вертикальная секция помечена как Red и включает в себя две такие же кнопки, но по умолчанию выбрана Computer
Как подробно написано в этой статье, JavaFX-приложение стоит на трёх основных китах:
-
Stage = Окно приложения
-
Scene = Сцена, содержимое окна, описывается графом сцены (Scene Graph)
-
Nodes = Отдельные узлы графа сцены
Узлы JaxaFX имеют сложную иерархию. В рамках графа сцены всегда есть корневой узел (root), включающий в себя другие узлы (потомки), которые, в свою очередь, тоже могут иметь потомков, и так до любого уровня вложенности, то есть узлы образуют дерево. Кроме этого, отдельные типы узлов образуют иерархию наследования. Например:
-
Parent = подвид узла, который может иметь потомков
-
Group = подвид Parent, простая обёртка над одним или несколькими другими узлами
-
Region = подвид Parent, описывающий графический компонент — элемент графического интерфейса, видимый пользователю и дающий возможность делать с ним какие-то действия.
-
Control = подвид Region, описывающий обычный компонент, не содержащий внутри себя других компонентов. Примером являются Button, ListView, ScrollBar и другие.
-
Pane = подвид Region, описывающий панель, состоящую из других упорядоченных компонентов. Примером являются BorderPane, DialogPane, HBox, VBox
-
Shape = подвид узла, описывающий разные виды геометрических фигур — этот элемент интерфейса просто изображается на экране, не предоставляя возможности с ним взаимодействовать. Примером фигур являются Circle, Line, Polygon, Rectangle, Text
Для того, чтобы создать JavaFX-приложение, необходим класс, расширяющий javafx.application.Application
. Логика создания главного окна задаётся в его методе start
, будущее окно Stage
передаётся ему как параметр primaryStage
. Необходимо, как минимум:
-
Создать корневой элемент сцены
-
Создать сцену на основе этого элемента и связать её с окном
-
Задать заголовок окна
-
Показать окно, вызвав его функцию
show
Все эти действия выполнены ниже, см. также пример.
public class HelloJavafx extends Application {
@Override
public void start(Stage primaryStage) {
// Создаём корневой элемент сцены
Group root = new Group();
// Создаём сцену и задаём её размеры
Scene scene = new Scene(root, 400, 300);
// Связываем сцену и окно
primaryStage.setScene(scene);
// Задаём заголовок окну
primaryStage.setTitle("Hello, JavaFX");
// Показываем окно
primaryStage.show();
}
public static void main(String[] args) {
// Запускаем JavaFX-приложение
launch(args);
}
}
class HelloView : View("Hello JavaFX") {
override val root = BorderPane()
}
class HelloApp : App(HelloView::class)
fun main(args: Array<String>) {
Application.launch(HelloApp::class.java, *args)
}
Как видно из примера выше, tornadofx добавляет к "китам" JavaFX ещё несколько понятий:
-
App — простая обёртка над JavaFX-приложением, его метод
start(Stage)
выполняет примерно те действия, которые мы воспроизвели выше (в примере для "чистого" JavaFX) при создании главного окна приложения -
View — представление, объединяющее в себе главное окно и его сцену
Для реализации простого tornadofx-приложения нам нужно написать два класса — наследник View
, в котором обязательно переопределить корневой узел сцены root
и задать заголовок View("Hello JavaFX")
, и наследник App
, который через reflection — см. App(HelloView::class)
связывает приложение и представление. Главная функция запускает приложение через Application.launch
, опять-таки используя reflection.
Когда мы разрабатываем GUI-приложение (GUI = Graphical User Interface), важной задачей является отделение внутренней логики приложения от его графической части. Применительно к языкам Java и Kotlin удобно, например, поместить внутреннюю логику в отдельный пакет и полностью абстрагировать её от графической части — например, не применять классы и функции из пакетов javafx
и tornadofx
и их подпакетов. Это позволяет, в частности:
-
разрабатывать и тестировать внутреннюю логику приложения отдельно от его графических функций
-
менять графическую часть приложения (в том числе, используемую библиотеку) без значительного изменения внутренней логики
Общеизвестным шаблоном (pattern), реализующим подобную архитектуру, является MVC = Model-View-Controller (модель-представление-контроллер). В этом шаблоне приложение предполагается делить даже не на две, а на три части:
-
Модель описывает внутреннюю логику, используется и тестируется через её API
-
Представление описывает, как информация из модели представляется пользователю — для JavaFX-приложения это совокупность сцен и диалогов
-
Контроллер описывает, как приложение реагирует на команды пользователю — для JavaFX-приложения это совокупность слушателей
В соответствии с изображением ниже, контроллер изменяет модель, а представление обновляется в соответствии с моделью. При этом пользователь видит представление и использует контроллер для управления приложением.
С исходным кодом данного примера вы можете познакомиться здесь. Это реализация известной логической игры. Фактически это слегка модифицированные крестики-нолики на поле 7х6, в которых нужно составить в ряд четыре своих фишки, а сами фишки не ставятся на поле непосредственно, а закидываются туда сверху и падают в нижний свободный ряд. Подробнее см. по ссылке.
Реализация игры иллюстрирует, в частности, архитектуру модель-представление, с этой целью классы разбиты на следующие пакеты:
-
core — модель, не зависящая от конкретного представления
-
console, swing, javafx — три разных видов представления, позволяющие игре работать соответственно в консоли, под библиотекой Swing и под tornadofx (нас интересует в первую очередь последнее представление)
-
controller — независимая от представления часть контроллера
Модель содержит простые классы, напрямую проецирующиеся на правила игры — клетка Cell
, фишка Chip
, игровое поле Board
. Класс "игровое поле" позволяет, в частности, добавлять фишки на доску makeTurn
и определять победителя winner
. Я не останавливаюсь на этих классах подробно, так как их реализация не очень относится к теме сегодняшнего рассказа.
Некоторые возможности JavaFX и tornadofx проиллюстрированы в пакете javafx. Он содержит классы FourInRowApp
, ChoosePlayerDialog
и FourInRowView
. Поговорим о них подробнее.
Логика, выполняющаяся при запуске приложения, описывается в методе start наследника класса App. Если этот метод не переопределяется, он выполняет действия по умолчанию вроде этих.
public void start(Stage primaryStage) {
Group root = new Group();
Scene scene = new Scene(root, 400, 300);
primaryStage.setScene(scene);
primaryStage.setTitle("Hello, JavaFX");
primaryStage.show();
}
Во многих случаях, однако, логика запуска приложения не ограничивается созданием одного окна. В частности, Intellij IDEA, будучи запущенной в первый раз, перед запуском основного окна с редактором демонстрирует нам окно создания нового проекта, а также в ходе своей инициализации показывает так называемый Splash Screen. Приложение "4 в ряд" тоже перед появлением на экране основного окна предлагает нам с помощью уже знакомого диалога выбрать участников игры.
Диалогом обычно называется особый вид окна, предлагающий пользователю сделать некий выбор. Он отличается от обычного окна своей модальностью — если диалог появился на экране, нам не удастся его убрать и переключиться на другое окно данного приложения до тех пор, пока мы не сделаем требуемый выбор и не закроем диалог нажатием одной из кнопок в нём (как, скажем, Start game и Quit в рассматриваемой игре). В частности, окна сохранения и загрузки файла в Microsoft Word и других известных приложениях тоже являются диалогами.
В JavaFX класс Dialog
содержит обёртку над узлом DialogPane
— панелью диалога. Наследник класса, например ChoosePlayerDialog
, устанавливает содержимое этой панели.
class ChoosePlayerDialog : Dialog<ButtonType>() {
private val yellowPlayer = SimpleStringProperty()
val yellowComputer: Boolean get() = yellowPlayer.value == "Computer"
private val redPlayer = SimpleStringProperty()
val redComputer: Boolean get() = redPlayer.value == "Computer"
init {
title = "Four-in-Row"
with (dialogPane) {
headerText = "Choose players"
buttonTypes.add(ButtonType("Start Game", ButtonBar.ButtonData.OK_DONE))
buttonTypes.add(ButtonType("Quit", ButtonBar.ButtonData.CANCEL_CLOSE))
content = hbox {
vbox {
label("Yellow")
togglegroup {
bind(yellowPlayer)
radiobutton("Human") {
isSelected = true
}
radiobutton("Computer")
}
}
spacer(Priority.ALWAYS)
vbox {
label("Red")
togglegroup {
bind(redPlayer)
radiobutton("Human")
radiobutton("Computer") {
isSelected = true
}
}
}
}
}
}
}
В начале нашего рассказа мы уже вкратце обсудили механизм вкладывания узлов друг в друга, который использует DSL tornadofx. Здесь вложение узлов описывается на уровне DSL фигурными скобками. Обратите внимание на специальный узел spacer
, использованный между двумя вертикальными секциями. Этот узел не видим на экране, а по смыслу он является подобием "пружинки". В данном случае она прижимает одну вертикальную секцию к левой стороне диалога, а другую — к правой.
Дополнительным механизмом, часто использующимся в JavaFX диалогах, является связывание "модели" и "представления" диалога с помощью механизма JavaFX-свойств. В данном случае yellowPlayer
и redPlayer
как раз и являются строковыми JavaFX-свойствами — элементами модели диалога model
. Связывание их с представлением диалога делается в основном коде
togglegroup {
bind(yellowPlayer)
radiobutton("Human") {
isSelected = true
}
radiobutton("Computer")
}
Здесь создаётся "группа выбора" togglegroup с двумя радио-кнопками внутри. Вызов bind
связывает выбор, сделанный пользователем, с содержимым свойства yellowPlayer
. Например, если пользователь выбрал кнопку "Human", то и значением yellowPlayer.value
будет строка "Human". Это позволяет, во-первых, проверить состояние диалога без залезания вглубь его структуры, а во-вторых, изменить это состояние программно через изменение значения свойства yellowPlayer
.
Появление диалога на экране выполняется вызовом его метода showAndWait
. Далее, анализируя результат этого метода, мы можем узнать, какая из кнопок диалога в данном случае была нажата и выполнить связанные с ней действия.
override fun start(stage: Stage) {
val dialog = ChoosePlayerDialog()
val result = dialog.showAndWait()
if (result.isPresent && result.get().buttonData == ButtonBar.ButtonData.OK_DONE) {
// Нажата ОК
yellowHuman = !dialog.yellowComputer
redHuman = !dialog.redComputer
super.start(stage)
}
}
Вызов super.start(stage)
переходит к выполнению метода start
базового класса, реализующего, как уже говорилось, стандартную логику создания главного окна.
Класс FourInRowView реализует представление нашего приложения. Он инкапсулирует в себе ссылку на модель (игровое поле) и содержимое сцены главного окна. Отметим следующие моменты.
Корневым элементом сцены в данном случае является BorderPane
. Это панель, содержащая в пределе из пяти частей — верхней, нижней, левой, правой и центральной. При изменении размера этой панели она автоматически "подстраивает" размеры своих частей под свой собственный размер. В приложении "4 в ряд" верхняя часть top
содержит меню menu
и панель инструментов toolbar
, нижняя часть bottom
содержит метку статуса, а центральная часть center
— представление игрового поля.
Для отображения игрового поля используется другой вид панели — сеточная панель GridPane
. Такая панель подобна таблице, и она также умеет подстраивать размеры своих ячеек под свой собственный размер. В данном случае каждый элемент панели — это кнопка button
, на которую можно нажать, при этом происходит падение фишки в соответствующий столбец (высота кнопка значения при этом не имеет).
Для обработки событий используется стандартный механизм "слушателей".
menu("Game") {
item("Restart").action {
// При выборе пункта меню Game > Restart вызовется функция restartGame
restartGame()
}
// ...
}
// ...
val button = button {
style {
backgroundColor += Color.GRAY
minWidth = dimension
minHeight = dimension
}
}
button.action {
// При нажатии на кнопку выполнится этот код
if (inProcess) {
listener.cellClicked(cell)
}
}
Из приведённого кода видно, что одним из способов создания слушателя в tornadofx является вызов функции action
на определённом "нажимаемом" компоненте, с указанием выполняемых действий в соответствующей лямбде.
В "чистом" JavaFX обработка событий осуществляется с помощью интерфейса EventListener
и множества его подинтерфейсов, в частности, EventHandler
в данном случае, а код регистрации слушателя выглядит примерно так:
button.setOnAction(new EventHandler() {
@Override
public void handle(ActionEvent event) {
System.out.println("Вы только что нажали на кнопку!");
}
});
К имеющимся стандартным событиям JavaFX позволяет добавить свои. В приложении "4 в ряд" подобный механизм используется для обработки событий таймера.
private fun startTimerIfNeeded() {
if (yellowComputer != null || redComputer != null) {
timer(daemon = true, period = 1000) {
if (inProcess) {
computerToMakeTurn?.let {
fire(AutoTurnEvent(it))
}
} else {
this.cancel()
}
}
}
}
Таймер — специальный компонент, умеющий что-то делать периодически. Наш таймер срабатывает раз в 1000 миллисекунд и работает в режиме "демона", то есть не препятствует завершению приложения. Используется он для выполнения ходов компьютерным игроком, с тем, чтобы между ходом человека и ходом компьютера была некоторая пауза, благодаря которой человек может лучше понять, какой же именно ход был выполнен компьютером. В качестве "своего" события используется класс private data class AutoTurnEvent(val player: ComputerPlayer) : FXEvent()
, а для активизации события — функция fire
.
Обработка события выполняется с помощью функции subscribe
:
subscribe<AutoTurnEvent> {
it.player.makeComputerTurn()
}
Многие графические библиотеки позволяют, как иногда говорят, "писать программы мышкой". Под этим обычно имеют в виду возможность создания графических интерфейсов в редакторе, подобном Paint. Такой редактор включает в себя палитру компонентов и возможность редактировать их различные свойства. Результатом работы редактора, как правило, является описание интерфейса во внутреннем формате (часто с этой целью используется XML-формат) и/или готовый код на языке программирования, создающий нарисованный графический интерфейс.
В библиотеке JavaFX с этой целью используется инструмент под названием JavaFX Scene Builder, скачать его можно отсюда. Построитель сцен (после установки) интегрируется со всеми основными Java IDE, в частности, с Intellij IDEA и с NetBeans IDE. Результатом работы построителя сцен является FXML-файл, код для его использования в программе выглядит примерно так (пример взят с сайта Oracle).
@Override
public void start(Stage stage) throws Exception {
Parent root = FXMLLoader.load(getClass().getResource("/some.fxml"));
Scene scene = new Scene(root, 400, 300);
stage.setTitle("Welcome to FXML!");
stage.setScene(scene);
stage.show();
}