J. Pablo Fernández

Restoring window sizes in JavaFX

Advertisements

Update 2018-05-23: Updated the code to my current version, which fixes a few bugs.
When doing usability testing of an alpha version of Dashman, one thing that I was strongly asked was to have the windows remember their sizes when you re-open the application. The need was clear as it was annoying to have the window be a different size when re-started.

The new version of Dashman is built using Java and JavaFX and thus I searched for how to do this, how to restore size. I found many posts, forums, questions, etc all with the same simplistic solution: restoring width and height, and maybe position.

What those were missing was restoring whether the window was maximized (maximized is not the same as occupying all the available space, at least in Windows). But most important than that, none of the solutions took into consideration the fact that the resolutions and quantity of screens could be different than the last time the application run, thus, you could end up with a window completely out of bounds, invisible, immobile.

I came up with this solution, a class that’s designed to be serializable to your config to store the values but also restore them and make sure the window is visible and if not, move it to a visible place:


// Copyright (c) 2017-2018 Flexpoint Tech Ltd. All rights reserved.

package tech.dashman.dashman;

import com.fasterxml.jackson.annotation.JsonIgnore;
import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.stage.Screen;
import javafx.stage.Stage;
import lombok.Data;
import tech.dashman.common.Jsonable;

@Data
public class StageSizer implements Jsonable {
    private static double MINIMUM_VISIBLE_WIDTH = 100;
    private static double MINIMUM_VISIBLE_HEIGHT = 50;
    private static double MARGIN = 50;
    private static double DEFAULT_WIDTH = 800;
    private static double DEFAULT_HEIGHT = 600;

    private Boolean maximized = false;
    private Boolean hidden = false;
    private Double x = MARGIN;
    private Double y = MARGIN;
    private Double width = DEFAULT_WIDTH;
    private Double height = DEFAULT_HEIGHT;

    @JsonIgnore
    private Boolean hideable = true;

    @JsonIgnore
    public void setStage(Stage stage) {
        // First, restore the size and position of the stage.
        resizeAndPosition(stage, () -> {
            // If the stage is not visible in any of the current screens, relocate it the primary screen.
            if (isWindowIsOutOfBounds(stage)) {
                moveToPrimaryScreen(stage);
            }
            // And now watch the stage to keep the properties updated.
            watchStage(stage);
        });
    }

    private void resizeAndPosition(Stage stage, Runnable callback) {
        Platform.runLater(() -> {
            if (getHidden() != null && getHidden() && getHideable()) {
                stage.hide();
            }
            if (getX() != null) {
                stage.setX(getX());
            }
            if (getY() != null) {
                stage.setY(getY());
            }
            if (getWidth() != null) {
                stage.setWidth(getWidth());
            } else {
                stage.setWidth(DEFAULT_WIDTH);
            }
            if (getHeight() != null) {
                stage.setHeight(getHeight());
            } else {
                stage.setHeight(DEFAULT_HEIGHT);
            }
            if (getMaximized() != null) {
                stage.setMaximized(getMaximized());
            }
            if (getHidden() == null || !getHidden() || !getHideable()) {
                stage.show();
            }

            new Thread(callback).start();
        });
    }

    public void setHidden(boolean value) {
        this.hidden = value;
    }

    private boolean isWindowIsOutOfBounds(Stage stage) {
        for (Screen screen : Screen.getScreens()) {
            Rectangle2D bounds = screen.getVisualBounds();
            if (stage.getX() + stage.getWidth() - MINIMUM_VISIBLE_WIDTH >= bounds.getMinX() &&
                    stage.getX() + MINIMUM_VISIBLE_WIDTH <= bounds.getMaxX() &&
                    bounds.getMinY() <= stage.getY() && // We want the title bar to always be visible.
                    stage.getY() + MINIMUM_VISIBLE_HEIGHT <= bounds.getMaxY()) {
                return false;
            }
        }
        return true;
    }

    private void moveToPrimaryScreen(Stage stage) {
        Rectangle2D bounds = Screen.getPrimary().getVisualBounds();
        stage.setX(bounds.getMinX() + MARGIN);
        stage.setY(bounds.getMinY() + MARGIN);
        stage.setWidth(DEFAULT_WIDTH);
        stage.setHeight(DEFAULT_HEIGHT);
    }

    private void watchStage(Stage stage) {
        // Get the current values.
        setX(stage.getX());
        setY(stage.getY());
        setWidth(stage.getWidth());
        setHeight(stage.getHeight());
        setMaximized(stage.isMaximized());
        setHidden(!stage.isShowing());
        // Watch for future changes.
        stage.xProperty().addListener((observable, old, x) -> setX((Double) x));
        stage.yProperty().addListener((observable, old, y) -> setY((Double) y));
        stage.widthProperty().addListener((observable, old, width) -> setWidth((Double) width));
        stage.heightProperty().addListener((observable, old, height) -> setHeight((Double) height));
        stage.maximizedProperty().addListener((observable, old, maximized) -> setMaximized(maximized));
        stage.showingProperty().addListener(observable -> setHidden(!stage.isShowing())); // Using an invalidation instead of a change listener due to this weird behaviour: https://stackoverflow.com/questions/50280052/property-not-calling-change-listener-unless-theres-an-invalidation-listener-as
    }
}

and the way you use it is quite simple. On your start method, you create or restore an instance of StageSizer and then do this:


public void start(Stage stage) {
    StageSizer stageSizer = createOrRestoreStageSizerFromConfig();
    stageSizer.setStage(stage);
}

I haven’t put a lot of testing on this code yet but it seems to work. Well, at least on Windows. The problem is that this snippet is interacting with the reality of screen sizes, resolutions, adding and removing monitors, etc. If you find a bug, please, let me know and I might release this a library with the fix so we can keep on collectively improving this.

Advertisements

Advertisements