Навігація та управління екранами у JavaFX MVVM
Навігація та управління екранами у JavaFX MVVM
Вступ: Проблема переходів між екранами
Користувач переглядає список аудіокниг. Натискає "Add Audiobook" → відкривається форма додавання. Заповнює форму, натискає "Save" → повертається до списку, де бачить нову аудіокнигу. Натискає на аудіокнигу → відкривається екран деталей. Натискає "Edit" → відкривається форма редагування з даними цієї аудіокниги.
Це типовий сценарій навігації у desktop-додатку. Але хто керує цими переходами? Controller? ViewModel? Application?
У наївному підході Controller містить код навігації:
@FXML
private void onAddClicked() {
try {
FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-form-view.fxml"));
Parent root = loader.load();
AudiobookFormController controller = loader.getController();
controller.setViewModel(new AudiobookFormViewModel(service));
Stage stage = (Stage) addButton.getScene().getWindow();
stage.getScene().setRoot(root);
} catch (IOException e) {
e.printStackTrace();
}
}
Проблеми цього підходу:
Проблема 1: Дублювання коду. Кожен Controller, що виконує навігацію, містить однаковий код завантаження FXML, створення ViewModel, зміни Scene. Якщо у додатку 10 екранів, це 10 копій цього коду.
Проблема 2: Порушення Single Responsibility. Controller не повинен знати, як завантажувати FXML, як створювати ViewModel, як отримувати Stage. Його відповідальність — з'єднувати View з ViewModel.
Проблема 3: Жорстке кодування шляхів. "audiobook-form-view.fxml" — це magic string. Якщо перейменувати файл, доведеться шукати всі місця, де він використовується.
Проблема 4: Складність передачі параметрів. Як передати Audiobook з екрану списку у форму редагування? Через Singleton ViewModel? Через глобальну змінну? Це призводить до сильної зв'язаності.
Проблема 5: Відсутність історії навігації. Як реалізувати кнопку "Back"? Потрібно зберігати стек попередніх екранів, але де?
Рішення всіх цих проблем — Navigator Pattern: централізований компонент, що керує навігацією. Він знає, як завантажувати екрани, як передавати параметри, як зберігати історію, як відкривати діалоги.
У цій статті ми побудуємо повноцінну систему навігації для MVVM-додатку. Ми розглянемо:
- Navigator Pattern для централізованої навігації.
- ScreenRegistry для реєстрації екранів (замість magic strings).
- Передачу параметрів між екранами (через ViewModel, через Navigator, через AssistedInject).
- Navigation Stack для історії переходів (кнопка "Back").
- Модальні діалоги з поверненням результату.
- Інтеграцію Navigator з ViewModel (як ViewModel ініціює навігацію без порушення MVVM).
Патерни навігації у desktop-додатках
Перш ніж писати код, розглянемо три основні патерни навігації у JavaFX-додатках:
1. Single Window Navigation: Заміна вмісту одного вікна
Концепція: Один Stage (вікно), одна Scene, але Root Node змінюється при навігації.
// Перехід з екрану A на екран B
Parent screenB = loadFxml("screen-b.fxml");
stage.getScene().setRoot(screenB);
Переваги:
- Просто реалізувати.
- Один екран на весь екран — немає накладання вікон.
- Легко керувати розміром вікна (один Stage).
Недоліки:
- Неможливо показати два екрани одночасно (наприклад, список + деталі).
- Складно реалізувати модальні діалоги (потрібен окремий Stage).
Коли використовувати: Додатки з лінійною навігацією (wizard, форми), мобільні додатки (один екран на весь екран).
2. Multiple Windows: Відкриття нових Stage
Концепція: Кожен екран — це окремий Stage (вікно). Навігація = відкриття нового вікна.
// Відкриття нового вікна
Stage newStage = new Stage();
newStage.setScene(new Scene(loadFxml("screen-b.fxml")));
newStage.show();
Переваги:
- Можна показати кілька екранів одночасно.
- Природна підтримка модальних діалогів (
stage.initModality(Modality.APPLICATION_MODAL)). - Користувач може розташовувати вікна як хоче.
Недоліки:
- Складно керувати життєвим циклом вікон (коли закривати, як передавати дані між вікнами).
- Може бути багато відкритих вікон → плутанина.
Коли використовувати: Складні додатки з багатьма незалежними екранами (IDE, графічні редактори), діалоги.
3. Master-Detail: Розділений екран
Концепція: Один екран розділений на дві частини: Master (список) та Detail (деталі вибраного елемента).
// BorderPane: ліворуч список, праворуч деталі
BorderPane root = new BorderPane();
root.setLeft(loadFxml("audiobook-list.fxml"));
root.setRight(loadFxml("audiobook-detail.fxml"));
Переваги:
- Два екрани одночасно — зручно для перегляду списку та деталей.
- Немає переходів між екранами — все на одному екрані.
Недоліки:
- Обмежений простір для кожної частини.
- Складно адаптувати під різні розміри екрану.
Коли використовувати: Додатки з чіткою структурою Master-Detail (email-клієнти, файлові менеджери).
Порівняння патернів
Single Window
Переваги:
- Простота реалізації
- Один екран на весь екран
- Легке керування розміром
Недоліки:
- Неможливо показати два екрани
- Складні модальні діалоги
Використання: Wizard, форми, мобільні додатки
Multiple Windows
Переваги:
- Кілька екранів одночасно
- Природні модальні діалоги
- Гнучке розташування
Недоліки:
- Складне керування життєвим циклом
- Можлива плутанина з вікнами
Використання: IDE, графічні редактори, складні додатки
Master-Detail
Переваги:
- Два екрани одночасно
- Немає переходів
- Зручний перегляд
Недоліки:
- Обмежений простір
- Складна адаптація
Використання: Email-клієнти, файлові менеджери
Наш вибір: Ми реалізуємо Single Window Navigation з підтримкою Multiple Windows для діалогів. Це найпоширеніший підхід для desktop-додатків: основна навігація через заміну Root Node, діалоги через окремі Stage.
Navigator Pattern: Централізоване управління навігацією
Navigator — це клас, що інкапсулює всю логіку навігації: завантаження FXML, створення Controllers через Guice, зміну Root Node, відкриття діалогів.
Інтерфейс Navigator
package dev.kostyl.audiobook.infrastructure.navigation;
import javafx.scene.Parent;
import java.util.Map;
import java.util.Optional;
public interface Navigator {
/**
* Перехід на екран за його ідентифікатором.
*
* @param screenId ідентифікатор екрану (наприклад, "audiobook-list")
*/
void navigateTo(String screenId);
/**
* Перехід на екран з параметрами.
*
* @param screenId ідентифікатор екрану
* @param params параметри для передачі у ViewModel
*/
void navigateTo(String screenId, Map<String, Object> params);
/**
* Відкриття модального діалогу.
*
* @param screenId ідентифікатор діалогу
* @return результат діалогу (Optional.empty() якщо скасовано)
*/
<T> Optional<T> openDialog(String screenId);
/**
* Відкриття модального діалогу з параметрами.
*
* @param screenId ідентифікатор діалогу
* @param params параметри для передачі у ViewModel
* @return результат діалогу
*/
<T> Optional<T> openDialog(String screenId, Map<String, Object> params);
/**
* Повернення на попередній екран (Back button).
*/
void goBack();
/**
* Перевірка, чи можна повернутися назад.
*
* @return true якщо є попередній екран у стеку
*/
boolean canGoBack();
}
Цей інтерфейс визначає контракт Navigator: які операції він підтримує. Тепер створимо реалізацію.
Реалізація FxmlNavigator
package dev.kostyl.audiobook.infrastructure.navigation;
import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Singleton;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.io.IOException;
import java.util.*;
@Singleton
public class FxmlNavigator implements Navigator {
private final Injector injector;
private final ScreenRegistry screenRegistry;
private final Stack<NavigationEntry> navigationStack;
private Stage primaryStage;
@Inject
public FxmlNavigator(Injector injector, ScreenRegistry screenRegistry) {
this.injector = injector;
this.screenRegistry = screenRegistry;
this.navigationStack = new Stack<>();
}
/**
* Встановлення primary Stage (викликається з Application.start()).
*/
public void setPrimaryStage(Stage primaryStage) {
this.primaryStage = primaryStage;
}
@Override
public void navigateTo(String screenId) {
navigateTo(screenId, Collections.emptyMap());
}
@Override
public void navigateTo(String screenId, Map<String, Object> params) {
try {
// Завантаження FXML
String fxmlPath = screenRegistry.getFxmlPath(screenId);
Parent root = loadFxml(fxmlPath);
// Збереження поточного екрану у стек
if (primaryStage.getScene() != null && primaryStage.getScene().getRoot() != null) {
navigationStack.push(new NavigationEntry(
getCurrentScreenId(),
primaryStage.getScene().getRoot()
));
}
// Зміна Root Node
if (primaryStage.getScene() == null) {
primaryStage.setScene(new Scene(root));
} else {
primaryStage.getScene().setRoot(root);
}
// Передача параметрів у ViewModel (якщо потрібно)
// TODO: реалізувати передачу параметрів
} catch (IOException e) {
throw new NavigationException("Failed to navigate to screen: " + screenId, e);
}
}
@Override
public <T> Optional<T> openDialog(String screenId) {
return openDialog(screenId, Collections.emptyMap());
}
@Override
public <T> Optional<T> openDialog(String screenId, Map<String, Object> params) {
try {
// Завантаження FXML
String fxmlPath = screenRegistry.getFxmlPath(screenId);
FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath));
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load();
// Створення модального Stage
Stage dialogStage = new Stage();
dialogStage.initModality(Modality.APPLICATION_MODAL);
dialogStage.initOwner(primaryStage);
dialogStage.setScene(new Scene(root));
// Показ діалогу (блокує виконання до закриття)
dialogStage.showAndWait();
// Отримання результату з Controller
Object controller = loader.getController();
if (controller instanceof DialogController) {
return Optional.ofNullable(((DialogController<T>) controller).getResult());
}
return Optional.empty();
} catch (IOException e) {
throw new NavigationException("Failed to open dialog: " + screenId, e);
}
}
@Override
public void goBack() {
if (!canGoBack()) {
throw new IllegalStateException("Cannot go back: navigation stack is empty");
}
NavigationEntry previousEntry = navigationStack.pop();
primaryStage.getScene().setRoot(previousEntry.getRoot());
}
@Override
public boolean canGoBack() {
return !navigationStack.isEmpty();
}
private Parent loadFxml(String fxmlPath) throws IOException {
FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath));
loader.setControllerFactory(injector::getInstance);
return loader.load();
}
private String getCurrentScreenId() {
// TODO: зберігати поточний screenId при навігації
return "unknown";
}
/**
* Запис у стеку навігації.
*/
private static class NavigationEntry {
private final String screenId;
private final Parent root;
public NavigationEntry(String screenId, Parent root) {
this.screenId = screenId;
this.root = root;
}
public String getScreenId() {
return screenId;
}
public Parent getRoot() {
return root;
}
}
}
Розбір реалізації
Рядки 20-21: Залежності. Injector для створення Controllers через Guice. ScreenRegistry для отримання шляхів до FXML за ідентифікатором екрану.
Рядок 22: Navigation Stack. Stack<NavigationEntry> зберігає історію навігації. Кожен запис містить screenId та Parent (Root Node екрану).
Рядок 24: Primary Stage. Посилання на головне вікно додатку. Встановлюється з Application.start() через setPrimaryStage().
Рядки 44-64: navigateTo(). Завантажує FXML через loadFxml(), зберігає поточний екран у стек, змінює Root Node у Scene. Якщо Scene ще не створена (перший запуск), створює нову Scene.
Рядки 76-103: openDialog(). Завантажує FXML, створює новий Stage з модальністю APPLICATION_MODAL (блокує головне вікно), показує діалог через showAndWait() (блокує виконання до закриття), отримує результат з Controller через інтерфейс DialogController.
Рядки 107-114: goBack(). Витягує попередній екран зі стеку та встановлює його як Root Node. Якщо стек порожній, викидає виняток.
Рядки 382-386: loadFxml(). Створює FXMLLoader, встановлює ControllerFactory для інтеграції з Guice, завантажує FXML та повертає Root Node.
Рядки 396-412: NavigationEntry. Внутрішній клас для зберігання запису у стеку навігації. Містить screenId (для відладки) та Parent (Root Node екрану).
ScreenRegistry: Реєстрація екранів
Замість magic strings ("audiobook-list-view.fxml") створимо ScreenRegistry — централізований реєстр екранів.
Реалізація ScreenRegistry
package dev.kostyl.audiobook.infrastructure.navigation;
import com.google.inject.Singleton;
import java.util.HashMap;
import java.util.Map;
@Singleton
public class ScreenRegistry {
private final Map<String, ScreenDefinition> screens = new HashMap<>();
public ScreenRegistry() {
registerScreens();
}
private void registerScreens() {
// Основні екрани
register("audiobook-list", "/fxml/audiobook-list-view.fxml", "Audiobook Platform");
register("audiobook-form", "/fxml/audiobook-form-view.fxml", "Add Audiobook");
register("audiobook-detail", "/fxml/audiobook-detail-view.fxml", "Audiobook Details");
register("author-list", "/fxml/author-list-view.fxml", "Authors");
register("author-form", "/fxml/author-form-view.fxml", "Add Author");
register("genre-list", "/fxml/genre-list-view.fxml", "Genres");
// Dialogs
register("confirm-delete", "/fxml/dialogs/confirm-delete-dialog.fxml", "Confirm Delete");
register("select-author", "/fxml/dialogs/select-author-dialog.fxml", "Select Author");
}
private void register(String screenId, String fxmlPath, String title) {
screens.put(screenId, new ScreenDefinition(screenId, fxmlPath, title));
}
public String getFxmlPath(String screenId) {
ScreenDefinition screen = screens.get(screenId);
if (screen == null) {
throw new IllegalArgumentException("Screen not found: " + screenId);
}
return screen.getFxmlPath();
}
public String getTitle(String screenId) {
ScreenDefinition screen = screens.get(screenId);
if (screen == null) {
throw new IllegalArgumentException("Screen not found: " + screenId);
}
return screen.getTitle();
}
public boolean isRegistered(String screenId) {
return screens.containsKey(screenId);
}
/**
* Визначення екрану.
*/
private static class ScreenDefinition {
private final String screenId;
private final String fxmlPath;
private final String title;
public ScreenDefinition(String screenId, String fxmlPath, String title) {
this.screenId = screenId;
this.fxmlPath = fxmlPath;
this.title = title;
}
public String getScreenId() {
return screenId;
}
public String getFxmlPath() {
return fxmlPath;
}
public String getTitle() {
return title;
}
}
}
Переваги ScreenRegistry:
- Централізація: Всі екрани зареєстровані в одному місці. Легко побачити, які екрани є у додатку.
- Відсутність magic strings: Замість
"audiobook-list-view.fxml"використовуємо"audiobook-list". Якщо перейменувати файл, змінюємо лише Registry. - Додаткова інформація: Можна зберігати не лише шлях до FXML, а й title (для встановлення заголовку вікна), розмір вікна, іконку.
- Валідація:
getFxmlPath()викидає виняток, якщо екран не зареєстрований. Це виявляє помилки на етапі розробки.
Використання Navigator у Controller
public class AudiobookListController {
private final AudiobookListViewModel viewModel;
private final Navigator navigator;
@Inject
public AudiobookListController(AudiobookListViewModel viewModel, Navigator navigator) {
this.viewModel = viewModel;
this.navigator = navigator;
}
@FXML
private void onAddClicked() {
navigator.navigateTo("audiobook-form");
}
@FXML
private void onEditClicked() {
if (viewModel.getSelectedAudiobook() != null) {
navigator.navigateTo("audiobook-form", Map.of(
"audiobook", viewModel.getSelectedAudiobook().getAudiobook()
));
}
}
@FXML
private void onDeleteClicked() {
Optional<Boolean> result = navigator.openDialog("confirm-delete");
if (result.isPresent() && result.get()) {
viewModel.deleteSelected();
}
}
@FXML
private void onBackClicked() {
if (navigator.canGoBack()) {
navigator.goBack();
}
}
}
Тепер Controller не містить коду завантаження FXML — лише виклики navigator.navigateTo() та navigator.openDialog(). Весь складний код інкапсульований у Navigator.
Передача параметрів між екранами
Як передати Audiobook з екрану списку у форму редагування? Є три підходи:
Підхід 1: Через Singleton ViewModel
Концепція: AudiobookListViewModel — Singleton. Він зберігає selectedAudiobook. AudiobookFormViewModel отримує його через ін'єкцію.
@Singleton
public class AudiobookListViewModel {
private final ObjectProperty<Audiobook> selectedAudiobook = new SimpleObjectProperty<>();
// Getters
}
public class AudiobookFormViewModel {
private final AudiobookListViewModel listViewModel;
@Inject
public AudiobookFormViewModel(AudiobookListViewModel listViewModel) {
this.listViewModel = listViewModel;
// Завантаження даних з selectedAudiobook
Audiobook audiobook = listViewModel.getSelectedAudiobook();
if (audiobook != null) {
loadAudiobook(audiobook);
}
}
}
Переваги: Просто реалізувати, не потрібен Navigator для передачі параметрів.
Недоліки: Сильна зв'язаність між ViewModels. AudiobookFormViewModel залежить від AudiobookListViewModel, хоча логічно вони незалежні. Складно тестувати.
Підхід 2: Через параметри Navigator
Концепція: Navigator.navigateTo(screenId, params) приймає Map<String, Object> з параметрами. Navigator передає їх у ViewModel через setter або метод initialize().
// Controller
navigator.navigateTo("audiobook-form", Map.of("audiobook", audiobook));
// Navigator
@Override
public void navigateTo(String screenId, Map<String, Object> params) {
Parent root = loadFxml(fxmlPath);
// Отримання Controller з FXMLLoader
Object controller = loader.getController();
// Передача параметрів у ViewModel через Controller
if (controller instanceof ParameterizedController) {
((ParameterizedController) controller).setParameters(params);
}
primaryStage.getScene().setRoot(root);
}
// Controller
public class AudiobookFormController implements ParameterizedController {
@Override
public void setParameters(Map<String, Object> params) {
Audiobook audiobook = (Audiobook) params.get("audiobook");
if (audiobook != null) {
viewModel.loadAudiobook(audiobook);
}
}
}
Переваги: Явна передача параметрів, немає зв'язаності між ViewModels.
Недоліки: Потрібен інтерфейс ParameterizedController, type-safety відсутня (Map з Object).
Підхід 3: Через AssistedInject (Factory)
Концепція: ViewModel створюється через Factory з параметром. Navigator викликає Factory замість injector.getInstance().
// Factory
public interface AudiobookFormViewModelFactory {
AudiobookFormViewModel create(@Nullable Audiobook audiobook);
}
// ViewModel
public class AudiobookFormViewModel {
@Inject
public AudiobookFormViewModel(
@Assisted @Nullable Audiobook audiobook,
AudiobookService service
) {
if (audiobook != null) {
loadAudiobook(audiobook);
}
}
}
// Navigator (модифікований)
public void navigateTo(String screenId, Audiobook audiobook) {
AudiobookFormViewModelFactory factory = injector.getInstance(AudiobookFormViewModelFactory.class);
AudiobookFormViewModel viewModel = factory.create(audiobook);
// Завантаження FXML та передача ViewModel у Controller
// ...
}
Переваги: Type-safety, явні залежності, легко тестувати.
Недоліки: Складніша реалізація, потрібна інтеграція Factory з Navigator.
Інтеграція Navigator з ViewModel
У MVVM ViewModel не повинен знати про Navigator безпосередньо — це порушує принцип розділення відповідальностей. Але як ViewModel ініціює навігацію (наприклад, після успішного збереження)?
Проблема: ViewModel не може викликати Navigator
// ПОГАНО: ViewModel залежить від Navigator
public class AudiobookFormViewModel {
private final Navigator navigator;
@Inject
public AudiobookFormViewModel(Navigator navigator) {
this.navigator = navigator;
}
public void save() {
// Збереження аудіокниги
audiobookService.save(audiobook);
// Навігація назад
navigator.goBack(); // Порушення MVVM!
}
}
Це порушує MVVM: ViewModel не повинен знати про UI-логіку (навігацію). Крім того, це ускладнює тестування: потрібно mock Navigator.
Рішення 1: Events через Property
ViewModel експонує ObjectProperty<NavigationRequest>, Controller слухає зміни та викликає Navigator.
// NavigationRequest — immutable об'єкт з інформацією про навігацію
public class NavigationRequest {
private final String screenId;
private final Map<String, Object> params;
private final NavigationType type; // NAVIGATE, GO_BACK, OPEN_DIALOG
public NavigationRequest(String screenId) {
this(screenId, Collections.emptyMap(), NavigationType.NAVIGATE);
}
public NavigationRequest(String screenId, Map<String, Object> params) {
this(screenId, params, NavigationType.NAVIGATE);
}
public NavigationRequest(String screenId, Map<String, Object> params, NavigationType type) {
this.screenId = screenId;
this.params = params;
this.type = type;
}
// Getters
}
public enum NavigationType {
NAVIGATE, GO_BACK, OPEN_DIALOG
}
ViewModel:
public class AudiobookFormViewModel {
private final ObjectProperty<NavigationRequest> navigationRequest = new SimpleObjectProperty<>();
public void save() {
// Збереження аудіокниги
audiobookService.save(audiobook);
// Запит на навігацію назад
navigationRequest.set(new NavigationRequest(null, null, NavigationType.GO_BACK));
}
public ObjectProperty<NavigationRequest> navigationRequestProperty() {
return navigationRequest;
}
}
Controller:
public class AudiobookFormController {
@FXML
public void initialize() {
setupBindings();
setupNavigationListener();
}
private void setupNavigationListener() {
viewModel.navigationRequestProperty().addListener((obs, old, request) -> {
if (request != null) {
handleNavigationRequest(request);
viewModel.navigationRequestProperty().set(null); // Reset
}
});
}
private void handleNavigationRequest(NavigationRequest request) {
switch (request.getType()) {
case NAVIGATE:
navigator.navigateTo(request.getScreenId(), request.getParams());
break;
case GO_BACK:
navigator.goBack();
break;
case OPEN_DIALOG:
navigator.openDialog(request.getScreenId(), request.getParams());
break;
}
}
}
Переваги: ViewModel не залежить від Navigator, легко тестувати (перевірити, що navigationRequest встановлено).
Недоліки: Додатковий boilerplate-код (NavigationRequest, listener у Controller).
Рішення 2: Callback через інтерфейс
ViewModel приймає callback-інтерфейс у конструкторі. Controller передає лямбду, що викликає Navigator.
// Інтерфейс для callback
public interface NavigationCallback {
void navigateTo(String screenId);
void goBack();
}
// ViewModel
public class AudiobookFormViewModel {
private final NavigationCallback navigationCallback;
@Inject
public AudiobookFormViewModel(
AudiobookService service,
@Assisted NavigationCallback navigationCallback
) {
this.audiobookService = service;
this.navigationCallback = navigationCallback;
}
public void save() {
audiobookService.save(audiobook);
navigationCallback.goBack();
}
}
// Controller
public class AudiobookFormController {
@Inject
public AudiobookFormController(AudiobookFormViewModelFactory factory, Navigator navigator) {
this.viewModel = factory.create(new NavigationCallback() {
@Override
public void navigateTo(String screenId) {
navigator.navigateTo(screenId);
}
@Override
public void goBack() {
navigator.goBack();
}
});
}
}
Переваги: Простіше, ніж Events, явний контракт (інтерфейс).
Недоліки: ViewModel залежить від NavigationCallback (хоча це абстракція, не конкретна реалізація).
Модальні діалоги з поверненням результату
Діалоги — це окремі екрани, що відкриваються поверх головного вікна та повертають результат (наприклад, вибраний елемент, підтвердження).
Інтерфейс DialogController
package dev.kostyl.audiobook.infrastructure.navigation;
import java.util.Optional;
public interface DialogController<T> {
/**
* Отримання результату діалогу.
*
* @return результат (Optional.empty() якщо діалог скасовано)
*/
Optional<T> getResult();
}
Приклад: Діалог підтвердження видалення
confirm-delete-dialog.fxml:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<VBox xmlns:fx="http://javafx.com/fxml"
fx:controller="dev.kostyl.audiobook.controller.ConfirmDeleteDialogController"
spacing="20" padding="20" prefWidth="400">
<Label text="Are you sure you want to delete this audiobook?"
style="-fx-font-size: 14px;"/>
<Label fx:id="audiobookTitleLabel"
style="-fx-font-weight: bold; -fx-font-size: 16px;"/>
<HBox spacing="10" alignment="CENTER_RIGHT">
<Button fx:id="cancelButton" text="Cancel" onAction="#onCancelClicked"/>
<Button fx:id="confirmButton" text="Delete"
styleClass="danger-button" onAction="#onConfirmClicked"/>
</HBox>
</VBox>
ConfirmDeleteDialogController:
package dev.kostyl.audiobook.controller;
import dev.kostyl.audiobook.infrastructure.navigation.DialogController;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.stage.Stage;
import java.util.Optional;
public class ConfirmDeleteDialogController implements DialogController<Boolean> {
@FXML private Label audiobookTitleLabel;
@FXML private Button cancelButton;
@FXML private Button confirmButton;
private Boolean result;
@FXML
public void initialize() {
// Можна встановити title через параметри Navigator
audiobookTitleLabel.setText("Selected audiobook");
}
@FXML
private void onConfirmClicked() {
result = true;
closeDialog();
}
@FXML
private void onCancelClicked() {
result = false;
closeDialog();
}
private void closeDialog() {
Stage stage = (Stage) cancelButton.getScene().getWindow();
stage.close();
}
@Override
public Optional<Boolean> getResult() {
return Optional.ofNullable(result);
}
}
Використання у Controller:
@FXML
private void onDeleteClicked() {
Optional<Boolean> result = navigator.openDialog("confirm-delete");
if (result.isPresent() && result.get()) {
viewModel.deleteSelected();
}
}
Приклад: Діалог вибору автора
SelectAuthorDialogController:
public class SelectAuthorDialogController implements DialogController<Author> {
@FXML private TableView<Author> authorTable;
@FXML private Button selectButton;
@FXML private Button cancelButton;
private Author selectedAuthor;
@Inject
private AuthorRepository authorRepository;
@FXML
public void initialize() {
// Завантаження авторів
authorTable.getItems().addAll(authorRepository.findAll());
// Активація кнопки Select лише коли щось обрано
selectButton.disableProperty().bind(
authorTable.getSelectionModel().selectedItemProperty().isNull()
);
}
@FXML
private void onSelectClicked() {
selectedAuthor = authorTable.getSelectionModel().getSelectedItem();
closeDialog();
}
@FXML
private void onCancelClicked() {
selectedAuthor = null;
closeDialog();
}
private void closeDialog() {
Stage stage = (Stage) selectButton.getScene().getWindow();
stage.close();
}
@Override
public Optional<Author> getResult() {
return Optional.ofNullable(selectedAuthor);
}
}
Використання:
@FXML
private void onSelectAuthorClicked() {
Optional<Author> result = navigator.openDialog("select-author");
if (result.isPresent()) {
viewModel.setSelectedAuthor(result.get());
}
}
getResult() повертає Optional.empty(). Це явно вказує, що результату немає, і змушує викликаючий код обробити цей випадок.Практичні завдання
Рівень 1: Базова навігація
Завдання 1.1: Створіть ScreenRegistry з реєстрацією трьох екранів: "home", "settings", "about". Створіть FxmlNavigator та реалізуйте метод navigateTo().
Завдання 1.2: Створіть MainController з трьома кнопками: "Home", "Settings", "About". При натисканні кожної кнопки викликайте navigator.navigateTo() з відповідним screenId.
Завдання 1.3: Додайте кнопку "Back" у кожен екран. Прив'яжіть її disableProperty до navigator.canGoBack().not(). При натисканні викликайте navigator.goBack().
Рівень 2: Передача параметрів та діалоги
Завдання 2.1: Реалізуйте передачу параметрів через Navigator.navigateTo(screenId, params). Створіть інтерфейс ParameterizedController з методом setParameters(Map<String, Object>). Модифікуйте FxmlNavigator, щоб він викликав цей метод після завантаження FXML.
Завдання 2.2: Створіть діалог "Select Genre" з TableView жанрів. Реалізуйте DialogController<Genre>. При натисканні "Select" поверніть обраний жанр. При натисканні "Cancel" поверніть Optional.empty().
Завдання 2.3: Реалізуйте навігацію з параметром: при натисканні на аудіокнигу у списку відкривайте екран деталей з передачею Audiobook через параметри Navigator.
Рівень 3: Інтеграція з ViewModel та складні сценарії
Завдання 3.1: Реалізуйте NavigationRequest та інтеграцію з ViewModel. AudiobookFormViewModel.save() має встановлювати navigationRequest у GO_BACK. Controller слухає зміни та викликає navigator.goBack().
Завдання 3.2: Створіть Wizard Pattern: форма з трьома кроками (Step 1: Basic Info, Step 2: Details, Step 3: Confirmation). Кнопки "Next", "Previous", "Finish". Navigator керує переходами між кроками. Дані зберігаються у Singleton ViewModel, доступному всім крокам.
Завдання 3.3: Реалізуйте обмеження розміру Navigation Stack (максимум 10 екранів). При перевищенні видаляйте найстаріший екран зі стеку. Додайте метод navigator.clearHistory() для очищення стеку.
Підсумок
У цій статті ми побудували повноцінну систему навігації для JavaFX MVVM-додатку. Ключові висновки:
Navigator Pattern централізує управління навігацією. Замість дублювання коду завантаження FXML у кожному Controller, весь код інкапсульований у Navigator. Controller лише викликає navigator.navigateTo() або navigator.openDialog().
ScreenRegistry замінює magic strings. Всі екрани зареєстровані у централізованому реєстрі з ідентифікаторами ("audiobook-list", "audiobook-form"). Це дозволяє легко змінювати шляхи до FXML, додавати метадані (title, розмір вікна), валідувати існування екранів.
Navigation Stack зберігає історію переходів. Кожен перехід зберігає поточний екран у стек. Метод goBack() витягує попередній екран зі стеку та відновлює його зі збереженим станом (прокрутка, вибір, введені дані).
Три підходи до передачі параметрів: Singleton ViewModel (проста реалізація, сильна зв'язаність), параметри Navigator (явна передача, відсутність type-safety), AssistedInject Factory (type-safety, складніша реалізація). Вибір залежить від складності параметрів.
ViewModel не залежить від Navigator. Інтеграція через Events (ObjectProperty<NavigationRequest>) або Callbacks (NavigationCallback інтерфейс). Controller слухає Events або передає Callback у ViewModel. Це зберігає розділення відповідальностей MVVM.
Модальні діалоги через окремі Stage. Navigator.openDialog() створює новий Stage з модальністю APPLICATION_MODAL, показує діалог через showAndWait(), отримує результат через інтерфейс DialogController<T>. Результат повертається як Optional<T>.
Single Window Navigation для основних екранів, Multiple Windows для діалогів. Основна навігація через заміну Root Node у Scene (один екран на весь екран). Діалоги через окремі Stage (модальні вікна поверх головного).
У наступній статті ми розглянемо тестування JavaFX MVVM-додатків: unit-тестування ViewModel з mock залежностями, інтеграційне тестування ViewModel + Repository з H2 database, UI-тестування з TestFX (автоматизоване тестування JavaFX інтерфейсу), Test Doubles (Mock vs Stub vs Fake), та організацію тестів для різних шарів додатку.
Валідація та обробка помилок у MVVM
Від реактивної валідації Properties до централізованої системи Validators: валідація на рівні ViewModel, Validator Pattern, CompositeValidator, асинхронна валідація унікальності, обробка помилок Repository та Service, відображення помилок у View.
Тестування JavaFX MVVM-додатків
Від unit-тестів ViewModel до UI-автоматизації: тестування з Mockito, інтеграційні тести з H2 database, TestFX для автоматизованого тестування JavaFX UI, Test Doubles (Mock vs Stub vs Fake), організація тестів для різних шарів.