org.controlsfx.control.Notifications Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of controlsfx Show documentation
Show all versions of controlsfx Show documentation
High quality UI controls and other tools to complement the core JavaFX distribution
/**
* Copyright (c) 2014, 2019, ControlsFX
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* * Neither the name of ControlsFX, any associated website, nor the
* names of its contributors may be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL CONTROLSFX BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.controlsfx.control;
import impl.org.controlsfx.skin.NotificationBar;
import java.lang.ref.WeakReference;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.ParallelTransition;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.image.ImageView;
import javafx.stage.Popup;
import javafx.stage.PopupWindow;
import javafx.stage.Screen;
import javafx.stage.Window;
import javafx.util.Duration;
import org.controlsfx.control.action.Action;
import org.controlsfx.tools.Utils;
import static impl.org.controlsfx.i18n.Localization.getString;
/**
* An API to show popup notification messages to the user in the corner of their
* screen, unlike the {@link NotificationPane} which shows notification messages
* within your application itself.
*
* Screenshot
*
* The following screenshot shows a sample notification rising from the
* bottom-right corner of my screen:
*
*
*
*
*
*
Code Example:
*
* To create the notification shown in the screenshot, simply do the following:
*
*
* {@code
* Notifications.create()
* .title("Title Text")
* .text("Hello World 0!")
* .showWarning();
* }
*
*
* When there are too many notifications on the screen, one can opt to collapse
* the notifications into a single notification using {@link Notifications#threshold(int, Notifications)}.
*
* {@code
* Notifications.create()
* .title("Title Text")
* .text("Hello World 0!")
* .threshold(3, Notifications.create().title("Collapsed Notification"))
* .showWarning();
* }
*
*
*/
public class Notifications {
/***************************************************************************
* * Static fields * *
**************************************************************************/
private static final String STYLE_CLASS_DARK = "dark"; //$NON-NLS-1$
/***************************************************************************
* * Private fields * *
**************************************************************************/
private String title;
private String text;
private Node graphic;
private ObservableList actions = FXCollections.observableArrayList();
private Pos position = Pos.BOTTOM_RIGHT;
private Duration hideAfterDuration = Duration.seconds(5);
private boolean hideCloseButton;
private EventHandler onAction;
private Window owner;
private Screen screen = null;
private List styleClass = new ArrayList<>();
private int threshold;
private Notifications thresholdNotification;
/***************************************************************************
* * Constructors * *
**************************************************************************/
// we do not allow instantiation of the Notifications class directly - users
// must go via the builder API (that is, calling create())
private Notifications() {
// no-op
}
/***************************************************************************
* * Public API * *
**************************************************************************/
/**
* Call this to begin the process of building a notification to show.
*/
public static Notifications create() {
return new Notifications();
}
/**
* Specify the text to show in the notification.
*/
public Notifications text(String text) {
this.text = text;
return this;
}
/**
* Specify the title to show in the notification.
*/
public Notifications title(String title) {
this.title = title;
return this;
}
/**
* Specify the graphic to show in the notification.
*/
public Notifications graphic(Node graphic) {
this.graphic = graphic;
return this;
}
/**
* Specify the position of the notification on screen, by default it is
* {@link Pos#BOTTOM_RIGHT bottom-right}.
*/
public Notifications position(Pos position) {
this.position = position;
return this;
}
/**
* The dialog window owner - which can be {@link Screen}, {@link Window}
* or {@link Node}. If specified, the notifications will be inside
* the owner, otherwise the notifications will be shown within the whole
* primary (default) screen.
*/
public Notifications owner(Object owner) {
if (owner instanceof Screen) {
this.screen = (Screen) owner;
} else {
this.owner = Utils.getWindow(owner);
}
return this;
}
/**
* Specify the duration that the notification should show, after which it
* will be hidden.
*/
public Notifications hideAfter(Duration duration) {
this.hideAfterDuration = duration;
return this;
}
/**
* Specify what to do when the user clicks on the notification (in addition
* to the notification hiding, which happens whenever the notification is
* clicked on).
*/
public Notifications onAction(EventHandler onAction) {
this.onAction = onAction;
return this;
}
/**
* Specify that the notification should use the built-in dark styling,
* rather than the default 'modena' notification style (which is a
* light-gray).
*/
public Notifications darkStyle() {
styleClass.add(STYLE_CLASS_DARK);
return this;
}
/**
* Specify that the close button in the top-right corner of the notification
* should not be shown.
*/
public Notifications hideCloseButton() {
this.hideCloseButton = true;
return this;
}
/**
* Specify the actions that should be shown in the notification as buttons.
*/
public Notifications action(Action... actions) {
this.actions = actions == null ? FXCollections. observableArrayList() : FXCollections
.observableArrayList(actions);
return this;
}
/**
* Collapses all the current notifications into a single notification when the
* number of notifications exceed the threshold limit. A value of zero will disable
* the threshold behavior.
*
* @param threshold The number of notifications to show before they can be collapsed
* into a single notification.
* @param thresholdNotification The {@link Notifications notification} to show when
* threshold is reached.
*/
public Notifications threshold(int threshold, Notifications thresholdNotification) {
this.threshold = threshold;
this.thresholdNotification = thresholdNotification;
return this;
}
/**
* Instructs the notification to be shown, and that it should use the
* built-in 'warning' graphic.
*/
public void showWarning() {
graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-warning.png").toExternalForm())); //$NON-NLS-1$
show();
}
/**
* Instructs the notification to be shown, and that it should use the
* built-in 'information' graphic.
*/
public void showInformation() {
graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-information.png").toExternalForm())); //$NON-NLS-1$
show();
}
/**
* Instructs the notification to be shown, and that it should use the
* built-in 'error' graphic.
*/
public void showError() {
graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-error.png").toExternalForm())); //$NON-NLS-1$
show();
}
/**
* Instructs the notification to be shown, and that it should use the
* built-in 'confirm' graphic.
*/
public void showConfirm() {
graphic(new ImageView(Notifications.class.getResource("/org/controlsfx/dialog/dialog-confirm.png").toExternalForm())); //$NON-NLS-1$
show();
}
/**
* Instructs the notification to be shown.
*/
public void show() {
NotificationPopupHandler.getInstance().show(this);
}
/***************************************************************************
* * Private support classes * *
**************************************************************************/
// not public so no need for JavaDoc
private static final class NotificationPopupHandler {
private static final NotificationPopupHandler INSTANCE = new NotificationPopupHandler();
private static final String FINAL_ANCHOR_Y = "finalAnchorY";
private double startX;
private double startY;
private double screenWidth;
private double screenHeight;
static final NotificationPopupHandler getInstance() {
return INSTANCE;
}
private final Map> popupsMap = new HashMap<>();
private static final double PADDING = 15;
private static final double SPACING = 15;
// for animating in the notifications
private ParallelTransition parallelTransition = new ParallelTransition();
private boolean isShowing = false;
public void show(Notifications notification) {
Window window;
if (notification.owner == null) {
/*
* If the owner is not set, we work with the whole screen.
*/
window = Utils.getWindow(null);
Screen screen = notification.screen != null
? notification.screen
: getScreenBounds(window).orElse(Screen.getPrimary());
Rectangle2D screenBounds = screen.getBounds();
startX = screenBounds.getMinX();
startY = screenBounds.getMinY();
screenWidth = screenBounds.getWidth();
screenHeight = screenBounds.getHeight();
} else {
/*
* If the owner is set, we will make the notifications popup
* inside its window.
*/
startX = notification.owner.getX();
startY = notification.owner.getY();
screenWidth = notification.owner.getWidth();
screenHeight = notification.owner.getHeight();
window = notification.owner;
}
show(window, notification);
}
private Optional getScreenBounds(Window window) {
if (window == null) {
return Optional.empty();
}
final ObservableList screensForRectangle = Screen.getScreensForRectangle(window.getX(),
window.getY(),
window.getWidth(),
window.getHeight());
return screensForRectangle.stream()
.filter(Objects::nonNull)
.findFirst();
}
private void show(Window owner, final Notifications notification) {
// Stylesheets which are added to the scene of a popup aren't
// considered for styling. For this reason, we need to find the next
// window in the hierarchy which isn't a popup.
Window ownerWindow = owner;
while (ownerWindow instanceof PopupWindow) {
ownerWindow = ((PopupWindow) ownerWindow).getOwnerWindow();
}
// need to install our CSS
Scene ownerScene = ownerWindow == null ? null : ownerWindow.getScene();
if (ownerScene != null) {
String stylesheetUrl = Notifications.class.getResource("notificationpopup.css").toExternalForm(); //$NON-NLS-1$
if (!ownerScene.getStylesheets().contains(stylesheetUrl)) {
// The stylesheet needs to be added at the beginning so that
// the styling can be adjusted with custom stylesheets.
ownerScene.getStylesheets().add(0, stylesheetUrl);
}
}
final Popup popup = new Popup();
popup.setAutoFix(false);
final Pos p = notification.position;
final Notifications notificationToShow;
final List popups = popupsMap.get(p);
if (notification.threshold > 0 && popups != null && popups.size() >= notification.threshold) {
for (Popup popupElement : popups) {
popupElement.hide();
}
final Notifications thresholdNotification = notification.thresholdNotification;
if (thresholdNotification.text == null || thresholdNotification.text.isEmpty()) {
thresholdNotification.text = MessageFormat.format(getString("notifications.threshold.text"), popups.size());
}
notificationToShow = thresholdNotification;
} else {
notificationToShow = notification;
}
final NotificationBar notificationBar = new NotificationBar() {
@Override
public String getTitle() {
return notificationToShow.title;
}
@Override
public String getText() {
return notificationToShow.text;
}
@Override
public Node getGraphic() {
return notificationToShow.graphic;
}
@Override
public ObservableList getActions() {
return notificationToShow.actions;
}
@Override
public boolean isShowing() {
return isShowing;
}
@Override
protected double computeMinWidth(double height) {
String text = getText();
Node graphic = getGraphic();
if ((text == null || text.isEmpty()) && (graphic != null)) {
return graphic.minWidth(height);
}
return 400;
}
@Override
protected double computeMinHeight(double width) {
String text = getText();
Node graphic = getGraphic();
if ((text == null || text.isEmpty()) && (graphic != null)) {
return graphic.minHeight(width);
}
return 100;
}
@Override
public boolean isShowFromTop() {
return NotificationPopupHandler.this.isShowFromTop(notificationToShow.position);
}
@Override
public void hide() {
isShowing = false;
// this would slide the notification bar out of view,
// but I prefer the fade out below
// doHide();
// animate out the popup by fading it
createHideTimeline(popup, this, p, Duration.ZERO).play();
}
@Override
public boolean isCloseButtonVisible() {
return !notificationToShow.hideCloseButton;
}
@Override
public double getContainerHeight() {
return startY + screenHeight;
}
@Override
public void relocateInParent(double x, double y) {
// this allows for us to slide the notification upwards
switch (p) {
case BOTTOM_LEFT:
case BOTTOM_CENTER:
case BOTTOM_RIGHT:
popup.setAnchorY(y - PADDING);
break;
default:
// no-op
break;
}
}
};
notificationBar.getStyleClass().addAll(notificationToShow.styleClass);
notificationBar.setOnMouseClicked(e -> {
if (notificationToShow.onAction != null) {
ActionEvent actionEvent = new ActionEvent(notificationBar, notificationBar);
notificationToShow.onAction.handle(actionEvent);
// animate out the popup
createHideTimeline(popup, notificationBar, p, Duration.ZERO).play();
}
});
popup.getContent().add(notificationBar);
popup.show(owner, 0, 0);
// determine location for the popup
double anchorX = 0, anchorY = 0;
final double barWidth = notificationBar.getWidth();
final double barHeight = notificationBar.getHeight();
// get anchorX
switch (p) {
case TOP_LEFT:
case CENTER_LEFT:
case BOTTOM_LEFT:
anchorX = PADDING + startX;
break;
case TOP_CENTER:
case CENTER:
case BOTTOM_CENTER:
anchorX = startX + (screenWidth / 2.0) - barWidth / 2.0 - PADDING / 2.0;
break;
default:
case TOP_RIGHT:
case CENTER_RIGHT:
case BOTTOM_RIGHT:
anchorX = startX + screenWidth - barWidth - PADDING;
break;
}
// get anchorY
switch (p) {
case TOP_LEFT:
case TOP_CENTER:
case TOP_RIGHT:
anchorY = PADDING + startY;
break;
case CENTER_LEFT:
case CENTER:
case CENTER_RIGHT:
anchorY = startY + (screenHeight / 2.0) - barHeight / 2.0 - PADDING / 2.0;
break;
default:
case BOTTOM_LEFT:
case BOTTOM_CENTER:
case BOTTOM_RIGHT:
anchorY = startY + screenHeight - barHeight - PADDING;
break;
}
popup.setAnchorX(anchorX);
setFinalAnchorY(popup, anchorY);
popup.setAnchorY(anchorY);
isShowing = true;
notificationBar.doShow();
addPopupToMap(p, popup);
// begin a timeline to get rid of the popup
Timeline timeline = createHideTimeline(popup, notificationBar, p, notification.hideAfterDuration);
timeline.play();
}
private void hide(Popup popup, Pos p) {
popup.hide();
removePopupFromMap(p, popup);
}
private Timeline createHideTimeline(final Popup popup, NotificationBar bar, final Pos p, Duration startDelay) {
KeyValue fadeOutBegin = new KeyValue(bar.opacityProperty(), 1.0);
KeyValue fadeOutEnd = new KeyValue(bar.opacityProperty(), 0.0);
KeyFrame kfBegin = new KeyFrame(Duration.ZERO, fadeOutBegin);
KeyFrame kfEnd = new KeyFrame(Duration.millis(500), fadeOutEnd);
Timeline timeline = new Timeline(kfBegin, kfEnd);
timeline.setDelay(startDelay);
timeline.setOnFinished(e -> hide(popup, p));
return timeline;
}
private void addPopupToMap(Pos p, Popup popup) {
List popups;
if (!popupsMap.containsKey(p)) {
popups = new LinkedList<>();
popupsMap.put(p, popups);
} else {
popups = popupsMap.get(p);
}
doAnimation(p, popup);
// add the popup to the list so it is kept in memory and can be
// accessed later on
popups.add(popup);
}
private void removePopupFromMap(Pos p, Popup popup) {
if (popupsMap.containsKey(p)) {
List popups = popupsMap.get(p);
popups.remove(popup);
}
}
private void doAnimation(Pos p, Popup changedPopup) {
List popups = popupsMap.get(p);
if (popups == null) {
return;
}
parallelTransition.stop();
parallelTransition.getChildren().clear();
final boolean isShowFromTop = isShowFromTop(p);
// animate all other popups in the list upwards so that the new one
// is in the 'new' area.
// firstly, we need to determine the target positions for all popups
double sum = 0;
double targetAnchors[] = new double[popups.size()];
for (int i = popups.size() - 1; i >= 0; i--) {
Popup _popup = popups.get(i);
final NotificationBar notificationBar = (NotificationBar) _popup.getContent().get(0);
final double popupHeight = notificationBar.minHeight(notificationBar.getWidth());
if (isShowFromTop) {
if (i == popups.size() - 1) {
sum = getFinalAnchorY(changedPopup) + popupHeight + SPACING;
} else {
sum += popupHeight + SPACING;
}
targetAnchors[i] = sum;
_popup.setAnchorY(sum-popupHeight);
} else {
if (i == popups.size() - 1) {
sum = getFinalAnchorY(changedPopup) - (popupHeight + SPACING);
} else {
sum -= (popupHeight + SPACING);
}
targetAnchors[i] = sum;
_popup.setAnchorY(sum+popupHeight);
}
}
// then we set up animations for each popup to animate towards the
// target
for (int i = popups.size() - 1; i >= 0; i--) {
final Popup _popup = popups.get(i);
_popup.setAnchorX(changedPopup.getAnchorX());
final double anchorYTarget = targetAnchors[i];
if (anchorYTarget < 0) {
_popup.hide();
}
final double oldAnchorY = getFinalAnchorY(_popup);
final double distance = anchorYTarget - oldAnchorY;
setFinalAnchorY(_popup, oldAnchorY + distance);
Transition t = new CustomTransition(_popup, oldAnchorY, distance);
t.setCycleCount(1);
parallelTransition.getChildren().add(t);
}
parallelTransition.play();
}
private double getFinalAnchorY(Popup popup) {
return (double) popup.getProperties().get(FINAL_ANCHOR_Y);
}
private void setFinalAnchorY(Popup popup, double anchorY) {
popup.getProperties().put(FINAL_ANCHOR_Y, anchorY);
}
private boolean isShowFromTop(Pos p) {
switch (p) {
case TOP_LEFT:
case TOP_CENTER:
case TOP_RIGHT:
return true;
default:
return false;
}
}
class CustomTransition extends Transition {
private WeakReference popupWeakReference;
private double oldAnchorY;
private double distance;
CustomTransition(Popup popup, double oldAnchorY, double distance) {
popupWeakReference = new WeakReference<>(popup);
this.oldAnchorY = oldAnchorY;
this.distance = distance;
setCycleDuration(Duration.millis(350.0));
}
@Override
protected void interpolate(double frac) {
Popup popup = popupWeakReference.get();
if (popup != null) {
double newAnchorY = oldAnchorY + distance * frac;
popup.setAnchorY(newAnchorY);
}
}
}
}
}