Проектування баз даних

Навігація та управління екранами у JavaFX MVVM

Від хаотичних переходів до централізованої навігації: Navigator Pattern, ScreenRegistry, передача параметрів між екранами, Navigation Stack для історії переходів, модальні діалоги з поверненням результату, інтеграція з ViewModel через Events.

Навігація та управління екранами у 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).
Navigator у MVVM — це інфраструктурний компонент. Він не є частиною Model, View або ViewModel. Це окремий шар, що забезпечує навігацію. ViewModel не знає про Navigator безпосередньо — вони комунікують через Events або Callbacks.

Патерни навігації у 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 — це клас, що інкапсулює всю логіку навігації: завантаження 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 екрану).

Чому зберігати Parent у стеку? Коли користувач повертається назад, ми відновлюємо попередній екран зі збереженим станом (прокрутка, вибір, введені дані). Якщо б ми перезавантажували FXML, стан втрачався б. Але це має недолік: всі екрани у стеку залишаються у пам'яті. Для додатків з багатьма екранами розгляньте обмеження розміру стеку або очищення старих екранів.

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.

Рекомендація: Використовуйте Підхід 2 (параметри Navigator) для простих випадків (передача ID, прапорців). Використовуйте Підхід 3 (AssistedInject) для складних випадків (передача об'єктів Domain Model, кілька параметрів). Уникайте Підходу 1 (Singleton ViewModel) — він створює сильну зв'язаність.

Інтеграція 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 (хоча це абстракція, не конкретна реалізація).

Не передавайте Navigator безпосередньо у ViewModel. Це порушує MVVM та ускладнює тестування. Використовуйте Events (Рішення 1) або Callbacks (Рішення 2) для непрямої комунікації.

Модальні діалоги з поверненням результату

Діалоги — це окремі екрани, що відкриваються поверх головного вікна та повертають результат (наприклад, вибраний елемент, підтвердження).

Інтерфейс 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());
    }
}
Діалоги повертають Optional: Якщо користувач натиснув "Cancel" або закрив вікно, 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), та організацію тестів для різних шарів додатку.

Copyright © 2026