javaintellij-ideajavafxsavepane

Save/Load a Pane drawing on JavaFx


I have created a paint application where I draw shapes and add them as nodes to the Pane. Something similar to the code below:

currentShape = new Rectangle();
shapeList.add(currentShape);
pane.getChildren().addAll(shapelist);

I want to save a drawing and be able to load that saved drawing. I am not sure how to approach this. I looked at examples using Snapshots/Writable Image of the following sort. However this gives me a Cannot Resolve SwingFXUtils error

save.setOnAction(event -> {
            FileChooser fileChooser = new FileChooser();

            //Set extension filter
            fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("png files (*.png)", "*.png"));

            //Prompt user to select a file
            File f = fileChooser.showSaveDialog(null);

            if(f != null){
                try {
                    //Pad the capture area
                    WritableImage writableImage = new WritableImage((int) pane.getWidth(), (int) pane.getHeight());
                    pane.snapshot(null, writableImage);
                    RenderedImage renderedImage = SwingFXUtils.fromFXImage(writableImage, null);
                    //Write the snapshot to the chosen file
                    ImageIO.write(renderedImage, "png", f);
                } catch (IOException ex)
                { ex.printStackTrace(); }

            }
        });

If anyone has a better suggestion on how to save and load a drawing on a pane, I would appreciate it.


Solution

  • Slaw is right: The best approach by far is to create your own model objects which represent what is shown in your application.

    But, if you want to try writing and reading JavaFX Shapes directly, you do have an option: XML bean serialization.

    XML bean serialization is performed by the XMLEncoder and XMLDecoder classes.

    Unlike regular Java serialization, they don’t look at fields, only bean properties. A bean property is defined by a public read-method, which is a zero-argument method that starts with get (or, if it returns a primitive boolean, it may optionally start with is instead of get).

    Thus, the presence of a getWidth() method defines a property named width.

    If there is a corresponding set method (in the above case, it would be setWidth), which takes exactly one argument of the same type returned by the get-method, the property is defined as a writable property.

    (The full rules are a little more complex than this; I’ve only described the general case. The full JavaBeans specification is here.)

    If you’ve looked at the javadoc for JavaFX, you’ve probably noticed that JavaFX classes have a lot of properties defined. This means you can save your pane children with something like this:

    private static final java.nio.file.Path SAVE_FILE_LOCATION =
        Paths.get(System.getProperty("user.home"), "shapes.xml");
    
    void save()
    throws IOException {
        try (XMLEncoder encoder = new XMLEncoder(
            new BufferedOutputStream(
                Files.newOutputStream(SAVE_FILE_LOCATION)))) {
    
            encoder.setExceptionListener(e -> {
                throw new RuntimeException(e);
            });
    
            encoder.writeObject(pane.getChildren().toArray(new Node[0]));
        }
    }
    
    void load()
    throws IOException {
        try (XMLDecoder decoder = new XMLDecoder(
            new BufferedInputStream(
                Files.newInputStream(SAVE_FILE_LOCATION)))) {
    
            decoder.setExceptionListener(e -> {
                throw new RuntimeException(e);
            });
    
            pane.getChildren().setAll((Node[]) decoder.readObject());
        }
    }
    

    However, there are limits on what XMLEncoder can know and intuit about a class. For instance, in the above code, I am using an array of Node rather than the raw ObservableList, because XMLEncoder doesn’t know how to serialize any of the (not publicly documented) concrete implementations of ObservableList. XMLEncoder does, however, have the built-in ability to serialize arrays, as long as it can serialize the array elements themselves.

    A more significant issue is that it doesn’t know how to serialize some properties and will just ignore them. For example, Color is not a typical Java bean: it is read-only, so while XMLEncoder can read its data, there are no set-methods, so the encoder doesn’t know what instructions to write that a future XMLDecoder would be able to use to create an equivalent Color object.

    We can customize an XMLEncoder by providing it with custom PersistenceDelegates. Conveniently, the DefaultPersistenceDelegate subclass allows bean property names to be passed to the constructor, which creates a delegate that will tell XMLDecoder to look for a constructor which takes an argument corresponding to each of those properties in the originally written data.

    Since Color has a four-argument constructor that takes the values of the red, green, blue, and opacity properties, we can add a DefaultPersistenceDelegate to an XMLEncoder, which instructs future XMLDecoders to use those properties’ values when reconstituting a Color object:

    encoder.setPersistenceDelegate(Color.class,
        new DefaultPersistenceDelegate(
            new String[] { "red", "green", "blue", "opacity" }));
    

    The above means: “When writing a Color object, write instructions for future decoders to look for a constructor in the Color class that takes four doubles, then write the actual values to pass in the future by calling the Color object’s getRed, getGreen, getBlue, and getOpacity methods respectively.”

    If you expect your shapes will include Text objects, you can add a persistence delegate for the Font class:

    encoder.setPersistenceDelegate(Font.class,
        new DefaultPersistenceDelegate(
            new String[] { "name", "size" }));
    

    And you can also add persistence delegates for the other Paint implementations:

    encoder.setPersistenceDelegate(LinearGradient.class,
        new DefaultPersistenceDelegate(new String[] {
            "startX", "startY", "endX", "endY",
            "proportional", "cycleMethod", "stops"
        }));
    encoder.setPersistenceDelegate(RadialGradient.class,
        new DefaultPersistenceDelegate(new String[] {
            "focusAngle", "focusDistance", "centerX", "centerY",
            "radius", "proportional", "cycleMethod", "stops"
        }));
    

    (I have deliberately omitted ImagePattern, because while representing an Image in XML is possible, it is ugly and inefficient. If you intend to store images, XML is not a good storage format.)

    So, the updated version of the load and store methods looks like this:

    private static void addPersistenceDelegatesTo(Encoder encoder) {
        encoder.setPersistenceDelegate(Font.class,
            new DefaultPersistenceDelegate(
                new String[] { "name", "size" }));
        encoder.setPersistenceDelegate(Color.class,
            new DefaultPersistenceDelegate(
                new String[] { "red", "green", "blue", "opacity" }));
        encoder.setPersistenceDelegate(LinearGradient.class,
            new DefaultPersistenceDelegate(new String[] {
                "startX", "startY", "endX", "endY",
                "proportional", "cycleMethod", "stops"
            }));
        encoder.setPersistenceDelegate(RadialGradient.class,
            new DefaultPersistenceDelegate(new String[] {
                "focusAngle", "focusDistance", "centerX", "centerY",
                "radius", "proportional", "cycleMethod", "stops"
            }));
    }
    
    private static final java.nio.file.Path SAVE_FILE_LOCATION =
        Paths.get(System.getProperty("user.home"), "shapes.xml");
    
    void save()
    throws IOException {
        try (XMLEncoder encoder = new XMLEncoder(
            new BufferedOutputStream(
                Files.newOutputStream(SAVE_FILE_LOCATION)))) {
    
            encoder.setExceptionListener(e -> {
                throw new RuntimeException(e);
            });
    
            addPersistenceDelegatesTo(encoder);
    
            encoder.writeObject(pane.getChildren().toArray(new Node[0]));
        }
    }
    
    void load()
    throws IOException {
        try (XMLDecoder decoder = new XMLDecoder(
            new BufferedInputStream(
                Files.newInputStream(SAVE_FILE_LOCATION)))) {
    
            decoder.setExceptionListener(e -> {
                throw new RuntimeException(e);
            });
    
            pane.getChildren().setAll((Node[]) decoder.readObject());
        }
    }