简体   繁体   中英

Is there a way to change a Java property without firing a value changed event to it's listeners?

What I'm trying to do

I'm looking for a way to change a property, without a call to the listeners's changed method.

More specifically I'm trying to implement an undo/redo functionality. The way I've implemented it is as following, in an example with a BooleanProperty and a JavaFX CheckBox .

  1. The selectedProperty of the CheckBox is changed by a mouse click.
  2. A BooleanProperty (actually a JavaFX SimpleBooleanProperty ) is changed because it is bound bidirectionally to the selectedProperty
  3. The ChangeListener of the BooleanProperty registers this and adds a Command on the application's undoStack . The Command stores the property, the old and the new value.
  4. The user clicks the undo button
  5. Via the button the application takes that last Command from the stack and calls it's undo() method.
  6. The undo() method changes the BooleanProperty back.
  7. The ChangeListener registers this change again and creates a new Command
  8. An endless cycle is created

My Hacky Solution

The way I did it is by passing the ChangeListener to the Command object. Then the undo() method first removes the ChangeListener , changes the BooleanProperty and then adds the ChangeListener again.
It feels wrong and hacky to pass the ChangeListener to the Command (in my actual implementation in the 3. step there are actually a few more classes between the ChangeListener and the Command which now all need to know about the ChangeListener )

My Question

Is this really the way to do it? Isn't there a way to change the property in step 6 and just tell it to not inform it's listeners? Or at least to get it's listeners?

There's no supported way of bypassing listeners, as you describe. You just need to build this logic into your undo/redo mechanism. The idea is basically to set a flag if you are performing an undo/redo, and not add the change to your stack if so.

Here's a very simple example: note this is not production quality - for example typing in a text control will add to the stack for every character change (keeping copies of the current text at each change). In real code, you should coalesce these changes together.

import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;

import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;

public class UndoManager {

    private boolean performingUndoRedo = false ;
    private Deque<Command<?>> undoStack = new LinkedList<>();
    private Deque<Command<?>> redoStack = new LinkedList<>();


    private Map<Property<?>, ChangeListener<?>> listeners = new HashMap<>();

    public <T> void register(Property<T> property) {
        // don't register properties multiple times:
        if (listeners.containsKey(property)) {
            return ;
        }
        // FIXME: should coalesce (some) changes on the same property, so, e.g. typing in a text
        // control does not result in a separate command for each character
        ChangeListener<? super T> listener = (obs, oldValue, newValue) -> {
            if (! performingUndoRedo) {
                Command<T> cmd = new Command<>(property, oldValue, newValue) ;
                undoStack.addFirst(cmd);
            }
        };
        property.addListener(listener);
        listeners.put(property, listener);
    }

    public <T> void unregister(Property<T> property) {
        listeners.remove(property);
    }

    public void undo() {
        if (undoStack.isEmpty()) {
            return ;
        }
        Command<?> command = undoStack.pop();
        performingUndoRedo = true ;
        command.undo();
        redoStack.addFirst(command);
        performingUndoRedo = false ;
    }

    public void redo() {
        if (redoStack.isEmpty()) {
            return ;
        }
        Command<?> command = redoStack.pop();
        performingUndoRedo = true ;
        command.redo();
        undoStack.addFirst(command);
        performingUndoRedo = false ;
    }



    private static class Command<T> {
        private final Property<T> property ;
        private final T oldValue ;
        private final T newValue ;

        public Command(Property<T> property, T oldValue, T newValue) {
            super();
            this.property = property;
            this.oldValue = oldValue;
            this.newValue = newValue;
        }

        private void undo() {
            property.setValue(oldValue);
        }

        private void redo() {
            property.setValue(newValue);
        }

        @Override 
        public String toString() {
            return "property: "+property+", from: "+oldValue+", to: "+newValue ;
        }
    }
}

And here's a quick test harness:

import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

public class UndoExample extends Application {

    @Override
    public void start(Stage stage) throws Exception {
        ComboBox<Color> textColor = new ComboBox<Color>();
        textColor.getItems().addAll(Color.BLACK, Color.RED, Color.DARKGREEN, Color.BLUE);
        textColor.setValue(Color.BLACK);
        textColor.setCellFactory(lv -> new ColorCell());
        textColor.setButtonCell(new ColorCell());
        CheckBox italic = new CheckBox("Italic");
        TextArea text = new TextArea();
        updateStyle(text, textColor.getValue(), italic.isSelected());

        ChangeListener<Object> listener = (obs, oldValue, newValue) -> 
            updateStyle(text, textColor.getValue(), italic.isSelected());
        textColor.valueProperty().addListener(listener);
        italic.selectedProperty().addListener(listener);

        UndoManager undoMgr = new UndoManager();
        undoMgr.register(textColor.valueProperty());
        undoMgr.register(italic.selectedProperty());
        undoMgr.register(text.textProperty());

        Button undo = new Button("Undo");
        Button redo = new Button("Redo");
        undo.setOnAction(e -> undoMgr.undo());
        redo.setOnAction(e -> undoMgr.redo());

        HBox controls = new HBox(textColor, italic, undo, redo);
        controls.setSpacing(5);

        BorderPane root = new BorderPane(text);
        root.setTop(controls);

        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }

    private void updateStyle(TextArea text, Color textColor, boolean italic) {
        StringBuilder style = new StringBuilder()
                .append("-fx-text-fill: ")
                .append(hexString(textColor))
                .append(";")
                .append("-fx-font: ");
        if (italic) {
            style.append("italic ");
        }
        style.append("13pt sans-serif ;");
        text.setStyle(style.toString());
    }

    private String hexString(Color color) {
        int r = (int) (color.getRed() * 255) ;
        int g = (int) (color.getGreen() * 255) ;
        int b = (int) (color.getBlue() * 255) ;
        return String.format("#%02x%02x%02x", r, g, b);
    }

    private static class ColorCell extends ListCell<Color> {
        private Rectangle rect = new Rectangle(25, 25);
        @Override
        protected void updateItem(Color color, boolean empty) {
            super.updateItem(color, empty);
            if (empty || color==null) {
                setGraphic(null);
            } else {
                rect.setFill(color);
                setGraphic(rect);
            }
        }       
    }

    public static void main(String[] args) {
        Application.launch(args);
    }

}

There is pretty much not a possibility to do this without "hacks"!
However, there is also a shorter solution, via using reflection:

/**
 * Set the value of property without firing any change event.
 * The value of property will be set via reflection.
 * This property must be "Base" property such as {@link DoublePropertyBase}.
 * 
 * @param property | Property to set!
 * @param newValue | New value of property.
 */
public static <T> void setPropertyWithoutFiringEvent(Property<T> property, T newValue)
{
    Class<?> cls = property.getClass();
    while (cls != null) //While until helper variable is found
    {
        try 
        {
            Field fieldH = cls.getDeclaredField("helper"), fieldV = cls.getDeclaredField("valid");
            fieldH.setAccessible(true);
            fieldV.setAccessible(true);
            
            Object helper = fieldH.get(property), valid = fieldV.getBoolean(property); //Temporary values
            fieldH.set(property, null); //Disabling ExpressionHelper by setting it on null;
            property.setValue(newValue);
            fieldH.set(property, helper); //Setting helper back!

            fieldV.set(property, valid); //Important
            return;
        } 
        catch (Exception e) 
        {
            cls = cls.getSuperclass(); //If not found go to super class of property next time!
        }
    }
    System.err.println("Property " + property + " cant be set because variable \"helper\" was not found!");
}

This function temporarily disables ExpressionHelper what is an object responsible for firing change events, and then it will change the value of property and enable ExpressionHelper back! This will cause that one change will not be notified!
If the reflection is not friendly solution for you, then just use the solution above however this one is far shorter and simpler.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM