Skip to content

Latest commit

 

History

History
367 lines (290 loc) · 32.4 KB

10_JavaFX.adoc

File metadata and controls

367 lines (290 loc) · 32.4 KB

JavaFX-приложения с графическим интерфейсом

Сегодня мы поговорим о приложениях, созданных с помощью библиотеки JavaFX. Эта библиотека появилась в 2007 году, она предназначена для проектирования приложений с графическим интерфейсом — как Desktop-приложений (работающих на обычном компьютере), так и Web-приложений (работающих в браузере). Библиотека была призвана заменить GUI-библиотеку предыдущего поколения — Swing.

Первые годы JavaFX существовала отдельно от основной платформы Java, и язык для её программирования назывался JavaFX Script. В 2014 году библиотека вошла в состав JDK 1.8 и получила полноценную поддержку со стороны языка Java. В 2018 году при выпуске JDK 11 JavaFX убрали из состава JDK и выделили в отдельную библиотеку.

Поддержка на Java и Kotlin

Библиотека 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

Dialog

Простое приложение на JavaFX

Как подробно написано в этой статье, 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);
    }
}

Простое приложение на tornadofx

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-приложение (GUI = Graphical User Interface), важной задачей является отделение внутренней логики приложения от его графической части. Применительно к языкам Java и Kotlin удобно, например, поместить внутреннюю логику в отдельный пакет и полностью абстрагировать её от графической части — например, не применять классы и функции из пакетов javafx и tornadofx и их подпакетов. Это позволяет, в частности:

  • разрабатывать и тестировать внутреннюю логику приложения отдельно от его графических функций

  • менять графическую часть приложения (в том числе, используемую библиотеку) без значительного изменения внутренней логики

Общеизвестным шаблоном (pattern), реализующим подобную архитектуру, является MVC = Model-View-Controller (модель-представление-контроллер). В этом шаблоне приложение предполагается делить даже не на две, а на три части:

  • Модель описывает внутреннюю логику, используется и тестируется через её API

  • Представление описывает, как информация из модели представляется пользователю — для JavaFX-приложения это совокупность сцен и диалогов

  • Контроллер описывает, как приложение реагирует на команды пользователю — для JavaFX-приложения это совокупность слушателей

В соответствии с изображением ниже, контроллер изменяет модель, а представление обновляется в соответствии с моделью. При этом пользователь видит представление и использует контроллер для управления приложением.

MVC

Пример: игра "4 в ряд"

С исходным кодом данного примера вы можете познакомиться здесь. Это реализация известной логической игры. Фактически это слегка модифицированные крестики-нолики на поле 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();
}