Search code examples
javafxalignmenttablecolumnjavafx-css

How does one set the TableColumn's sort arrow alignment?


The Gist

In a JavaFX TableColumn, there is a sort arrow off to the right side.

Example of the mentioned arrow

How does one set this arrow's alignment?

My Use Case

I ask because I'm trying to apply Material Design to JavaFX and the arrow needs to be on the left—otherwise the arrow appears to belong to the adjacent column.

Arrow appearing to belong to the adjacent column.

What I do know

I know that you can get at the TableColumnHeader like so:

for (final Node headerNode : tableView.lookupAll(".column-header")) {
    TableColumnHeader tableColumnHeader = (TableColumnHeader) headerNode;

I know that TableColumnHeader has a Label label and a GridPane sortArrowGrid as its children.

How do I move the sortArrowGrid to the front of the children? .toFront() is just for z-order right?

    Node arrow = tableColumnHeader.lookup(".arrow");
    if (arrow != null) {
        GridPane sortArrowGrid = (GridPane) arrow.getParent();
        // TODO: Move the sortArrowGrid to the front of the tableColumnHeader's children
    }

I feel I may be going about this wrong—I'd love to do it with CSS.


Solution

  • Expanding a bit (with some code) on my comment: as already noted, alignment (or in fx-speak: content display) of the sort indicator is not configurable, not in style nor by any property of column/header - instead, it's hard-coded in the header's layout code.

    Meaning that we need to implement a custom columnHeader that supports configurable display. The meat is in a custom TableColumnHeader which has:

    • a property sortIconDisplayProperty() to configure the relative location of the sort indicator
    • an overridden layoutChildren() that positions the label and sort indicator as configured
    • for fun: make that property styleable (needs some boilerplate around StyleableProperty and its registration with the CSS handlers)

    To use, we need the whole stack of a custom TableViewSkin, TableHeaderRow, NestedTableColumnHeader: all just boiler-plate to create and return the custom xx instances in their relevant factory methods.

    Below is an example which crudely (read: the layout is not perfect, should have some padding and guarantee not to overlap with the text ... but then, core isn't that good at it, neither) supports setting the icon left of the text. For complete support, you might want to implement setting it on top/bottom .. me being too lazy right now ;)

    /**
     * https://stackoverflow.com/q/49121560/203657
     * position sort indicator at leading edge of column header
     * 
     * @author Jeanette Winzenburg, Berlin
     */
    public class TableHeaderLeadingSortArrow extends Application {
    
        /**
         * Custom TableColumnHeader that lays out the sort icon at its leading edge.
         */
        public static class MyTableColumnHeader extends TableColumnHeader {
    
            public MyTableColumnHeader(TableColumnBase column) {
                super(column);
            }
    
            @Override
            protected void layoutChildren() {
                // call super to ensure that all children are created and installed
                super.layoutChildren();
                Node sortArrow = getSortArrow();
                // no sort indicator, nothing to do
                if (sortArrow == null || !sortArrow.isVisible()) return;
                if (getSortIconDisplay() == ContentDisplay.RIGHT) return;
                // re-arrange label and sort indicator
                double sortWidth = sortArrow.prefWidth(-1);
                double headerWidth = snapSizeX(getWidth()) - (snappedLeftInset() + snappedRightInset());
                double headerHeight = getHeight() - (snappedTopInset() + snappedBottomInset());
    
                // position sort indicator at leading edge
                sortArrow.resize(sortWidth, sortArrow.prefHeight(-1));
                positionInArea(sortArrow, snappedLeftInset(), snappedTopInset(),
                        sortWidth, headerHeight, 0, HPos.CENTER, VPos.CENTER);
                // resize label to fill remaining space
                getLabel().resizeRelocate(sortWidth, 0, headerWidth - sortWidth, getHeight());
            }
    
            // --------------- make sort icon location styleable
            // use StyleablePropertyFactory to simplify styling-related code
            private static final StyleablePropertyFactory<MyTableColumnHeader> FACTORY = 
                    new StyleablePropertyFactory<>(TableColumnHeader.getClassCssMetaData());
    
            // default value (strictly speaking: an implementation detail)
            // PENDING: what about RtoL orientation? Is it handled correctly in
            // core?
            private static final ContentDisplay DEFAULT_SORT_ICON_DISPLAY = ContentDisplay.RIGHT;
    
            private static CssMetaData<MyTableColumnHeader, ContentDisplay> CSS_SORT_ICON_DISPLAY = 
                    FACTORY.createEnumCssMetaData(ContentDisplay.class,
                            "-fx-sort-icon-display",
                            header -> header.sortIconDisplayProperty(),
                            DEFAULT_SORT_ICON_DISPLAY);
    
            // property with lazy instantiation
            private StyleableObjectProperty<ContentDisplay> sortIconDisplay;
    
            protected StyleableObjectProperty<ContentDisplay> sortIconDisplayProperty() {
                if (sortIconDisplay == null) {
                    sortIconDisplay = new SimpleStyleableObjectProperty<>(
                            CSS_SORT_ICON_DISPLAY, this, "sortIconDisplay",
                            DEFAULT_SORT_ICON_DISPLAY);
    
                }
                return sortIconDisplay;
            }
    
            protected ContentDisplay getSortIconDisplay() {
                return sortIconDisplay != null ? sortIconDisplay.get()
                        : DEFAULT_SORT_ICON_DISPLAY;
            }
    
            protected void setSortIconDisplay(ContentDisplay display) {
                sortIconDisplayProperty().set(display);
            }
    
            /**
             * Returnst the CssMetaData associated with this class, which may
             * include the CssMetaData of its superclasses.
             * 
             * @return the CssMetaData associated with this class, which may include
             *         the CssMetaData of its superclasses
             */
            public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
                return FACTORY.getCssMetaData();
            }
    
            /** {@inheritDoc} */
            @Override
            public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
                return getClassCssMetaData();
            }
    
    //-------- reflection acrobatics .. might use lookup and/or keeping aliases around
            private Node getSortArrow() {
                return (Node) FXUtils.invokeGetFieldValue(TableColumnHeader.class, this, "sortArrow");
            }
    
            private Label getLabel() {
                return (Label) FXUtils.invokeGetFieldValue(TableColumnHeader.class, this, "label");
            }
    
        }
    
        private Parent createContent() {
            // instantiate the tableView with the custom default skin
            TableView<Locale> table = new TableView<>(FXCollections.observableArrayList(
                    Locale.getAvailableLocales())) {
    
                        @Override
                        protected Skin<?> createDefaultSkin() {
                            return new MyTableViewSkin<>(this);
                        }
    
            };
            TableColumn<Locale, String> countryCode = new TableColumn<>("CountryCode");
            countryCode.setCellValueFactory(new PropertyValueFactory<>("country"));
            TableColumn<Locale, String> language = new TableColumn<>("Language");
            language.setCellValueFactory(new PropertyValueFactory<>("language"));
            TableColumn<Locale, String> variant = new TableColumn<>("Variant");
            variant.setCellValueFactory(new PropertyValueFactory<>("variant"));
            table.getColumns().addAll(countryCode, language, variant);
    
            BorderPane pane = new BorderPane(table);
    
            return pane;
        }
    
        /**
         * Custom nested columnHeader, headerRow und skin only needed to 
         * inject the custom columnHeader in their factory methods.
         */
        public static class MyNestedTableColumnHeader extends NestedTableColumnHeader {
    
            public MyNestedTableColumnHeader(TableColumnBase column) {
                super(column);
            }
    
            @Override
            protected TableColumnHeader createTableColumnHeader(
                    TableColumnBase col) {
                return col == null || col.getColumns().isEmpty() || col == getTableColumn() ?
                        new MyTableColumnHeader(col) :
                        new MyNestedTableColumnHeader(col);
            }
        }
    
        public static class MyTableHeaderRow extends TableHeaderRow {
    
            public MyTableHeaderRow(TableViewSkinBase tableSkin) {
                super(tableSkin);
            }
    
            @Override
            protected NestedTableColumnHeader createRootHeader() {
                return new MyNestedTableColumnHeader(null);
            }
        }
    
        public static class MyTableViewSkin<T> extends TableViewSkin<T> {
    
            public MyTableViewSkin(TableView<T> table) {
                super(table);
            }
    
            @Override
            protected TableHeaderRow createTableHeaderRow() {
                return new MyTableHeaderRow(this);
            }
    
        }
    
        @Override
        public void start(Stage stage) throws Exception {
            stage.setScene(new Scene(createContent()));
            URL uri = getClass().getResource("columnheader.css");
            stage.getScene().getStylesheets().add(uri.toExternalForm());
            stage.setTitle(FXUtils.version());
            stage.show();
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    
        @SuppressWarnings("unused")
        private static final Logger LOG = Logger
                .getLogger(TableHeaderLeadingSortArrow.class.getName());
    
    }
    

    The columnheader.css to configure:

    .column-header {
        -fx-sort-icon-display: LEFT;
    }
    

    Version note:

    the example is coded against fx9 - which moved Skins into public scope along with a bunch of other changes. To make it work with fx8

    • adjust import statement to old locations in com.sun.** (not shown anyway, your IDE is your friend ;)
    • for all SomethingHeader, change the constructors to contain the tableSkin as parameter and pass the skin in all factory methods (possible in fx8, as getTableViewSkin() - or similar - has protected scope and thus is accessible for subclasses)