tableviewjavafx-8tablecelleditor

JavaFX8 - customised EditCell class - how to disable mouse clicks for a TableView while still allowing clicks inside a TableCell that's being edited?


I'm writing an app that will have many data entry windows, each of which has an editable TableView for data entry.

I'm using user James_D's very helpful EditCell code https://gist.github.com/james-d/be5bbd6255a4640a5357 as the basis for TableCell editing and have extended it to include numeric and date data types.

I'm validating data as it's entered. If there's no error, I let the user move away from the cell by either clicking on another cell or by tabbing or shift+tabbing out of the cell. If there is an error, I don't let the user move away from the cell until the error is corrected.

My code is working apart from one thing.

I'm using a mouse event handler on the TableView to detect when the user tries to click away from a cell. If there's an error, I consume the click to stop focus from leaving the cell.

That part works fine.

However, the handler also consumes clicks inside the cell being edited, so if the user wants to click to position the cursor at the relevant place to correct the error, they can't.

Is there any way of getting around this ie. allowing clicks inside the cell being edited while, at the same time, disabling clicks at the tableview level?

I've also tried using setMouseTransparent at a TableView level rather than consuming clicks, but the same thing happens.

Here are excerpts from my code.

I declare the mouse event handler in an FXML controller supertype. The dataEntryError flag is declared at the app level.

import static ztestform.ZTestForm.dataEntryError;
//...
public static EventHandler<MouseEvent> tvMousePressedHandler;
//...
public void defineMouseEventHandler() {
    tvMousePressedHandler = (MouseEvent event) -> {
        if ( dataEntryError ) event.consume();
    };   
}

I add the mouse event handler to the TableView in each of the relevant FXML controllers.

import static ztestform.ZTestForm.dataEntryError;
//...
private void initialiseTableView() {
    //...
    defineMouseEventHandler();
    tvTestModel.addEventFilter(MouseEvent.MOUSE_PRESSED, tvMousePressedHandler);
    //...
}

Per James_D's example, I have an EditCell class which is instantiated by cell factories on the TableColumns. It manages starting edits in the cells and committing or cancelling the edits as required.

In EditCell, I trap data entry errors with a change listener on the cell's textProperty(). If there's an error, I add the TableView mouse event handler to consume the click. If there's no error, the mouse event handler is removed.

textField.textProperty().addListener((ObservableValue<? extends String> observable, String oldValue, String newValue) -> {
    getTableView().removeEventFilter(MouseEvent.MOUSE_PRESSED, controllerRef.tvMousePressedHandler);
    if ( isDataValid(classType, newValue) ) {
        dataEntryError = false;
        textField.setStyle("");
        DAOGenUtil.clearSystemMessage(controllerRef);
    } else {
        dataEntryError = true;
        textField.setStyle("-fx-background-color: pink;");
        displayErrorMessage(classType, controllerRef);
        getTableView().addEventFilter(MouseEvent.MOUSE_PRESSED, controllerRef.tvMousePressedHandler);
    }
});        

Somewhere in here I'd like to say "allow clicks in textField even though they're consumed at the TableView level" but don't know how to do that.

Is someone able to help me please? I've been stuck on this for four days now. :-(

I'm using JavaFX8, NetBeans 8.2 and Scene Builder 8.3.

In case you need to see it, here is the full code for my extended EditCell class. Thanks to James_D for posting the original code and also to the very clever people on StackOverflow who answer questions; the solutions have been an invaluable knowledge source for me!

package ztestform;

import fxmlcontrollers.FXMLControllerSuperType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import javafx.beans.value.ObservableValue;
import javafx.event.Event;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.CellEditEvent;
import javafx.scene.control.TablePosition;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.util.StringConverter;
import javafx.util.converter.DoubleStringConverter;
import javafx.util.converter.LocalDateStringConverter;
import javafx.util.converter.LongStringConverter;
import static ztestform.ZTestForm.COLOUR_DARK_RED;
import static ztestform.ZTestForm.FORMAT_DATE_DISPLAY;
import static ztestform.ZTestForm.FORMAT_DOUBLE_FOUR_DECIMALS;
import static ztestform.ZTestForm.FORMAT_INTEGER;
import static ztestform.ZTestForm.dataEntryError;

public class EditCell<S, T> extends TableCell<S, T> {

    private final TextField textField = new TextField();

    //Converter for converting the text in the text field to the user type, and vice-versa:
    private final StringConverter<T> converter ;

    public static DAOGenUtil DAOGenUtil = new DAOGenUtil();
    private final Class<T> classType = null;

    public EditCell(StringConverter<T> converter, Class<T> classType, FXMLControllerSuperType controllerRef) {

        this.converter = converter ;

        itemProperty().addListener((obx, oldItem, newItem) -> {
            if (newItem == null) {
                setText(null);
            } else {
                setText(converter.toString(newItem));
            }
        });
        setGraphic(textField);
        setContentDisplay(ContentDisplay.TEXT_ONLY);

//*******************************************************************************************************************    
//If the user hits ENTER and there are no data entry errors, commit the edit

        textField.setOnAction(evt -> {
            if ( ! dataEntryError ) {
                commitEdit(this.converter.fromString(textField.getText()));
            }
        });

//*******************************************************************************************************************    
//If the cell loses focus and there are no data entry errors, commit the edit

        textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
            if (! isNowFocused && ! dataEntryError ) {
                commitEdit(this.converter.fromString(textField.getText()));
            }
        });

//*******************************************************************************************************************    
//Validate data as it's entered

        textField.textProperty().addListener((ObservableValue<? extends String> observable, String oldValue, String newValue) -> {

            getTableView().removeEventFilter(MouseEvent.MOUSE_PRESSED, controllerRef.tvMousePressedHandler);

            if ( isDataValid(classType, newValue) ) {
                dataEntryError = false;
                textField.setStyle("");
                DAOGenUtil.clearSystemMessage(controllerRef);
            } else {
                dataEntryError = true;
                textField.setStyle("-fx-background-color: pink;");
                displayErrorMessage(classType, controllerRef);
                getTableView().addEventFilter(MouseEvent.MOUSE_PRESSED, controllerRef.tvMousePressedHandler);
            }

        });        

//*******************************************************************************************************************    
//Trap and process ESCAPE, TAB and SHIFT+TAB

        textField.addEventFilter(KeyEvent.KEY_PRESSED, event -> {

            TablePosition<S, ?> pos = getTableView().getFocusModel().getFocusedCell();
            int maximumVisibleColumnNumber = DAOGenUtil.getMaximumVisibleColumnNumber(getTableView());

            if (event.getCode() == KeyCode.ESCAPE) {

                textField.setText(converter.toString(getItem()));
                cancelEdit();
                event.consume();
                dataEntryError = false;
                DAOGenUtil.clearSystemMessage(controllerRef);
                getTableView().setMouseTransparent(false);

            } else if ( event.isShiftDown() && event.getCode() == KeyCode.TAB ) {

                if ( dataEntryError ) {
                    event.consume();
                } else {
                    getTableView().setMouseTransparent(false);
                    getTableView().getSelectionModel().selectLeftCell();
                    if ( pos.getColumn() == 0 ) {
                        //We're at the start of a row so position to the end of the previous row
                        getTableView().getSelectionModel().select(pos.getRow()-1, getTableView().getColumns().get(maximumVisibleColumnNumber));
                        event.consume();
                    }
                }

            } else if ( event.getCode() == KeyCode.TAB ) {

                if ( dataEntryError ) {
                    event.consume();
                } else {
                    getTableView().setMouseTransparent(false);
                    getTableView().getSelectionModel().selectRightCell();
                    if ( pos.getColumn() == maximumVisibleColumnNumber ) {
                        //We're at the end of a row so position to the start of the next row
                        getTableView().getSelectionModel().select(pos.getRow()+1, getTableView().getColumns().get(0));
                        event.consume();
                    }
                }

            }

        });

    }

//*******************************************************************************************************************    
//Create EditCells for String data types

    public static final StringConverter<String> IDENTITY_CONVERTER_STRING = new StringConverter<String>() {

        @Override
        public String toString(String object) {
            return object;
        }

        @Override
        public String fromString(String string) {
            return string;
        }

    };

    public static <S> EditCell<S, String> createStringEditCell(FXMLControllerSuperType controllerRef) {
        return new EditCell<S, String>(IDENTITY_CONVERTER_STRING, String.class, controllerRef);
    }

//*******************************************************************************************************************    
//Create EditCells for Long data types

    public static final LongStringConverter IDENTITY_CONVERTER_LONG = new LongStringConverter() {

        public String toString(Long object) {

            String object2 = DAOGenUtil.formatValue(FORMAT_INTEGER, Long.toString(object));
            return ( object == null ? "0" : object2 );

        }

        public Long fromString(String object) {

            Long object3 = Long.parseLong(object.replaceAll(",",""));
            return ( object.isEmpty() ? 0 : object3 );

        }

    };

    public static <S> EditCell<S, Long> createLongEditCell(FXMLControllerSuperType controllerRef) {
        return new EditCell<S, Long>(IDENTITY_CONVERTER_LONG, Long.class, controllerRef);
    }

//*******************************************************************************************************************    
//Create EditCells for Double data types

    public static final DoubleStringConverter IDENTITY_CONVERTER_DOUBLE = new DoubleStringConverter() {

        public String toString(Double object) {

            String object2 = DAOGenUtil.formatValue(FORMAT_DOUBLE_FOUR_DECIMALS, Double.toString(object));
            return ( object == null ? "0" : object2 );

        }

        public Double fromString(String object) {

            Double object3 = Double.parseDouble(object.replaceAll(",",""));
            return ( object.isEmpty() ? 0 : object3 );

        }

    };

    public static <S> EditCell<S, Double> createDoubleEditCell(FXMLControllerSuperType controllerRef) {
        return new EditCell<S, Double>(IDENTITY_CONVERTER_DOUBLE, Double.class, controllerRef);
    }

//*******************************************************************************************************************    
//Create EditCells for LocalDate data types

    public static final LocalDateStringConverter IDENTITY_CONVERTER_LOCAL_DATE = new LocalDateStringConverter() {

        public String toString(LocalDate object) {

            DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(FORMAT_DATE_DISPLAY);
            String object2 = dateFormatter.format( (TemporalAccessor) object);
            return ( object == null ? "0" : object2 );

        }

        public LocalDate fromString(String object) {

            DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("d/M/yyyy");
            LocalDate object3 = LocalDate.parse(object, dateFormatter);

            return object.isEmpty() ? null : object3;

        }

    };

    public static <S> EditCell<S, LocalDate> createLocalDateEditCell(FXMLControllerSuperType controllerRef) {
        return new EditCell<S, LocalDate>(IDENTITY_CONVERTER_LOCAL_DATE, LocalDate.class, controllerRef);
    }

//*******************************************************************************************************************    
//Code to start, cancel and commit edits

    @Override
    public void startEdit() {

        super.startEdit();
        textField.setText(converter.toString(getItem()));
        setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
        textField.requestFocus();

    }

    @Override
    public void cancelEdit() {

        super.cancelEdit();
        setContentDisplay(ContentDisplay.TEXT_ONLY);

    }

    @Override
    public void commitEdit(T item) {

        // This block is necessary to support commit on losing focus, because the baked-in mechanism
        // sets our editing state to false before we can intercept the loss of focus.
        // The default commitEdit(...) method simply bails if we are not editing...

        if (! isEditing() && ! item.equals(getItem())) {
            TableView<S> table = getTableView();
            if (table != null) {
                TableColumn<S, T> column = getTableColumn();
                CellEditEvent<S, T> event = new CellEditEvent<>(table, 
                        new TablePosition<S,T>(table, getIndex(), column), 
                        TableColumn.editCommitEvent(), item);
                Event.fireEvent(column, event);
            }
        }

        super.commitEdit(item);

        setContentDisplay(ContentDisplay.TEXT_ONLY);

    }

//*******************************************************************************************************************    
//Validate data

    public boolean isDataValid(Class<T> classType, String enteredData) {

        boolean dataOK = true;
        String enteredDataWithoutCommas = "";

        if ( classType == Long.class || classType == Double.class ) {
            enteredDataWithoutCommas = enteredData.replaceAll(",", "");
        }

        if ( ( classType == Long.class && ! DAOGenUtil.isIntegerOrLong(enteredDataWithoutCommas) )
             || classType == Double.class && ! DAOGenUtil.isNumeric(enteredDataWithoutCommas)
             || classType == LocalDate.class && ! DAOGenUtil.isDate(enteredData) ) {
            dataOK = false;

        } else {

            dataOK = true;

        }

        return dataOK;

    }

//*******************************************************************************************************************    
//Display data entry error messages

    public void displayErrorMessage(Class<T> classType, FXMLControllerSuperType controllerRef) {

        if ( classType == Long.class ) {

            DAOGenUtil.setSystemMessage(controllerRef, "Invalid number (expected format 9,999).", COLOUR_DARK_RED);

        } else if ( classType == Double.class ) {

            DAOGenUtil.setSystemMessage(controllerRef, "Invalid number (expected format 9,999.9999).", COLOUR_DARK_RED);

        } else if ( classType == LocalDate.class ) {

            DAOGenUtil.setSystemMessage(controllerRef, "Invalid date (expected format DAY/MONTH/4-digit YEAR).", COLOUR_DARK_RED);

        }

    }

}

Solution

  • In a MouseEvent the node that was clicked is available via pickResult. Note that this may be a child of the control introduced by the skin and not the control itself. You can still find the clicked cell by traversing upwards through the scene hierarchy. This allows you to determine if the click was outside of cell being edited and make the decision to consume the event or not based on this information:

    public static void registerEditingHandler(final TableView<?> tableView) {
        EventHandler<MouseEvent> handler = event -> {
            TablePosition<?, ?> position = tableView.getEditingCell();
            if (position != null) {
                Node n = event.getPickResult().getIntersectedNode();
    
                while (n != tableView
                        && !(n instanceof TableCell)) {
                    n = n.getParent();
                }
    
                // consume cells outside of cells or on cells not matching the
                // editedPosition
                if (n == tableView) {
                    event.consume();
                } else {
                    TableCell<?, ?> cell = (TableCell<?, ?>) n;
                    if (cell.getIndex() != position.getRow()
                            || cell.getTableColumn() != position.getTableColumn()) {
                        event.consume();
                    }
                }
            }
        };
        tableView.addEventFilter(MouseEvent.MOUSE_CLICKED, handler);
        tableView.addEventFilter(MouseEvent.MOUSE_PRESSED, handler);
        tableView.addEventFilter(MouseEvent.MOUSE_RELEASED, handler);
    }