package com.twasyl.slideshowfx.controls; import com.twasyl.slideshowfx.utils.PlatformHelper; import com.twasyl.slideshowfx.utils.ResourceHelper; import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon; import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView; import javafx.animation.FadeTransition; import javafx.animation.ParallelTransition; import javafx.animation.ScaleTransition; import javafx.animation.TranslateTransition; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.BoundingBox; import javafx.geometry.Bounds; import javafx.geometry.Pos; import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.Tooltip; import javafx.scene.effect.BlendMode; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.layout.Background; import javafx.scene.layout.GridPane; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.util.Duration; /** * This class allows to create a guided tour for a given screen. A list of {@link com.twasyl.slideshowfx.controls.Tour.Step} * is provided and must be filled. They are displayed when the user * uses the <code>RIGHT</code> arrow key to move * forward and the <code>LEFT</code> arrow key to move backward. The <code>ESCAPE</code> key is used to exit the tour. * In order to start the tour, the {@link #start()} method must be called. To end the tour, the user must hit the * <code>ESCAPE</code> key or the {@link #end()} method must be called. * * @author Thierry Wasylczenko * @version 1.0 * @since SlideshowFX 1.0 */ public class Tour extends StackPane { public static class Step { private String selector; private String tooltip; public Step(String selector, String tooltip) { this.selector = selector; this.tooltip = tooltip; } public String getSelector() { return selector; } public void setSelector(String selector) { this.selector = selector; } public String getTooltip() { return tooltip; } public void setTooltip(String tooltip) { this.tooltip = tooltip; } } private ObservableList<Step> steps = FXCollections.observableArrayList(); private int currentStep = -1; private Scene scene; private Parent initialParent; private Group tourGroup = new Group(); private Rectangle tourBackground; private Rectangle tourHighlight; private Tooltip tourTooltip; public Tour(Scene scene) { this.scene = scene; this.setAlignment(Pos.TOP_LEFT); } public Tour addStep(Step step) { this.steps.add(step); return this; } /** * Start the tour. This method is mandatory in order to display the tour. It initializes the graphical elements that * are needed to display the tour (the background, the highlight). Keys for navigating in the tour are also defined * in this method. */ public void start() { this.initialParent = this.scene.getRoot(); // Initialize the background this.tourBackground = new Rectangle(0, 0, this.scene.getWidth(), this.scene.getHeight()); this.tourBackground.setFill(Color.WHITE); this.tourBackground.setOpacity(0); // Initialize the highlight this.tourHighlight = new Rectangle(0, 0, 1, 1); this.tourHighlight.setFill(Color.BLACK); this.tourHighlight.setOpacity(1); // Initialize the Tooltip this.tourTooltip = new Tooltip(); this.tourTooltip.setAutoHide(false); this.tourTooltip.setHideOnEscape(false); this.tourTooltip.setWrapText(true); this.tourTooltip.setStyle("-fx-font-size: 20pt;"); this.tourTooltip.setMaxWidth(this.initialParent.getLayoutBounds().getWidth() - 10); Tooltip.install(this.tourHighlight, this.tourTooltip); // Group all this.tourGroup = new Group(this.tourBackground, this.tourHighlight); this.tourGroup.setBlendMode(BlendMode.DIFFERENCE); // Set the next and previous listeners this.setOnKeyPressed(event -> { if (event.getCode() == KeyCode.RIGHT) this.next(); else if (event.getCode() == KeyCode.LEFT) this.previous(); else if (event.getCode() == KeyCode.ESCAPE) this.end(); }); // Prepare transitions for fade in final FadeTransition tourBackgroundTransition = new FadeTransition(Duration.millis(300), this.tourBackground); tourBackgroundTransition.setToValue(0.3); PlatformHelper.run(() -> { this.getChildren().addAll(this.initialParent, this.tourGroup); this.scene.setRoot(this); this.requestFocus(); tourBackgroundTransition.play(); tourBackgroundTransition.setOnFinished(event -> this.moveHighlight(null, this.getInstructionsNode())); }); } /** * End the tour and restore the initial view of the scene. */ public void end() { PlatformHelper.run(() -> { Tooltip.uninstall(this.tourHighlight, this.tourTooltip); this.tourTooltip.hide(); final FadeTransition tourBackgroundFadeOut = new FadeTransition(Duration.millis(300), this.tourBackground); tourBackgroundFadeOut.setToValue(0); final FadeTransition tourHighlightFadeOut = new FadeTransition(Duration.millis(300), this.tourHighlight); tourHighlightFadeOut.setToValue(0); final ParallelTransition globalFadeOut = new ParallelTransition(tourBackgroundFadeOut, tourHighlightFadeOut); globalFadeOut.setOnFinished(event -> { PlatformHelper.run(() -> { this.getChildren().remove(this.initialParent); this.scene.setRoot(this.initialParent); }); }); globalFadeOut.play(); }); } /** * Go to the next step of the tour. When the end of the tour is reach, the reload screen is displayed. */ public synchronized void next() { if(this.currentStep < this.steps.size() - 1) { this.moveHighlight(this.steps.get(++this.currentStep), null); } else { this.moveHighlight(null, this.getReloadNode()); } } /** * Go to the previous step of the tour. When the beginning of the tour is reached, the instructions screen is * displayed. */ public synchronized void previous() { if(this.currentStep > 0) { this.moveHighlight(this.steps.get(--this.currentStep), null); } else if(this.currentStep == 0) { this.moveHighlight(null, this.getInstructionsNode()); } } /** * Reload the tour by displaying the first step of it. */ public synchronized void reload() { this.currentStep = -1; this.next(); } /** * Moves the highlight to the target represented by {@code step.getSelector()}. The additional {@code graphic} is added * to the tooltip as part of the {@code step.getTooltip()}. Both {@code step} and {@code graphic} could be null, but * the displayed result may not be accurate. * @param step * @param graphic */ private synchronized void moveHighlight(final Step step, final Node graphic) { final Node target = step != null ? this.initialParent.lookup(step.getSelector()) : null; this.tourTooltip.hide(); final double scaleToX = target == null ? 1 : (target.getLayoutBounds().getWidth() + 10) / this.tourHighlight.getWidth(); final double scaleToY = target == null ? 1 : (target.getLayoutBounds().getHeight() + 10) / this.tourHighlight.getHeight(); final ScaleTransition scale = new ScaleTransition(Duration.millis(500), this.tourHighlight); scale.setToX(scaleToX); scale.setToY(scaleToY); final Bounds targetBounds = target == null ? new BoundingBox(0, 0, 0, 0) : target.localToScene(target.getLayoutBounds()); final TranslateTransition translateTransition = new TranslateTransition(Duration.millis(500), this.tourHighlight); translateTransition.setToX(targetBounds.getMinX() + scaleToX / 2 - 5); translateTransition.setToY(targetBounds.getMinY() + scaleToY / 2 - 5); final ParallelTransition parallelTransition = new ParallelTransition(scale, translateTransition); parallelTransition.setOnFinished(event -> this.updateTooltip(step, graphic)); PlatformHelper.run(() -> parallelTransition.play()); } /** * Update the tooltip with the given {@code step} and {@code graphic}. If the {@code step} is null or it's text, the * text of the tooltip is set to {@code null}. If the {@code graphic} is null, then the graphic of the tooltip will also be null. * @param step The step to display the tooltip for. May be null. * @param graphic The graphic that is set to the tooltip. May be null */ private synchronized void updateTooltip(final Step step, final Node graphic) { if(step == null || step.getTooltip() == null) this.tourTooltip.setText(null); else this.tourTooltip.setText(step.getTooltip()); this.tourTooltip.setGraphic(graphic); this.tourTooltip.show(this.scene.getWindow()); final double anchorX = this.scene.getWindow().getX() + 5 + this.initialParent.getLayoutBounds().getWidth() / 2 - this.tourTooltip.getWidth() / 2; final double anchorY = this.scene.getWindow().getY() + 5 + this.initialParent.getLayoutBounds().getHeight() / 2 - this.tourTooltip.getHeight() / 2; this.tourTooltip.setAnchorX(anchorX); this.tourTooltip.setAnchorY(anchorY); } /** * Create the node that contains the instructions to use the tour. * @return The Node containing all the instructions necessary to use the tour. */ private Node getInstructionsNode() { final ImageView escKey = new ImageView(ResourceHelper.getExternalForm("/com/twasyl/slideshowfx/images/esc_key.png")); final ImageView leftKey = new ImageView(ResourceHelper.getExternalForm("/com/twasyl/slideshowfx/images/left_key.png")); final ImageView rightKey = new ImageView(ResourceHelper.getExternalForm("/com/twasyl/slideshowfx/images/right_key.png")); final Label escLabel = new Label("Exit the tour"); escLabel.setLabelFor(escKey); escLabel.setStyle("-fx-text-fill: white;"); final Label leftLabel = new Label("Previous step of the tour"); leftLabel.setLabelFor(leftKey); leftLabel.setStyle("-fx-text-fill: white;"); final Label rightLabel = new Label("Next step of the tour"); rightLabel.setLabelFor(rightKey); rightLabel.setStyle("-fx-text-fill: white;"); final GridPane instructionsPane = new GridPane(); instructionsPane.addColumn(0, escKey, leftKey, rightKey); instructionsPane.addColumn(1, escLabel, leftLabel, rightLabel); return instructionsPane; } /** * Create the Node that displays the reload screen. * @return The Node for displaying the reload screen. */ private Node getReloadNode() { final FontAwesomeIconView reloadIcon = new FontAwesomeIconView(FontAwesomeIcon.REFRESH); reloadIcon.setGlyphSize(48); reloadIcon.setGlyphStyle("-fx-fill: white"); final Button reloadButton = new Button(); reloadButton.setBackground(Background.EMPTY); reloadButton.setGraphic(reloadIcon); reloadButton.setOnAction(event -> reload()); return reloadButton; } }