javaanimationjavafx

How to interpolate Node color using custom CSS variables?


There's a node and I need to dynamically change its color. I also want to do this using CSS variables. The problem is that JavaFX seems to only perform a CSS lookup when a node property (fill) is explicitly bound to the corresponding styleable object property, the value of which should be obtained via CSS. In other words, CSS only works if styleable property is bound to a node property and that node exists in the scene graph.

But if the Node property is already bound, I can't interpolate its value in the Timeline. Is there any workaround here? For example, can I somehow manually trigger the CSS variable lookup before the timeline starts?

Minimal reproducible example:

public class ExampleApp extends Application {

    public static void main(String[] args) {
        launch();
    }

    @Override
    public void start(Stage stage) {
        var r = new AnimatedRect(200, 200);
        // actual: rect flashes red and blue
        // expected: rect flashes green and orange
        r.setStyle("-color1: green; -color2: orange;");

        var scene = new Scene(new BorderPane(r), 200, 200);
        stage.setScene(scene);
        stage.show();
    }

    static class AnimatedRect extends Rectangle {

        public AnimatedRect(double width, double height) {
            super(width, height);
            setFill(color1.get());

            // if you bind the color property to the rect fill, the CSS variables will start to work,
            // but the timeline will stop because it's forbidden to change a bound value,
            // ... and unfortunately bidirectional binding won't help here either
            // fillProperty().bind(color1);

            var timeline = new Timeline(
                new KeyFrame(Duration.millis(0),
                    new KeyValue(fillProperty(), color1.get(), LINEAR)
                ),
                new KeyFrame(Duration.millis(1000),
                    new KeyValue(fillProperty(), color2.get(), LINEAR)
                )
            );
            timeline.setCycleCount(Timeline.INDEFINITE);
            timeline.setAutoReverse(false);

            sceneProperty().addListener((obs, o, n) -> {
                if (n != null) {
                    timeline.play();
                } else {
                    timeline.stop();
                }
            });
        }

        final StyleableObjectProperty<Paint> color1 = new SimpleStyleableObjectProperty<>(
            StyleableProperties.COLOR1, AnimatedRect.this, "-color1", Color.RED
        );

        final StyleableObjectProperty<Paint> color2 = new SimpleStyleableObjectProperty<>(
            StyleableProperties.COLOR2, AnimatedRect.this, "-color2", Color.BLUE
        );

        static class StyleableProperties {

            private static final CssMetaData<AnimatedRect, Paint> COLOR1 = new CssMetaData<>(
                "-color1", PaintConverter.getInstance(), Color.RED
            ) {
                @Override
                public boolean isSettable(AnimatedRect c) {
                    return !c.color1.isBound();
                }

                @Override
                public StyleableProperty<Paint> getStyleableProperty(AnimatedRect c) {
                    return c.color1;
                }
            };

            private static final CssMetaData<AnimatedRect, Paint> COLOR2 = new CssMetaData<>(
                "-color2", PaintConverter.getInstance(), Color.BLUE
            ) {
                @Override
                public boolean isSettable(AnimatedRect c) {
                    return !c.color2.isBound();
                }

                @Override
                public StyleableProperty<Paint> getStyleableProperty(AnimatedRect c) {
                    return c.color2;
                }
            };

            private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;

            static {
                final List<CssMetaData<? extends Styleable, ?>> styleables =
                    new ArrayList<>(Rectangle.getClassCssMetaData());
                styleables.add(COLOR1);
                styleables.add(COLOR2);
                STYLEABLES = Collections.unmodifiableList(styleables);
            }
        }

        public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
            return StyleableProperties.STYLEABLES;
        }

        @Override
        public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
            return getClassCssMetaData();
        }
    }
}

UPDATE:

I found the problem. JavaFX resolves CSS variables after the node is connected to the scene. My previous code creates the timeline before the color values change. So I need to listen for color changes and update the timeline accordingly. Since it's immutable, the only way is to create a new object. It's still not optimal, because if I update both colors, the animation will be played twice, but at least it works now.

static class AnimatedRect extends Rectangle {

SimpleObjectProperty<Timeline> timeline = new SimpleObjectProperty<>();

public AnimatedRect(double width, double height) {
    super(width, height);
    setFill(color1.get());

    color1.addListener((obs, o, v) -> {
        if (timeline.get() != null) {
            timeline.get().stop();
        }
        timeline.set(createTimeline());
        timeline.get().play();
    });

    color2.addListener((obs, o, v) -> {
        if (timeline.get() != null) {
            timeline.get().stop();
        }

        timeline.set(createTimeline());
        timeline.get().play();
    });

    sceneProperty().addListener((obs, o, n) -> {
        if (n != null) {
            if (timeline.get() != null) {
                timeline.get().play();
            }
        } else {
            if (timeline.get() != null) {
                timeline.get().stop();
            }
        }
    });
}

Timeline createTimeline() {
    var timeline = new Timeline(
        new KeyFrame(Duration.millis(0),
            new KeyValue(fillProperty(), color1.getValue(), LINEAR)
        ),
        new KeyFrame(Duration.millis(1000),
            new KeyValue(fillProperty(), color2.getValue(), LINEAR)
        )
    );
    timeline.setCycleCount(Timeline.INDEFINITE);
    timeline.setAutoReverse(false);

    return timeline;
}

// .. the rest of the code

Solution

  • In the original code, you look up the values of the colors in the constructor of AnimatedRectangle, which will necessarily have their default values at that point, and use those to create the timeline. Once you set the colors via the call to setStyle(...), the timeline is already created and its "endpoint colors" are essentially immutable.

    (The problem is exacerbated, as noted in the update to the question, by the fact that the values of the CSS properties will not actually be set until applyCSS() is called on the rectangle, which is typically on the first layout pass after the rectangle is added to a scene.)

    A better solution is simply to look up the values of the endpoint colors on each frame of the animation. You can do this, for example, using a Transition instead of a Timeline (another solution would be to use an AnimationTimer). Here is a modified constructor which will work the way you want:

            public AnimatedRect(double width, double height) {
                super(width, height);
                setFill(color1.get());
    
    //            var timeline = new Timeline(
    //                    new KeyFrame(Duration.millis(0),
    //                            new KeyValue(fillProperty(), color1.get(), Interpolator.LINEAR)
    //                    ),
    //                    new KeyFrame(Duration.millis(1000),
    //                            new KeyValue(fillProperty(), color2.get(), Interpolator.LINEAR)
    //                    )
    //            );
    //            timeline.setCycleCount(Timeline.INDEFINITE);
    //            timeline.setAutoReverse(false);
    
                Transition transition = new Transition() {
                    {
                        setCycleDuration(Duration.seconds(1));
                    }
                    @Override
                    protected void interpolate(double v) {
                        Paint p1 = color1.get();
                        Paint p2 = color2.get();
                        // if these are both colors, interpolate them. 
                        // If they're e.g. gradients, just switch halfway:
                        if (p1 instanceof Color c1 && p2 instanceof Color c2) {
                            setFill(c1.interpolate(c2, v));
                        } else {
                            setFill(v <= 0.5 ? p1 : p2);
                        }
                    }
                };
                transition.setCycleCount(Animation.INDEFINITE);
                sceneProperty().subscribe(scene -> {
                    if (scene != null) {
                        transition.play();
                    } else {
                        transition.stop();
                    }
                });
            }
    

    Note this allows you to dynamically change the style of the rectangle while the animation is running.

    Also note that I changed sceneProperty().addListener(...) to sceneProperty().subscribe(), which I find slightly cleaner. Calling this would also work if the rectangle were already part of a scene (which is impossible in the current case, but could be possible under some code refactoring). subscribe() was introduced in JavaFX 21.