From 52d8c44320cb037ba4fdd0ac729f5ee9117600df Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 7 Feb 2026 00:01:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20JFXSnackbar=20(#5451)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/jfoenix/controls/JFXSnackbar.java | 421 ++++++++++++++++++ .../jfoenix/controls/JFXSnackbarLayout.java | 110 +++++ .../ui/decorator/DecoratorController.java | 3 +- HMCL/src/main/resources/assets/css/root.css | 8 + 4 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXSnackbar.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXSnackbarLayout.java diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXSnackbar.java b/HMCL/src/main/java/com/jfoenix/controls/JFXSnackbar.java new file mode 100644 index 000000000..146be34d9 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXSnackbar.java @@ -0,0 +1,421 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.PauseTransition; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.WeakChangeListener; +import javafx.css.PseudoClass; +import javafx.event.Event; +import javafx.event.EventType; +import javafx.geometry.Bounds; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +import javafx.util.Duration; +import org.jackhuang.hmcl.ui.animation.Motion; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +/// "Snackbars provide brief messages about app processes at the bottom of the screen" +/// (Material Design Guidelines). +/// +/// To show a snackbar you need to +///
    +/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in +/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method. +/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your +/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended. +/// +/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty, +/// but any arbitrary [Node] will do. +/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the +/// duration. +///
+/// +/// Finally, with all those things prepared, show your snackbar using +/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)]. +/// +/// It's most convenient to create functions to do most of this (creating the layout and event) with the default +/// settings; that way all you need to do to show a snackbar is specify the message or just the message and the duration. +/// +/// @see The Material Design Snackbar +public class JFXSnackbar extends Group { + + private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar"; + + private Pane snackbarContainer; + private final ChangeListener sizeListener = (o, oldVal, newVal) -> refreshPopup(); + private final WeakChangeListener weakSizeListener = new WeakChangeListener<>(sizeListener); + + private final AtomicBoolean processingQueue = new AtomicBoolean(false); + private final ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>(); + private final ConcurrentHashMap.KeySetView eventsSet = ConcurrentHashMap.newKeySet(); + + private final Pane content; + private PseudoClass activePseudoClass = null; + private PauseTransition pauseTransition; + + /// This constructor assumes that you will eventually call the [#registerSnackbarContainer(Pane)] method before + /// calling the [#enqueue(SnackbarEvent)] method. Otherwise, how will the snackbar know where to show itself? + /// + /// + /// "Snackbars provide brief messages about app processes at the bottom of the screen" + /// (Material Design Guidelines). + /// + /// To show a snackbar you need to + /// + /// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in + /// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method. + /// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your + /// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended. + /// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty, + /// but any arbitrary [Node] will do. + /// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the + /// duration. + /// + /// Finally, with all those things prepared, show your snackbar using + /// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)]. + /// + public JFXSnackbar() { + this(null); + } + + /// "Snackbars provide brief messages about app processes at the bottom of the screen" + /// (Material Design Guidelines). + /// + /// To show a snackbar you need to + /// + /// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in + /// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method. + /// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your + /// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended. + /// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty, + /// but any arbitrary [Node] will do. + /// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the + /// duration. + /// + /// Finally, with all those things prepared, show your snackbar using + /// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)]. + /// + /// @param snackbarContainer where the snackbar will appear. Using a single snackbar instead of many, will ensure that + /// the [#enqueue(SnackbarEvent)] method works correctly. + public JFXSnackbar(Pane snackbarContainer) { + initialize(); + content = new StackPane(); + content.getStyleClass().add("jfx-snackbar-content"); + //wrap the content in a group so that the content is managed inside its own container + //but the group is not managed in the snackbarContainer so it does not affect any layout calculations + getChildren().add(content); + setManaged(false); + setVisible(false); + + // register the container before resizing it + registerSnackbarContainer(snackbarContainer); + + // resize the popup if its layout has been changed + layoutBoundsProperty().addListener((o, oldVal, newVal) -> refreshPopup()); + + addEventHandler(SnackbarEvent.SNACKBAR, this::enqueue); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + } + + // Setters / Getters + + public Pane getPopupContainer() { + return snackbarContainer; + } + + public void setPrefWidth(double width) { + content.setPrefWidth(width); + } + + public double getPrefWidth() { + return content.getPrefWidth(); + } + + // Public API + + public void registerSnackbarContainer(Pane snackbarContainer) { + if (snackbarContainer != null) { + if (this.snackbarContainer != null) { + //since listeners are added the container should be properly registered/unregistered + throw new IllegalArgumentException("Snackbar Container already set"); + } + this.snackbarContainer = snackbarContainer; + this.snackbarContainer.getChildren().add(this); + this.snackbarContainer.heightProperty().addListener(weakSizeListener); + this.snackbarContainer.widthProperty().addListener(weakSizeListener); + } + } + + public void unregisterSnackbarContainer(Pane snackbarContainer) { + if (snackbarContainer != null) { + if (this.snackbarContainer == null) { + throw new IllegalArgumentException("Snackbar Container not set"); + } + this.snackbarContainer.getChildren().remove(this); + this.snackbarContainer.heightProperty().removeListener(weakSizeListener); + this.snackbarContainer.widthProperty().removeListener(weakSizeListener); + this.snackbarContainer = null; + } + } + + private void show(SnackbarEvent event) { + content.getChildren().setAll(event.getContent()); + openAnimation = getTimeline(event.getTimeout()); + if (event.getPseudoClass() != null) { + activePseudoClass = event.getPseudoClass(); + content.pseudoClassStateChanged(activePseudoClass, true); + } + openAnimation.play(); + } + + private Timeline openAnimation = null; + + private Timeline getTimeline(Duration timeout) { + Timeline animation; + animation = new Timeline( + new KeyFrame( + Duration.ZERO, + e -> { + this.toBack(); + this.setVisible(false); + }, + new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE), + new KeyValue(this.opacityProperty(), 0, Motion.EASE) + ), + new KeyFrame( + Duration.millis(10), + e -> { + this.toFront(); + this.setVisible(true); + } + ), + new KeyFrame(Duration.millis(300), + new KeyValue(this.opacityProperty(), 1, Motion.EASE), + new KeyValue(this.translateYProperty(), 0, Motion.EASE) + ) + ); + animation.setCycleCount(1); + pauseTransition = Duration.INDEFINITE.equals(timeout) ? null : new PauseTransition(timeout); + if (pauseTransition != null) { + animation.setOnFinished(finish -> { + pauseTransition.setOnFinished(done -> { + pauseTransition = null; + eventsSet.remove(currentEvent); + currentEvent = eventQueue.peek(); + close(); + }); + pauseTransition.play(); + }); + } + return animation; + } + + public void close() { + if (openAnimation != null) { + openAnimation.stop(); + } + if (this.isVisible()) { + Timeline closeAnimation = new Timeline( + new KeyFrame( + Duration.ZERO, + e -> this.toFront(), + new KeyValue(this.opacityProperty(), 1, Motion.EASE), + new KeyValue(this.translateYProperty(), 0, Motion.EASE) + ), + new KeyFrame( + Duration.millis(290), + e -> this.setVisible(true) + ), + new KeyFrame(Duration.millis(300), + e -> { + this.toBack(); + this.setVisible(false); + }, + new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE), + new KeyValue(this.opacityProperty(), 0, Motion.EASE) + ) + ); + closeAnimation.setCycleCount(1); + closeAnimation.setOnFinished(e -> { + resetPseudoClass(); + processSnackbar(); + }); + closeAnimation.play(); + } + } + + private SnackbarEvent currentEvent = null; + + public SnackbarEvent getCurrentEvent() { + return currentEvent; + } + + /** + * Shows {@link SnackbarEvent SnackbarEvents} one by one. The next event will be shown after the current event's duration. + * + * @param event the {@link SnackbarEvent event} to put in the queue. + */ + public void enqueue(SnackbarEvent event) { + synchronized (this) { + if (!eventsSet.contains(event)) { + eventsSet.add(event); + eventQueue.offer(event); + } else if (currentEvent == event && pauseTransition != null) { + pauseTransition.playFromStart(); + } + } + if (processingQueue.compareAndSet(false, true)) { + Platform.runLater(() -> { + currentEvent = eventQueue.poll(); + if (currentEvent != null) { + show(currentEvent); + } + }); + } + } + + private void resetPseudoClass() { + if (activePseudoClass != null) { + content.pseudoClassStateChanged(activePseudoClass, false); + activePseudoClass = null; + } + } + + private void processSnackbar() { + currentEvent = eventQueue.poll(); + if (currentEvent != null) { + eventsSet.remove(currentEvent); + show(currentEvent); + } else { + //The enqueue method and this listener should be executed sequentially on the FX Thread so there + //should not be a race condition + processingQueue.getAndSet(false); + } + } + + private void refreshPopup() { + if (snackbarContainer == null) { + return; + } + Bounds contentBound = this.getLayoutBounds(); + double offsetX = Math.ceil(snackbarContainer.getWidth() / 2) - Math.ceil(contentBound.getWidth() / 2); + double offsetY = snackbarContainer.getHeight() - contentBound.getHeight(); + this.setLayoutX(offsetX); + this.setLayoutY(offsetY); + + } + + /////////////////////////////////////////////////////////////////////////// + // Event API + /////////////////////////////////////////////////////////////////////////// + + /// Specifies _what_ and _how long_ to show a [JFXSnackbar]. + /// + /// The _what_ can be any arbitrary [Node]; the [JFXSnackbarLayout] is a great choice. + /// + /// The _how long_ is specified in the form of a [javafx.util.Duration][javafx.util.Duration], not to be + /// confused with the [java.time.Duration]. + public static class SnackbarEvent extends Event { + + public static final EventType SNACKBAR = new EventType<>(Event.ANY, "SNACKBAR"); + + /// The amount of time the snackbar will show for, if not otherwise specified. + /// + /// It's 1.5 seconds. + public static Duration DEFAULT_DURATION = Duration.seconds(1.5); + + private final Node content; + private final PseudoClass pseudoClass; + private final Duration timeout; + + /// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION] and no pseudoClass. + /// + /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. + public SnackbarEvent(Node content) { + this(content, DEFAULT_DURATION, null); + } + + /// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION]; you specify the contents and + /// pseudoClass. + /// + /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. + public SnackbarEvent(Node content, PseudoClass pseudoClass) { + this(content, DEFAULT_DURATION, pseudoClass); + } + + /// Creates a SnackbarEvent with no pseudoClass; you specify the contents and duration. + /// pseudoClass. + /// + /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. + /// @param timeout the amount of time you want the snackbar to show for. + public SnackbarEvent(Node content, Duration timeout) { + this(content, timeout, null); + } + + /// Creates a SnackbarEvent; you specify the contents, duration and pseudoClass. + /// + /// If you don't need so much customization, try one of the other constructors. + /// + /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. + /// @param timeout the amount of time you want the snackbar to show for. + public SnackbarEvent(Node content, Duration timeout, PseudoClass pseudoClass) { + super(SNACKBAR); + this.content = content; + this.pseudoClass = pseudoClass; + this.timeout = timeout; + } + + public Node getContent() { + return content; + } + + public PseudoClass getPseudoClass() { + return pseudoClass; + } + + public Duration getTimeout() { + return timeout; + } + + @Override + @SuppressWarnings("unchecked") + public EventType getEventType() { + return (EventType) super.getEventType(); + } + + public boolean isPersistent() { + return Duration.INDEFINITE.equals(getTimeout()); + } + } +} + diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXSnackbarLayout.java b/HMCL/src/main/java/com/jfoenix/controls/JFXSnackbarLayout.java new file mode 100644 index 000000000..0c0dd50ab --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXSnackbarLayout.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import javafx.beans.binding.Bindings; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.StackPane; + +/// JFXSnackbarLayout default layout for snackbar content +/// +/// @author Shadi Shaheen +/// @version 1.0 +/// @since 2018-11-16 +public class JFXSnackbarLayout extends BorderPane { + + private final Label toast; + private JFXButton action; + private final StackPane actionContainer; + + public JFXSnackbarLayout(String message) { + this(message, null, null); + } + + public JFXSnackbarLayout(String message, String actionText, EventHandler actionHandler) { + initialize(); + + toast = new Label(); + toast.setMinWidth(Control.USE_PREF_SIZE); + toast.getStyleClass().add("jfx-snackbar-toast"); + toast.setWrapText(true); + toast.setText(message); + StackPane toastContainer = new StackPane(toast); + toastContainer.setPadding(new Insets(20)); + actionContainer = new StackPane(); + actionContainer.setPadding(new Insets(0, 10, 0, 0)); + + toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> { + if (getPrefWidth() == -1) { + return getPrefWidth(); + } + double actionWidth = actionContainer.isVisible() ? actionContainer.getWidth() : 0.0; + return prefWidthProperty().get() - actionWidth; + }, prefWidthProperty(), actionContainer.widthProperty(), actionContainer.visibleProperty())); + + setLeft(toastContainer); + setRight(actionContainer); + + if (actionText != null) { + action = new JFXButton(); + action.setText(actionText); + action.setOnAction(actionHandler); + action.setMinWidth(Control.USE_PREF_SIZE); + action.setButtonType(JFXButton.ButtonType.FLAT); + action.getStyleClass().add("jfx-snackbar-action"); + // actions will be added upon showing the snackbar if needed + actionContainer.getChildren().add(action); + + if (!actionText.isEmpty()) { + action.setVisible(true); + actionContainer.setVisible(true); + actionContainer.setManaged(true); + // to force updating the layout bounds + action.setText(""); + action.setText(actionText); + action.setOnAction(actionHandler); + } else { + actionContainer.setVisible(false); + actionContainer.setManaged(false); + action.setVisible(false); + } + } + } + + private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar-layout"; + + public String getToast() { + return toast.getText(); + } + + public void setToast(String toast) { + this.toast.setText(toast); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + } +} + diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java index 34ee72b71..cdf73ed29 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.decorator; import com.jfoenix.controls.JFXSnackbar; +import com.jfoenix.controls.JFXSnackbarLayout; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; @@ -454,7 +455,7 @@ public class DecoratorController { // ==== Toast ==== public void showToast(String content) { - decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(content, null, 2000L, false, null)); + decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(new JFXSnackbarLayout(content))); } // ==== Wizard ==== diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index ef10b5645..655dff4b6 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -587,8 +587,16 @@ -fx-pref-width: 150.0px; } +/******************************************************************************* + * * + * JFX Snack Bar * + * * + ******************************************************************************/ + .jfx-snackbar-content { -fx-background-color: -monet-inverse-surface; + -fx-padding: 5; + -fx-spacing: 5; } .jfx-snackbar-toast {