, ,

Java­FX, CSS-Styles sowie Eigen­schaf­ten der Kom­po­nen­ten10 min read

In die­sem Bei­trag möch­ten wir eini­ge Vor­tei­le der Java­FX-Biblio­thek erläu­tern, die beson­ders im Bezug auf das User­in­ter­face eine Rol­le spielt.
Als einer der wich­ti­gen Vor­tei­le der Java­FX Biblio­thek gilt die Mög­lich­keit der Nut­zung von Styles (CSS), die uns aus dem WWW bekannt sind.
Dadurch ver­liert sich die Abhän­gig­keit zu der Biblio­thek Look & Feel. Man kann jedoch selbst das Aus­se­hen der Appli­ka­ti­on bestim­men, was auch ziem­lich fle­xi­bel und schön mög­lich ist. Die Gestal­tung kann dyna­misch erfol­gen, eine Ani­ma­ti­on beinhal­ten oder auch 3D Gra­fik.
Durch die Benut­zung der Style-Kon­zep­ti­on ist es nun mög­lich, die Appli­ka­tio­nen mit so genann­ten “Skins” zu erstel­len, durch die das Aus­se­hen der Appli­ka­ti­on kom­plett von der Busi­ness­lo­gik los­ge­löst wird und mehr Indi­vi­du­al­tät erhält. Sol­che indi­vi­du­el­len Skins kön­nen sogar sepa­rat von einem Desi­gner erstellt wer­den.

Erstel­len wir ein ein­fa­ches Bei­spiel eines Dia­log­fens­ters mit einem But­ton:

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

Styles kann man unter­schied­lich anwen­den:
1) Unmit­tel­bar im Code, um z.B. die Schrift­far­be in dem But­ton zu ändern:

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

avafx and css styles 2

2) Mit Hil­fe einer CSS-Datei, auf die die Klas­se Sce­ne aus­ge­rich­tet wer­den soll:
Dafür wird eine Datei mit der Erwei­te­rung .css erstellt und unter dem Pro­jekt­ver­zeich­nis abge­legt, z.B. /css/styles.css.
Inhalt der Datei:

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

Dabei ist es sehr wich­tig, die Ent­wick­lungs­um­ge­bung so ein­zu­rich­ten, dass sie die­se CSS-Datei­en beim Bil­den der Appli­ka­ti­on auch mit­ko­piert.
Unter Intel­liJ IDEA wird es bei­spiels­wei­se fol­gen­der­ma­ßen gemacht:

avafx and css styles 3

Nun ist alles fer­tig, um die Style-Datei ein­zu­bin­den:

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

Wir star­ten das Pro­jekt und bekom­men fol­gen­des Dia­log­fens­ter:
avafx and css styles 4

Instruk­ti­on .but­ton in der CSS-Datei sagt aus, dass nun alle Knöp­fe eine blaue Schrift­far­be haben wer­den:

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

avafx and css styles 5

Und was, wenn das nicht das ist, was wir brau­chen? Was, wenn wir einen kon­kre­ten Knopf defi­nie­ren wol­len?
3) Abhil­fe schafft die user­be­zo­ge­ne Defi­ni­ti­on des Knopf-Styles:
In styles.css schrei­ben wir:

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

Und im Code:

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

Das Dia­log­fens­ter sieht so aus:
avafx and css styles 6

Nun haben alle Knöp­fe, die mit der Style-Klas­se ver­bun­den sind, grü­ne Schrift­far­be, wobei die Metho­de add() uns dabei Hin­wei­se gibt, dass wir meh­re­re von sol­chen Styles hin­zu­fü­gen kön­nen, wodurch ver­schie­de­ne Ele­ment­ei­gen­schaf­ten erwei­tert, vor­de­fi­niert oder über­la­den wer­den.
4) User-Style kann man auch durch den so genann­ten ID defi­nie­ren:

In der styles.css schrei­ben wir:

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

Und im Code:

button2.setId("button2");

Als Ergeb­nis bekom­men wir fol­gen­des Dia­log­fens­ter:

avafx and css styles 7

D.h. alle Ele­men­te mit der glei­chen ID sehen gleich aus.

Was kann man sonst noch Inter­es­san­tes mit den Styles machen?

Styles kön­nen auch die so genann­ten Ver­hal­tens­trig­ger bear­bei­ten, die aus der XAML Welt kom­men.
Wenn wir bei dem Bei­spiel mit dem Knopf blei­ben, gehö­ren zu die­sen Trig­gern sol­che Ereig­nis­se der GUI wie Fokus, Selek­ti­on, Mou­se­down, Mou­seo­ver usw. also alles, was man nicht im Dia­log­fens­ter haben möch­te. Es beinhal­tet nur den Bui­siness­lo­gin bei den kun­den­sei­ti­gen Ände­rungs­wün­schen und es wird nur die CSS-Datei geän­dert und nicht die Logik.
So kann man z.B. mit der fol­gen­den CSS-Defi­ni­ti­on die Far­be des Knop­fes ändern, wenn der User mit der Maus dar­über fährt.

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

So sieht das Dia­log­fens­ter bei Mou­seo­ver aus:

avafx and css styles 8

Wie bereits oben erwähnt, führt das zum glei­chen Ver­hal­ten von allen But­ton-Klas­sen. Mit die­sem Code:

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

wer­den die Trig­ger nur für die Ele­men­te ange­wen­det, die auf die Klas­se «button1» ver­wei­sen.

Die­se Vor­ge­hens­wei­se kann man bei vie­len Trig­ger anwen­den, bei Knöp­fen gibt es z.B. auch noch fou­sed, selec­ted oder pres­set.

Lei­der kann das nicht direkt im Code genutzt wer­den:

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

Viel­leicht wird die­se Mög­lich­keit in der Zukunft durch Java­FX Deve­l­oper rea­li­siert.

Wozu brau­chen wir das alles? Im Inter­net kann man noch eine Rei­he an Bei­spie­len fin­den, die deut­li­cher sind als die­ses. Das Ziel des Bei­trags war nicht, die­se zu kopie­ren. Uns geht es dar­um, dass die Kon­zep­ti­on von Syles und Trig­ger  auch für den eige­nen Bedarf erwei­tert wer­den kann, das ist für uns von Inter­es­se.

Als Bei­spiel kön­nen wir fol­gen­de Aus­gangs­si­tua­ti­on betrach­ten:
wir wol­len mit Java­FX eine visu­el­le Kom­po­nen­te umset­zen, die für die Aus­wahl der Grö­ße einer im Text ein­ge­füg­ten Tabel­le die­nen soll. Das Aus­se­hen der Kom­po­nen­te, das Farb­sche­ma, die Grö­ße usw., sol­len dabei kein Bestand­teil der Busi­ness­lo­gik der Kom­po­nen­te sein, son­dern über eine exter­ne CSS-Datei kon­fi­gu­rier­bar sein.

Das soll­te unge­fähr so aus­se­hen:

avafx and css styles 9

Der User geht mit dem Maus­zei­ger über die Tabel­le und sieht sei­ne aus­ge­wähl­ten Ele­men­te, in dem Fall eine Tabel­le mit 7 x 8 Zel­len. Beim Klick auf die Kom­po­nen­te soll­te die Aus­wahl an das Pro­gramm über­mit­telt wer­den, um eine ent­spre­chen­de Tabel­le ein­zu­fü­gen.
Sicher­lich kann man auf die Aus­wahl über den Code reagie­ren, aber was wenn der eine Kun­de eine bestimm­te Far­be bevor­zugt, der ande­re aber ganz ande­re Far­ben bevor­zugt? Mög­lich ist auch, dass das Farb­sche­ma durch ein «Skin» oder sonst wie vor­de­fi­niert wird — was dann?

Hier wird nur eine Kom­po­nen­te für die Zel­len­se­lek­ti­on benö­tigt, um deren Aus­se­hen es an die­ser Stel­le aber nicht gehen soll.

Hier kön­nen wir auf das vor­an­ge­gan­ge­ne Bei­spiel mit dem Trig­ger «hover» für den But­ton anknüp­fen. Der wirkt aller­dings für jede Zel­le, wenn man mit dem Maus­zei­ger über sie fährt. Die Zel­len, die der Maus­zei­ger ver­las­sen hat, ent­spre­chen dem Trig­ger nicht mehr und fal­len somit aus der Aus­wahl. Wie kön­nen wir den gesam­ten Bereich aus­ge­wählt behal­ten?

Für die Lösung die­ser Auf­ga­be wird ein eige­ner Trig­ger erstellt, der auf eine bestimm­te Eigen­schaft des Objek­tes reagiert, z.B. «bin im Dia­pa­son der Selek­ti­on» oder inRange, die wir fol­gen­der­ma­ßen in der CSS-Datei defi­nie­ren kön­nen:

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

An der Stel­le muss man aber sagen, dass dies ist kei­ne ein­fa­che Auf­ga­be ist. Zudem ist die Lösung für die Java­FX Ver­sio­nen 1.7 und 1.8 gänz­lich unter­schied­lich. Für die Lösung in der Ver­si­on 1.7. sind wir auf die Nut­zung einer Men­ge der depre­ca­ted-Metho­den ange­wie­sen.

Für den Anfang schau­en wir uns die Kom­po­nen­te an:

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);
                }
            }
        }
    }
}

Beson­ders inter­es­sant ist die Metho­de select­Cell, in der die Zel­len­fär­bung unmit­tel­bar im Code rea­li­siert wird:

Für nor­ma­le Zel­len:

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

Für Zel­len im aus­ge­wähl­ten Bereich:

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

Weil es in der Auf­ga­ben­be­schrei­bung steht, dass kla­re Far­ben­zu­wei­sung unmög­lich ist, ver­su­chen wir die mit Hil­fe eines eige­nen Styles in styles.css zu defi­nie­ren:

#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
}

Und in der Metho­de select­Cell:

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");
            }
        }

Schon bes­ser, nicht war? D.h. wenn der Kun­de mit der Farb­wahl unzu­frie­den sein soll­te, kann man sie direkt in der Datei styles.css ändern. Die Logik der Kom­po­nen­te bleibt dabei unver­än­dert.

Aller­dings gibt es eine noch ele­gan­te­re Lösung der Auf­ga­be: Plat­zie­ren wir in styles.css noch zusätz­lich 2 Syt­les:

.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
}

Das bedeu­tet, dass die Zel­len sich auf den Style «MyCell» ori­en­tie­ren und wenn der Trig­ger «inRange» greift, ana­log zu «hover» oder «pres­set», ändert sich die Far­be ent­spre­chend.
Aber wie brin­gen wir der Zel­le bei, den Trig­ger zu star­ten?
Da wir in unse­rem Bei­spiel für die Zel­len, But­ton-Ele­men­te nut­zen, ist es erfor­der­lich ihr Ver­hal­ten in der so genann­ten Pseu­do-Klas­se neu zu defi­nie­ren. In Java­FX 1.7 wird das so gemacht:

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;
        }
    }

Wie wir sehen, sind alle Metho­den «…Pseu­do­Class…» — depre­ca­ted.
Jetzt nut­zen wir statt But­ton unse­ren Ran­ge­But­ton. Und die Metho­de select­Cell sieht nun so aus:

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

D.h. die Ände­rung der Feld­ei­gen­schaft «InRange» führt dazu, dass der Style-Trig­ger greift und die Far­be der aus­ge­wähl­ten Zel­len sich ent­spre­chend ändert.
Das ist genau das, was wir brau­chen!
In Java­FX 1.7 funk­tio­niert das. In Java­FX 1.8 ist das lei­der ver­bo­ten. Der Code ist nicht mehr kom­pi­lier­bar sobald JVM 1.8 hin­zu­ge­schal­tet wird.
Was bie­tet uns dann die neue Ver­si­on an der Stel­le?
Wie bereits erwar­tet, wur­den die depre­ca­ted Metho­den ent­fernt und die Archi­tek­tur ver­ein­facht. Jetzt reicht es daher aus, wenn wir fol­gen­des tun:

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);
        }
    }

Alles funk­tio­niert wie bis­her.
Man kann den Code noch etwas ver­ein­fa­chen…:

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);
                }
            });
        }

…und die Metho­de ent­spre­chend ändern:

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

Zusam­men­fas­sung: Mit Hil­fe der beschrie­be­nen Lösung auf Java­FX ist es mög­lich, spe­zi­el­le user­be­zo­ge­ne Eigen­schaf­ten der Kom­po­nen­ten zu erstel­len und die­se mit den Styles zu ver­knüp­fen,. Das ist beson­ders bequem, wenn die Anfor­de­rung besteht, die Busi­ness­lo­gik kom­plett von der GUI zu abs­tra­hie­ren.