3

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.

Brad Turek
  • 2,472
  • 3
  • 30
  • 56
  • This is not possible from CSS and since the `layoutChildren` method of `TableColumnHeader` always aligns the arrow to the right, the only way of changing this would be to adjust `translateX` of the label displaying the column name. You'd need to add a listener to the `layoutX` property of the sort arrow to handle resizing. Oh and did I mention this node is lazily created when the column is sorted for the first time? That is if you don't want to implement your own skin for `TableView`... – fabian Mar 06 '18 at 00:56
  • 2
    @fabian agreed that it's not possible by css - but a custom TableColumnHeader (we'll need the whole stack TableHeaderRow, NestedTableColumnHeader, TableViewSkin just to create the customXX) is not that difficult: override its layoutChildren and position label/arrow as needed – kleopatra Mar 06 '18 at 02:50

1 Answers1

2

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)
kleopatra
  • 51,061
  • 28
  • 99
  • 211
  • thanks for the code! I've tried it out. Yet, even with what I think are the right imports, it won't work; there's various things seemingly wrong. Here's [a project with just your code](https://github.com/TurekBot/TableHeaderSortArrowAlignment)—would you mind cloning it and seeing what I might be missing? – Brad Turek Mar 12 '18 at 15:22
  • ahh .. I see: this is for fx9 - which moved skins into public scope along with a bunch of other changes, so might blow in fx8 (didn't try), sry – kleopatra Mar 12 '18 at 15:55
  • worksforme in fx8 - with slight modifications: apart from the changed import statement, all constructors in the SomethingHeader need the tableViewSkin as first parameter (in fx8 it is accessible via getTableViewSkin or similar and with that can be passed in the factory methods) – kleopatra Mar 13 '18 at 11:41
  • Thanks, @kleopatra. Could a similar tactic to what you used here be used to make the diagonal column headers described in [this question](https://stackoverflow.com/q/46164114/5432315)? – Brad Turek Apr 10 '18 at 14:02