com.jfoenix.controls.JFXSnackbar Maven / Gradle / Ivy
/*
* 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 com.jfoenix.controls.JFXButton.ButtonType;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* @see Snackbars & toasts
*
* The use of a javafx Popup or PopupContainer for notifications would seem intuitive but Popups are displayed in their
* own dedicated windows and alligning the popup window and handling window on top layering is more trouble then it is
* worth.
*/
public class JFXSnackbar extends StackPane {
private Label toast;
private JFXButton action;
private Pane snackbarContainer;
private Group popup;
private ChangeListener super Number> sizeListener;
private AtomicBoolean processingQueue = new AtomicBoolean(false);
private ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>();
private StackPane actionContainer;
Interpolator easeInterpolator = Interpolator.SPLINE(0.250, 0.100, 0.250, 1.000);
public JFXSnackbar() {
this(null);
}
public JFXSnackbar(Pane snackbarContainer) {
BorderPane bPane = new BorderPane();
toast = new Label();
toast.setMinWidth(Control.USE_PREF_SIZE);
toast.getStyleClass().add("jfx-snackbar-toast");
toast.setWrapText(true);
StackPane toastContainer = new StackPane(toast);
toastContainer.setPadding(new Insets(20));
bPane.setLeft(toastContainer);
action = new JFXButton();
action.setMinWidth(Control.USE_PREF_SIZE);
action.setButtonType(ButtonType.FLAT);
action.getStyleClass().add("jfx-snackbar-action");
// actions will be added upon showing the snackbar if needed
actionContainer = new StackPane(action);
actionContainer.setPadding(new Insets(0, 10, 0, 0));
bPane.setRight(actionContainer);
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
if (this.getPrefWidth() == -1) {
return this.getPrefWidth();
}
double actionWidth = actionContainer.isVisible() ? actionContainer.getWidth() : 0.0;
return this.prefWidthProperty().get() - actionWidth;
}, this.prefWidthProperty(), actionContainer.widthProperty(), actionContainer.visibleProperty()));
//bind the content's height and width from this snackbar allowing the content's dimensions to be set externally
bPane.prefWidthProperty().bind(this.prefWidthProperty());
final BorderPane content = bPane;
content.getStyleClass().add("jfx-snackbar-content");
//setting a shadow enlarges the snackbar height leaving a gap below it
//JFXDepthManager.setDepth(content, 4);
//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
popup = new Group();
popup.getChildren().add(content);
popup.setManaged(false);
popup.setVisible(false);
popup.idProperty().bind(this.idProperty());
this.getStyleClass().addListener((ListChangeListener) c -> {
while (c.next()) {
if (c.wasAdded()) {
popup.getStyleClass().addAll(c.getAddedSubList());
}
if (c.wasRemoved()) {
popup.getStyleClass().removeAll(c.getRemoved());
}
}
});
sizeListener = (o, oldVal, newVal) -> {
refreshPopup();
};
// register the container before resizing it
registerSnackbarContainer(snackbarContainer);
// resize the popup if its layout has been changed
popup.layoutBoundsProperty().addListener((o, oldVal, newVal) -> refreshPopup());
addEventHandler(SnackbarEvent.SNACKBAR, e -> enqueue(e));
//This control actually orchestrates the popup logic and is never visibly displayed.
this.setVisible(false);
this.setManaged(false);
}
/***************************************************************************
* * Setters / Getters * *
**************************************************************************/
public Pane getPopupContainer() {
return snackbarContainer;
}
/***************************************************************************
* * 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(popup);
this.snackbarContainer.heightProperty().addListener(sizeListener);
this.snackbarContainer.widthProperty().addListener(sizeListener);
}
}
public void unregisterSnackbarContainer(Pane snackbarContainer) {
if (snackbarContainer != null) {
if (this.snackbarContainer == null) {
throw new IllegalArgumentException("Snackbar Container not set");
}
this.snackbarContainer.getChildren().remove(popup);
this.snackbarContainer.heightProperty().removeListener(sizeListener);
this.snackbarContainer.widthProperty().removeListener(sizeListener);
this.snackbarContainer = null;
}
}
public void show(String toastMessage, long timeout) {
this.show(toastMessage, null, timeout, null);
}
public void show(String message, String actionText, EventHandler super MouseEvent> actionHandler) {
this.show(message, actionText, -1, actionHandler);
}
public void show(String message, String actionText, long timeout, EventHandler super MouseEvent> actionHandler) {
toast.setText(message);
if (actionText != null && !actionText.isEmpty()) {
action.setVisible(true);
actionContainer.setVisible(true);
actionContainer.setManaged(true);
// to force updating the layout bounds
action.setText("");
action.setText(actionText);
action.setOnMouseClicked(actionHandler);
} else {
actionContainer.setVisible(false);
actionContainer.setManaged(false);
action.setVisible(false);
}
Timeline animation = getTimeline(timeout);
animation.play();
}
private Timeline getTimeline(long timeout) {
Timeline animation;
if (timeout <= 0) {
animation = new Timeline(
new KeyFrame(
Duration.ZERO,
e -> popup.toBack(),
new KeyValue(popup.visibleProperty(), false, Interpolator.EASE_BOTH),
new KeyValue(popup.translateYProperty(), popup.getLayoutBounds().getHeight(), easeInterpolator),
new KeyValue(popup.opacityProperty(), 0, easeInterpolator)
),
new KeyFrame(
Duration.millis(10),
e -> popup.toFront(),
new KeyValue(popup.visibleProperty(), true, Interpolator.EASE_BOTH)
),
new KeyFrame(Duration.millis(300),
new KeyValue(popup.opacityProperty(), 1, easeInterpolator),
new KeyValue(popup.translateYProperty(), 0, easeInterpolator)
)
);
animation.setCycleCount(1);
} else {
animation = new Timeline(
new KeyFrame(
Duration.ZERO,
(e) -> popup.toBack(),
new KeyValue(popup.visibleProperty(), false, Interpolator.EASE_BOTH),
new KeyValue(popup.translateYProperty(), popup.getLayoutBounds().getHeight(), easeInterpolator),
new KeyValue(popup.opacityProperty(), 0, easeInterpolator)
),
new KeyFrame(
Duration.millis(10),
(e) -> popup.toFront(),
new KeyValue(popup.visibleProperty(), true, Interpolator.EASE_BOTH)
),
new KeyFrame(Duration.millis(300),
new KeyValue(popup.opacityProperty(), 1, easeInterpolator),
new KeyValue(popup.translateYProperty(), 0, easeInterpolator)
),
new KeyFrame(Duration.millis(timeout / 2))
);
animation.setAutoReverse(true);
animation.setCycleCount(2);
animation.setOnFinished((e) -> processSnackbars());
}
return animation;
}
public void close() {
Timeline animation = new Timeline(
new KeyFrame(
Duration.ZERO,
e -> popup.toFront(),
new KeyValue(popup.opacityProperty(), 1, easeInterpolator),
new KeyValue(popup.translateYProperty(), 0, easeInterpolator)
),
new KeyFrame(
Duration.millis(290),
new KeyValue(popup.visibleProperty(), true, Interpolator.EASE_BOTH)
),
new KeyFrame(Duration.millis(300),
e -> popup.toBack(),
new KeyValue(popup.visibleProperty(), false, Interpolator.EASE_BOTH),
new KeyValue(popup.translateYProperty(),
popup.getLayoutBounds().getHeight(),
easeInterpolator),
new KeyValue(popup.opacityProperty(), 0, easeInterpolator)
)
);
animation.setCycleCount(1);
animation.setOnFinished(e -> processSnackbars());
animation.play();
}
private void processSnackbars() {
SnackbarEvent qevent = eventQueue.poll();
if (qevent != null) {
if (qevent.isPersistent()) {
show(qevent.getMessage(), qevent.getActionText(), qevent.getActionHandler());
} else {
show(qevent.getMessage(), qevent.getActionText(), qevent.getTimeout(), qevent.getActionHandler());
}
} 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);
}
}
public void refreshPopup() {
Bounds contentBound = popup.getLayoutBounds();
double offsetX = Math.ceil(snackbarContainer.getWidth() / 2) - Math.ceil(contentBound.getWidth() / 2);
double offsetY = snackbarContainer.getHeight() - contentBound.getHeight();
popup.setLayoutX(offsetX);
popup.setLayoutY(offsetY);
}
public void enqueue(SnackbarEvent event) {
eventQueue.add(event);
if (processingQueue.compareAndSet(false, true)) {
Platform.runLater(() -> {
SnackbarEvent qevent = eventQueue.poll();
if (qevent != null) {
if (qevent.isPersistent()) {
show(qevent.getMessage(), qevent.getActionText(), qevent.getActionHandler());
} else {
show(qevent.getMessage(),
qevent.getActionText(),
qevent.getTimeout(),
qevent.getActionHandler());
}
}
});
}
}
/***************************************************************************
* * Event API * *
**************************************************************************/
public static class SnackbarEvent extends Event {
public static final EventType SNACKBAR = new EventType<>(Event.ANY, "SNACKBAR");
private final String message;
private final String actionText;
private final long timeout;
private final boolean persistent;
private final EventHandler super MouseEvent> actionHandler;
public SnackbarEvent(String message) {
this(message, null, 3000, false, null);
}
public SnackbarEvent(String message, String actionText, long timeout, boolean persistent, EventHandler super MouseEvent> actionHandler) {
super(SNACKBAR);
this.message = message;
this.actionText = actionText;
this.timeout = timeout < 1 ? 3000 : timeout;
this.actionHandler = actionHandler;
this.persistent = persistent;
}
public String getMessage() {
return message;
}
public String getActionText() {
return actionText;
}
public long getTimeout() {
return timeout;
}
public EventHandler super MouseEvent> getActionHandler() {
return actionHandler;
}
@Override
public EventType extends SnackbarEvent> getEventType() {
return (EventType extends SnackbarEvent>) super.getEventType();
}
public boolean isPersistent() {
return persistent;
}
}
}