DE   |   EN   |   RU

Келтенринг 17, D – 82041 Оберхахинг Мюнхен  |  Тел.: +49 89 4554 6533  |  E-Mail: info@intechcore.com 

JavaFX, CSS-стили и пользовательские свойства компонентов.

Одним из важных преимуществ библиотеки JavaFX является использование стилей, принесенное из мира Web. Т.е. вместо того, чтобы быть зависимым от библиотек Look&Feel, пользователь сам определяет внешний вид, причем это действительно гибко и красиво, а также может изменяться динамически, «на лету», содержать анимацию и 3D графику. Используя концепцию стилей можно делать приложения с так называемыми «скинами», когда внешний вид полностью отделен от бизнес-логики, и даже разрабатывается отдельно, например, профессиональными дизайнерами.
Создадим простой пример диалога с кнопкой:

public class JavaFXDialog1 extends Application {
    @Override
    public void start(Stage stage) {
        final VBox vbox = new VBox();
        final Button button = new Button("test");
        vbox.getChildren().addAll(button);
        final Scene scene = new Scene(vbox, 150, 100);
        stage.setScene(scene);
        stage.show();
    }
  
    public static void main(String[] args) {
        launch(args);
    }

avafx and css styles 1
Применять стили можно несколькими способами:
1) Непосредственно в коде, например, изменить цвет фонта кнопки:

button.setStyle("-fx-text-fill: red");

avafx and css styles 2

2) Посредством CSS-файла, на который должен быть настроен класс Scene:
Для этого нужно создать файл с раширением .css и положить его в папку проекта, например — /css/styles.css.
Содержимое файла:

.button {
    -fx-text-fill: blue;
}

Да, тут главное не забыть настроить среду разработки, чтобы она копировала эти файлы в сборку, иначе можно долго разбираться, почему стиль не подключается
Например, в IntelliJ IDEA это делается так:
avafx and css styles 3

Теперь все готово, чтобы подключить к сцене стиль:

scene.getStylesheets().add((getClass().getResource("/css/styles.css")).toExternalForm());

Далее запустим проект и получим такой диалог:
avafx and css styles 4

Инструкция .button в CSS-файле говорит, что теперь все кнопки будут с голубым цветом:

final Button button1 = new Button("button1");
final Button button2 = new Button("button2");
vbox.getChildren().addAll(button1, button2);

avafx and css styles 5

А что если это не то, что нам надо? Что если нужно определить конкретную кнопку?
3) На помощь приходит определение пользовательского стиля кнопки:
В styles.css пишем:

.button1 {
    -fx-text-fill: green;
}

А в коде:

button1.getStyleClass().add("button1");

Получаемый диалог выглядит так:
avafx and css styles 6

Теперь все кнопки, присоединенные к этому классу стиля, будут с зеленым текстом, причем метод add() говорит, что подобных стилей можно добавлять несколько, тем самым расширяя, переопределяя или перекрывая разные свойства элемента.
4) Определить пользовательский стиль можно также через, так называемый, ID:
В styles.css пишем:

#button2 {
    -fx-text-fill: yellow;
}

А в коде:

button2.setId("button2");

Получаемый диалог:

avafx and css styles 7

Т.е. все элементы с одинаковым ID будут выглядеть одинаково.
Что же еще интересного можно сделать с помощью стилей?

Стили могут обрабатывать так называемые триггеры поведения, привнесенные из мира XAML.
На примере кнопки к ним относятся такие события интерфейса пользователя, как получение фокуса, выделение, нажатие, пронос курсора мыши над кнопкой, и пр. – все то, что так хочется вынести из кода диалога, чтобы разгрузить его, оставив лишь бизнес-логику. Тем более, если клиент хочет не то, и не так, то это не отразиться на коде диалога – все будет определять динамически подключаемый CSS-файл.
Например с помощью следующей CSS-инструкции можно изменить цвет кнопки, когда пользователь проносит над ней курсор мыши:

.button:hover {
    -fx-background-color: orange;
}

Получаемый диалог при наведении курсора мыши:
avafx and css styles 8

Причем, как уже указывалось ранее, это приведет к одинаковому поведению всех классов button, а такой код:

.button1:hover {
    -fx-background-color: orange;
}

будет применять триггер только для тех, кто ссылается на класс «button1».

Подобный подход можно применять для многих триггеров, например для кнопок кроме hover есть еще foused, selected, pressed.
Жаль, что подобное нельзя применить непосредственно в коде, например:

button.setStyle(":hover -fx-text-fill: red");

Может быть в дальнейшем разработчики JavaFX предоставят нам такую возможность.
Для чего мы все это изучаем? Ведь в интернете можно найти массу гораздо более мощных примеров, и цель статьи была не в их копировании. Дело в том, что концепцию стилей и триггеров можно так же расширять для своих нужд, а вот это уже представляет интерес.
Мне нужно было сделать визуальный компонент на JavaFX, который бы служил для выбора размерности вставляемой в текст таблицы, причем внешний вид компонента, его цветовая схема, размеры и пр. не должны были бы быть его частью, а настраивались бы внешними CSS-файлами. Выглядеть он должен примерно так:

avafx and css styles 9

Пользователь проносит курсор мыши над сеткой и видит выделение, в данном случае для таблиц 7х8 ячеек, при клике на компоненте это выделение должно передаваться в программу, чтобы вставилась таблица 7х8 ячеек.
Конечно, можно отслеживать выделение в коде и окрашивать ячейки определенным цветом, но что если одному клиенту нравится один цвет, другому – другой, либо цветовая схема определяется общим механизмом «скина», или еще как-нибудь – что делать тогда?

Тут нужен простой компонент для выделения ячеек, а внешний вид – не его забота.

Очевидно, напрашивается взять предыдущий пример триггером «hover» для кнопки, но он сработает для каждой ячейки при проносе над ней курсора мыши, те же ячейки, что теряют курсор мыши, перестают соответствовать триггеру и теряют цвет выделения, как же оставить выделенным весь диапазон?
Для решения задачи нужно сделать свой триггер, срабатывающий на какое-то свойство объекта, например – «нахожусь в диапазоне выделения», а короче – inRage, которое можно было бы использовать в CSS-файле подобным образом:

.MyCell:inRange {
    -fx-border-width: 0.5;
    -fx-border-color: #ffffff;
    -fx-background-color: lightskyblue
}

Забегая вперед скажу, что задача достаточно непростая, да еще и решение для версий JavaFX 1.7 и 1.8 – принципиально различные, и, например, для 1.7 придется использовать массу deprecated-методов.
Для начала рассмотрим сам компонент:

public class JavaFXDialog2  extends Application {
    @Override
    public void start(Stage stage) {
        final VBox vbox = new VBox();
        final GridPaneEx table = new GridPaneEx();
        table.init(10, 10);
        final Label label = new Label();
        label.setMaxWidth(Double.MAX_VALUE);
        label.setAlignment(Pos.CENTER);
        label.setTextAlignment(TextAlignment.CENTER);
        label.setStyle("-fx-padding: 3 0 5 0");
        label.textProperty().bind(table.text);
        vbox.getChildren().addAll(label, table);
        final Scene scene = new Scene(vbox, 350, 300);
        scene.getStylesheets().add((getClass().getResource("/css/styles.css")).toExternalForm());
        scene.setFill(null);
        stage.setScene(scene);
        stage.show();
    }
 
 
    public static void main(String[] args) {
        launch(args);
    }
 
 
    private void fireCreateTable(final int cols, final int rows){
        System.out.println("cols = " + cols + ", rows = " + rows);
    }
 
 
    protected class GridPaneEx extends GridPane {
  
        public final StringProperty text = new SimpleStringProperty("cancel");
        private int cols;
        private int rows;
  
        public GridPaneEx(){
            this.setOnMouseExited(new EventHandler() {
                @Override
                public void handle(MouseEvent mouseEvent) {
                    text.setValue("cancel");
                    deselectAll();
                }
            });
        }
  
        public void init(final int cols, final int rows){
            getChildren().clear();
            this.cols = cols;
            this.rows = rows;
            for (int col = 0; col < cols; col++){
                for (int row = 0; row < rows; row++){
                    final Button rect = new Button();
                    rect.setMinSize(30, 10);
                    add(rect, col, row);
                    final int selectedCol = col;
                    final int selectedRow = row;
                    rect.setOnMouseMoved(new EventHandler() {
                        @Override
                        public void handle(MouseEvent mouseEvent) {
                            selectRange(selectedCol, selectedRow);
                            text.setValue((selectedCol + 1) + " x " + (selectedRow + 1));
                        }
                    });
                    rect.setOnAction(new EventHandler() {
                        @Override
                        public void handle(ActionEvent actionEvent) {
                            fireCreateTable(selectedCol + 1, selectedRow + 1);
                            deselectAll();
                        }
                    });
                }
            }
            deselectAll();
        }
  
        private Node getNodeFromGridPane(int col, int row) {
            for (Node node : getChildren()) {
                if (GridPane.getColumnIndex(node) == col && GridPane.getRowIndex(node) == row) {
                    return node;
                }
            }
            return null;
        }
  
        private void selectCell(int col, int row, final boolean select){
            final Node node = getNodeFromGridPane(col, row);
            if (select){
                node.setStyle("-fx-border-width: 0.5; -fx-border-color: #ffffff; -fx-background-color: lightskyblue");
            } else {
                node.setStyle("-fx-border-width: 0.5; -fx-border-color: #000000; -fx-background-color: #ffffff");
            }
        }
  
        public void deselectAll(){
            for (int col = 0; col < cols; col++){
                for (int row = 0; row < rows; row++){
                    selectCell(col, row, false);
                }
            }
        }
        private void selectRange(int selectedCol, int selectedRow){
            deselectAll();
            for (int col = 0; col <= selectedCol; col++){
                for (int row = 0; row <= selectedRow; row++){
                    selectCell(col, row, true);
                }
            }
        }
    }
}

Интерес представляет метод selectCell, в котором окраска ячеек осуществляется непосредственно в коде:
для обычных ячеек:

node.setStyle("-fx-border-width: 0.5; -fx-border-color: #000000; -fx-background-color: #ffffff");

для ячеек в выбранном диапазоне:

node.setStyle("-fx-border-width: 0.5; -fx-border-color: #ffffff; -fx-background-color: lightskyblue");

Т.к. в описании задачи указано, что чётко прописать цвета никак нельзя, то попробуем задать их с помощью собственного стиля, пометим в styles.css такой код:

#MyCellNormal {
    -fx-border-width: 0.5;
    -fx-border-color: #000000;
    -fx-background-color: #ffffff;
}
 
 
#MyCellInRange {
    -fx-border-width: 0.5;
    -fx-border-color: #ffffff;
    -fx-background-color: lightskyblue
}

А в метод selectCell:

private void selectCell(int col, int row, final boolean select){
            final Node node = getNodeFromGridPane(col, row);
            if (select){
                                node.setId("MyCellNormal");
            } else {
                                node.setId("MyCellInRange");
            }
        }

Уже лучше, не правда ли? Т.е. если клиента не устраивают цвета, то он меняет их прямо в styles.css, компонент же остается неизменным.
Но оказывается, что есть еще более элегантное решение – поместим в styles.css два стиля:

.MyCell {
    -fx-border-width: 0.5;
    -fx-border-color: #000000;
    -fx-background-color: #ffffff;
}
 
 
.MyCell:inRange {
    -fx-border-width: 0.5;
    -fx-border-color: #ffffff;
    -fx-background-color: lightskyblue
}

Это значит, что ячейки будут ориентироваться на стиль «MyCell», а когда у них будет срабатывать триггер «inRange», по аналогии «hover» или «pressed» на кнопке, то цвет должен измениться соответствующим образом.
Как «научить» ячейки запускать триггер?
Т.к. в качестве ячеек в нашем компоненте используется Button , то необходимо переопределить его поведение в нашем «наследнике», а точнее переопределить поведение так называемого «псевдо-класса», для JavaFX 1.7 это делается так:

protected static class RangeButton extends Button {
        public RangeButton(){
            getStyleClass().add("MyCell");
        }
 
 
        private BooleanProperty inRange = new BooleanPropertyBase() {
 
 
            @Override
            protected void invalidated() {
                impl_pseudoClassStateChanged("inRange");
            }
 
 
            @Override
            public Object getBean() {
                return RangeButton.this;
            }
 
 
            @Override
            public String getName() {
                return "inRange";
            }
        };
 
 
        public boolean isInRange() {
            return inRange.get();
        }
 
 
        public void setInRange(boolean value) {
            inRange.set(value);
        }
 
 
        private static final long IN_RANGE_PSEUDOCLASS_STATE = StyleManager.getInstance().getPseudoclassMask("inRange");
 
 
        @Override
        public long impl_getPseudoClassState() {
            long mask = super.impl_getPseudoClassState();
            if (isInRange()) mask |= IN_RANGE_PSEUDOCLASS_STATE;
            return mask;
        }
    }

Как видно, все методы «…PseudoClass…» — deprecated.
Теперь вместо Button используем наш RangeButton и смотрите, как теперь преобразится метод selectCell:

private void selectCell(int col, int row, final boolean select){
            final Node node = getNodeFromGridPane(col, row);
            ((RangeButton)node).setInRange(select);
        }

Т.е. изменение состояния поля «InRange» приводит к срабатыванию триггера из стиля и цвет выделенных в диапазоне ячеек изменится соответствующим образом.
Это именно то, что нам нужно!
Для JavaFX 1.7 это работает, но в JavaFX 1.8 – запрещено, поэтому такой код перестанет компилироваться, как только будет подключена JVM 1.8.
Что же предлагает нам новая версия? Как и ожидалось deprecated-методы были убраны и упрощена архитектура в целом. Теперь достаточно сделать так:

protected static class RangeButton extends Button {        protected final PseudoClass pcInRange = PseudoClass.getPseudoClass("inRange");
 
 
        public RangeButton(){
            getStyleClass().add("MyCell");
        }
 
 
        protected final BooleanProperty inRange = new BooleanPropertyBase() {
 
 
            @Override
            protected void invalidated() {
                pseudoClassStateChanged(pcInRange, getValue());
            }
 
 
            @Override
            public Object getBean() {
                return RangeButton.this;
            }
 
 
            @Override
            public String getName() {
                return "inRange";
            }
        };
 
 
        public boolean isInRange() {
            return inRange.get();
        }
 
 
        public void setInRange(boolean value) {
            inRange.set(value);
        }
    }

Все работает, как и прежде.
Можно еще упростить код:

protected static class RangeButton extends Button {
        protected final BooleanProperty inRange;
        public RangeButton(){
            getStyleClass().add("MyCell");
            final PseudoClass pcInRange = PseudoClass.getPseudoClass("inRange");
            inRange = new SimpleBooleanProperty();
            inRange.addListener(new ChangeListener() {
                @Override
                public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
                    pseudoClassStateChanged(pcInRange, newValue);
                }
            });
        }

И изменить метод соответствующим образом:

private void selectCell(int col, int row, final boolean select){
            final Node node = getNodeFromGridPane(col, row);
            ((RangeButton)node).inRange.setValue(select);
        }

Резюме: с помощью описанного способа на JavaFX можно создавать специальные пользовательские свойства компонентов и связывать их со стилями, что очень удобно, если нужно, чтобы бизнес-логика была полностью абстрагированной от внешнего вида.

Опубликовано в Allgemein, актуально, статья