Search code examples
javajavafxeffectsfluent-design

Implementing Fluent Design's reveal highlight effect in JavaFX


I'd like to implement the reveal highlight effect for JavaFX that can be seen in various parts of Windows 10, particularly the Settings and Calculator apps.

The effect seems to be composed for two parts, a border highlight (seen here) and a background highlight (seen here, though admittedly looks better in person due to compression).

My first instinct was to see if this could be done in some sort of pixel shader but after googling around for that it seems JavaFX does provide a public API for anything like that?

Would it be possible to create this effect without resorting to a canvas and drawing the whole UI by hand?


Solution

  • First I'd like to say I have no idea how Windows implements that style. But one idea I had is to have multiple layers:

    1. A black background.

    2. A circle with a radial gradient going from white to transparent that moves with the mouse.

    3. A region with a black background and a shape that has holes wherever the option nodes are.

    4. The option nodes with a layered background.

      • When the mouse is not hovering:

        1. Transparent background with no insets.
        2. Black background with a slight inset.
      • When the mouse is hovering:

        1. Low opacity white background with no insets.
        2. Black background with a slight inset.
        3. White-to-transparent radial gradient background that's centered on the mouse.

    Unfortunately that means a lot of the styling has to be done in the code even though I'd prefer to put most of it in CSS. Here's a proof-of-concept I quickly mocked up. It's not fully functional but shows the look you want is possible.

    OptionsPane.java

    import javafx.beans.InvalidationListener;
    import javafx.collections.FXCollections;
    import javafx.collections.ObservableList;
    import javafx.geometry.Bounds;
    import javafx.geometry.HPos;
    import javafx.geometry.Insets;
    import javafx.geometry.VPos;
    import javafx.scene.Node;
    import javafx.scene.control.Label;
    import javafx.scene.input.MouseEvent;
    import javafx.scene.layout.Background;
    import javafx.scene.layout.BackgroundFill;
    import javafx.scene.layout.HBox;
    import javafx.scene.layout.Priority;
    import javafx.scene.layout.Region;
    import javafx.scene.layout.TilePane;
    import javafx.scene.layout.VBox;
    import javafx.scene.paint.Color;
    import javafx.scene.paint.RadialGradient;
    import javafx.scene.paint.Stop;
    import javafx.scene.shape.Circle;
    import javafx.scene.shape.Rectangle;
    import javafx.scene.shape.Shape;
    import javafx.scene.text.Font;
    
    public class OptionsPane extends Region {
    
      public static class Option {
    
        private final String title;
        private final String subtitle;
        private final Node graphic;
    
        public Option(String title, String subtitle, Node graphic) {
          this.title = title;
          this.subtitle = subtitle;
          this.graphic = graphic;
        }
    
        public String getTitle() {
          return title;
        }
    
        public String getSubtitle() {
          return subtitle;
        }
    
        public Node getGraphic() {
          return graphic;
        }
      }
    
      private final ObservableList<Option> options = FXCollections.observableArrayList();
    
      private final TilePane topTiles = new TilePane();
      private final Region midCover = new Region();
      private final Circle underGlow = new Circle();
    
      public OptionsPane() {
        setBackground(new Background(new BackgroundFill(Color.BLACK, null, null)));
    
        underGlow.setManaged(false);
        underGlow.setRadius(75);
        underGlow.visibleProperty().bind(hoverProperty());
        underGlow.setFill(
            new RadialGradient(
                0, 0,
                0.5, 0.5,
                1.0,
                true,
                null,
                new Stop(0.0, Color.WHITE),
                new Stop(0.35, Color.TRANSPARENT)));
        addEventFilter(
            MouseEvent.MOUSE_MOVED,
            e -> {
              underGlow.setCenterX(e.getX());
              underGlow.setCenterY(e.getY());
            });
    
        midCover.setBackground(new Background(new BackgroundFill(Color.BLACK, null, null)));
    
        topTiles.setMinSize(0, 0);
        topTiles.setVgap(20);
        topTiles.setHgap(20);
        topTiles.setPadding(new Insets(20));
        topTiles.setPrefTileWidth(250);
        topTiles.setPrefTileHeight(100);
        topTiles.setPrefColumns(3);
        options.addListener(
            (InvalidationListener)
                obs -> {
                  topTiles.getChildren().clear();
                  options.forEach(opt -> topTiles.getChildren().add(createOptionRegion(opt)));
                });
    
        getChildren().addAll(underGlow, midCover, topTiles);
      }
    
      public final ObservableList<Option> getOptions() {
        return options;
      }
    
      @Override
      protected void layoutChildren() {
        double x = getInsets().getLeft();
        double y = getInsets().getTop();
        double w = getWidth() - getInsets().getRight() - x;
        double h = getHeight() - getInsets().getBottom() - y;
    
        layoutInArea(midCover, x, y, w, h, -1, HPos.CENTER, VPos.CENTER);
        layoutInArea(topTiles, x, y, w, h, -1, HPos.CENTER, VPos.CENTER);
    
        Shape coverShape = new Rectangle(x, y, w, h);
        for (Node optionNode : topTiles.getChildren()) {
          Bounds b = optionNode.getBoundsInParent();
          Rectangle rect = new Rectangle(b.getMinX(), b.getMinY(), b.getWidth(), b.getHeight());
          coverShape = Shape.subtract(coverShape, rect);
        }
        midCover.setShape(coverShape);
      }
    
      private Region createOptionRegion(Option option) {
        Label titleLabel = new Label(option.getTitle());
        titleLabel.setTextFill(Color.WHITE);
        titleLabel.setFont(Font.font("System", 13));
    
        Label subtitleLabel = new Label(option.getSubtitle());
        subtitleLabel.setTextFill(Color.DARKGRAY);
        subtitleLabel.setFont(Font.font("System", 10));
    
        VBox textBox = new VBox(5, titleLabel, subtitleLabel);
        HBox.setHgrow(textBox, Priority.ALWAYS);
    
        HBox container = new HBox(10, textBox);
        container.setPadding(new Insets(10));
        if (option.getGraphic() != null) {
          container.getChildren().add(0, option.getGraphic());
        }
    
        setNonHoverBackground(container);
        container
            .hoverProperty()
            .addListener(
                (obs, ov, nv) -> {
                  if (!nv) {
                    setNonHoverBackground(container);
                  }
                });
    
        container.setOnMouseMoved(e -> setHoverBackground(container, e.getX(), e.getY()));
    
        return container;
      }
    
      private void setNonHoverBackground(Region region) {
        BackgroundFill fill1 = new BackgroundFill(Color.TRANSPARENT, null, null);
        BackgroundFill fill2 = new BackgroundFill(Color.BLACK, null, new Insets(2));
        region.setBackground(new Background(fill1, fill2));
      }
    
      private void setHoverBackground(Region region, double x, double y) {
        RadialGradient gradient =
            new RadialGradient(
                0, 0,
                x, y,
                400,
                false,
                null,
                new Stop(0.0, new Color(1, 1, 1, 0.2)),
                new Stop(0.35, Color.TRANSPARENT));
    
        BackgroundFill fill1 = new BackgroundFill(new Color(1, 1, 1, 0.3), null, null);
        BackgroundFill fill2 = new BackgroundFill(Color.BLACK, null, new Insets(2));
        BackgroundFill fill3 = new BackgroundFill(gradient, null, null);
        region.setBackground(new Background(fill1, fill2, fill3));
      }
    }
    

    Main.java

    import java.util.ArrayList;
    import java.util.List;
    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.scene.paint.Color;
    import javafx.scene.shape.Rectangle;
    import javafx.stage.Stage;
    
    public class Main extends Application {
    
      @Override
      public void start(Stage primaryStage) {
        OptionsPane pane = new OptionsPane();
    
        List<OptionsPane.Option> options = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
          Rectangle graphic = new Rectangle(20, 20, Color.DARKGRAY);
          options.add(
              new OptionsPane.Option("Option Title #" + (i + 1), "Description #" + (i + 1), graphic));
        }
        pane.getOptions().addAll(options);
    
        primaryStage.setScene(new Scene(pane));
        primaryStage.show();
      }
    }
    

    And this is what it looks like:

    Demo GIF of code.

    It's not exactly the same but you can experiment yourself and change things as you want.