/*
* 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.converters.RipplerMaskTypeConverter;
import com.jfoenix.transitions.CachedAnimation;
import com.jfoenix.transitions.CachedTransition;
import com.sun.javafx.css.converters.BooleanConverter;
import com.sun.javafx.css.converters.PaintConverter;
import com.sun.javafx.css.converters.SizeConverter;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.DefaultProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.event.Event;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.CacheHint;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* JFXRippler is the material design implementation of a ripple effect.
* the ripple effect can be applied to any node in the scene. JFXRippler is
* a {@link StackPane} container that holds a specified node (control node) and a ripple generator.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
@DefaultProperty(value = "control")
public class JFXRippler extends StackPane {
public enum RipplerPos {
FRONT, BACK
}
public enum RipplerMask {
CIRCLE, RECT
}
protected RippleGenerator rippler;
protected Pane ripplerPane;
protected Node control;
private static final double RIPPLE_MAX_RADIUS = 300;
private boolean enabled = true;
private Interpolator rippleInterpolator = Interpolator.SPLINE(0.0825,
0.3025,
0.0875,
0.9975); //0.1, 0.54, 0.28, 0.95);
/**
* creates empty rippler node
*/
public JFXRippler() {
this(null, RipplerMask.RECT, RipplerPos.FRONT);
}
/**
* creates a rippler for the specified control
*
* @param control
*/
public JFXRippler(Node control) {
this(control, RipplerMask.RECT, RipplerPos.FRONT);
}
/**
* creates a rippler for the specified control
*
* @param control
* @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control)
*/
public JFXRippler(Node control, RipplerPos pos) {
this(control, RipplerMask.RECT, pos);
}
/**
* creates a rippler for the specified control and apply the specified mask to it
*
* @param control
* @param mask can be either rectangle/cricle
*/
public JFXRippler(Node control, RipplerMask mask) {
this(control, mask, RipplerPos.FRONT);
}
/**
* creates a rippler for the specified control, mask and position.
*
* @param control
* @param mask can be either rectangle/cricle
* @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control)
*/
public JFXRippler(Node control, RipplerMask mask, RipplerPos pos) {
initialize();
this.maskType.set(mask);
this.position.set(pos);
setControl(control);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setSnapToPixel(false);
}
/***************************************************************************
* *
* Setters / Getters *
* *
**************************************************************************/
public void setControl(Node control) {
if (control != null) {
this.control = control;
// create rippler panels
rippler = new RippleGenerator();
ripplerPane = new StackPane();
ripplerPane.getChildren().add(rippler);
// set the control postion and listen if it's changed
if (this.position.get() == RipplerPos.BACK) {
ripplerPane.getChildren().add(this.control);
} else {
this.getChildren().add(this.control);
}
this.position.addListener((o, oldVal, newVal) -> {
if (this.position.get() == RipplerPos.BACK) {
ripplerPane.getChildren().add(this.control);
} else {
this.getChildren().add(this.control);
}
});
this.getChildren().add(ripplerPane);
// add listeners
initListeners();
// if the control got resized the overlay rect must be rest
control.layoutBoundsProperty().addListener((o, oldVal, newVal) -> {
resetOverLay();
resetClip();
});
control.boundsInParentProperty().addListener((o, oldVal, newVal) -> {
resetOverLay();
resetClip();
});
}
}
public Node getControl() {
return this.control;
}
public void setEnabled(boolean enable) {
this.enabled = enable;
}
// methods that can be changed by extending the rippler class
/**
* generate the clipping mask
*
* @return the mask node
*/
protected Node getMask() {
double borderWidth = ripplerPane.getBorder() != null ? ripplerPane.getBorder().getInsets().getTop() : 0;
Bounds bounds = control.getBoundsInParent();
double width = control.getLayoutBounds().getWidth();
double height = control.getLayoutBounds().getHeight();
double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX());
double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY());
double diffMaxX = Math.abs(control.getBoundsInLocal().getMaxX() - control.getLayoutBounds().getMaxX());
double diffMaxY = Math.abs(control.getBoundsInLocal().getMaxY() - control.getLayoutBounds().getMaxY());
Node mask;
switch (getMaskType()) {
case RECT:
mask = new Rectangle(bounds.getMinX() + diffMinX,
bounds.getMinY() + diffMinY,
width - 0.1 - 2 * borderWidth,
height - 0.1 - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane
break;
case CIRCLE:
double radius = Math.min((width / 2) - 0.1 - 2 * borderWidth, (height / 2) - 0.1 - 2 * borderWidth);
mask = new Circle((bounds.getMinX() + diffMinX + bounds.getMaxX() - diffMaxX) / 2,
(bounds.getMinY() + diffMinY + bounds.getMaxY() - diffMaxY) / 2,
radius,
Color.BLUE);
break;
default:
mask = new Rectangle(bounds.getMinX() + diffMinX,
bounds.getMinY() + diffMinY,
width - 0.1 - 2 * borderWidth,
height - 0.1 - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane
break;
}
if (control instanceof Shape || (control instanceof Region && ((Region) control).getShape() != null)) {
mask = new StackPane();
((Region) mask).setShape((control instanceof Shape) ? (Shape) control : ((Region) control).getShape());
((Region) mask).setBackground(new Background(new BackgroundFill(Color.WHITE,
CornerRadii.EMPTY,
Insets.EMPTY)));
mask.resize(width, height);
mask.relocate(bounds.getMinX() + diffMinX, bounds.getMinY() + diffMinY);
}
return mask;
}
/**
* compute the ripple raddius
*
* @return the ripple radius size
*/
protected double computeRippleRadius() {
double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth();
double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight();
return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5;
}
/**
* init mouse listeners on the rippler node
*/
protected void initListeners() {
ripplerPane.setOnMousePressed((event) -> {
createRipple(event.getX(), event.getY());
if (this.position.get() == RipplerPos.FRONT) {
this.control.fireEvent(event);
}
});
ripplerPane.setOnMouseReleased((event) -> {
if (this.position.get() == RipplerPos.FRONT) {
this.control.fireEvent(event);
}
});
ripplerPane.setOnMouseClicked((event) -> {
if (this.position.get() == RipplerPos.FRONT) {
this.control.fireEvent(event);
}
});
}
/**
* creates Ripple effect
*/
protected void createRipple(double x, double y) {
rippler.setGeneratorCenterX(x);
rippler.setGeneratorCenterY(y);
rippler.createMouseRipple();
}
/**
* fire event to the rippler pane manually
*
* @param event
*/
public void fireEventProgrammatically(Event event) {
if (!event.isConsumed()) {
ripplerPane.fireEvent(event);
}
}
public void showOverlay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.outAnimation.stop();
}
rippler.createOverlay();
rippler.overlayRect.inAnimation.play();
}
public void hideOverlay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.inAnimation.stop();
}
if (rippler.overlayRect != null) {
rippler.overlayRect.outAnimation.play();
}
}
/**
* Generates ripples on the screen every 0.3 seconds or whenever
* the createRipple method is called. Ripples grow and fade out
* over 0.6 seconds
*/
final class RippleGenerator extends Group {
private double generatorCenterX = 0;
private double generatorCenterY = 0;
private OverLayRipple overlayRect;
private AtomicBoolean generating = new AtomicBoolean(false);
private boolean cacheRipplerClip = false;
private boolean resetClip = false;
RippleGenerator() {
// improve in performance, by preventing
// redrawing the parent when the ripple effect is triggered
this.setManaged(false);
}
void createMouseRipple() {
if (enabled) {
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.getAnimation().play();
// create fade out transition for the ripple
ripplerPane.setOnMouseReleased(e -> {
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
}
ripple.inAnimation.getAnimation().stop();
ripple.outAnimation = new CachedAnimation(new Timeline(new KeyFrame(Duration.millis(Math.min(
800,
(0.9 * 500) / ripple.getScaleX())), ripple.outKeyValues)), this);
ripple.outAnimation.getAnimation().setOnFinished((event) -> getChildren().remove(ripple));
ripple.outAnimation.getAnimation().play();
if (overlayRect != null) {
overlayRect.outAnimation.play();
}
}
});
}
}
}
Runnable createManualRipple() {
if (enabled) {
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.getAnimation().play();
return () -> {
// create fade out transition for the ripple
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
}
ripple.inAnimation.getAnimation().stop();
ripple.outAnimation = new CachedAnimation(new Timeline(new KeyFrame(Duration.millis(Math.min(
800,
(0.9 * 500) / ripple.getScaleX())), ripple.outKeyValues)), this);
ripple.outAnimation.getAnimation().setOnFinished((event) -> getChildren().remove(ripple));
ripple.outAnimation.getAnimation().play();
if (overlayRect != null) {
overlayRect.outAnimation.play();
}
}
};
}
}
return () -> {
};
}
void cacheRippleClip(boolean cached) {
cacheRipplerClip = cached;
}
void createOverlay() {
if (overlayRect == null) {
overlayRect = new OverLayRipple();
overlayRect.setClip(getMask());
getChildren().add(0, overlayRect);
overlayRect.setFill(new Color(((Color) ripplerFill.get()).getRed(),
((Color) ripplerFill.get()).getGreen(),
((Color) ripplerFill.get()).getBlue(),
0.2));
}
}
void setGeneratorCenterX(double generatorCenterX) {
this.generatorCenterX = generatorCenterX;
}
void setGeneratorCenterY(double generatorCenterY) {
this.generatorCenterY = generatorCenterY;
}
private final class OverLayRipple extends Rectangle {
// Overlay ripple animations
CachedTransition inAnimation = new CachedTransition(this,
new Timeline(new KeyFrame(Duration.millis(1300),
new KeyValue(opacityProperty(),
1,
Interpolator.EASE_IN)))) {{
setDelay(Duration.millis(0));
setCycleDuration(Duration.millis(300));
}};
CachedTransition outAnimation = new CachedTransition(this,
new Timeline(new KeyFrame(Duration.millis(1300),
new KeyValue(opacityProperty(),
0,
Interpolator.EASE_OUT)))) {{
setDelay(Duration.millis(0));
setCycleDuration(Duration.millis(300));
}};
OverLayRipple() {
super(control.getLayoutBounds().getWidth() - 0.1, control.getLayoutBounds().getHeight() - 0.1);
this.getStyleClass().add("jfx-rippler-overlay");
// update initial position
double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX());
double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY());
Bounds bounds = control.getBoundsInParent();
this.setX(bounds.getMinX() + diffMinX);
this.setY(bounds.getMinY() + diffMinY);
// set initial attributes
this.setOpacity(0);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setSnapToPixel(false);
outAnimation.setOnFinished((finish) -> resetOverLay());
}
}
private final class Ripple extends Circle {
KeyValue[] outKeyValues;
CachedAnimation outAnimation = null;
CachedAnimation inAnimation = null;
private Ripple(double centerX, double centerY) {
super(centerX,
centerY,
ripplerRadius.get()
.doubleValue() == Region.USE_COMPUTED_SIZE ? computeRippleRadius() : ripplerRadius.get()
.doubleValue(),
null);
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, rippleInterpolator);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, rippleInterpolator);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, rippleInterpolator);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, rippleInterpolator);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, rippleInterpolator);
if (isRipplerRecenter()) {
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
double dy = (control.getLayoutBounds().getHeight() / 2 - centerY) / 1.55;
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
Math.signum(dx) * Math.min(Math.abs(dx),
this.getRadius() / 2),
rippleInterpolator);
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
Math.signum(dy) * Math.min(Math.abs(dy),
this.getRadius() / 2),
rippleInterpolator);
}
inAnimation = new CachedAnimation(new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(scaleXProperty(),
0,
rippleInterpolator),
new KeyValue(scaleYProperty(),
0,
rippleInterpolator),
new KeyValue(translateXProperty(),
0,
rippleInterpolator),
new KeyValue(translateYProperty(),
0,
rippleInterpolator),
new KeyValue(opacityProperty(),
1,
rippleInterpolator)
), new KeyFrame(Duration.millis(900), inKeyValues)), this);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setSnapToPixel(false);
setScaleX(0);
setScaleY(0);
if (ripplerFill.get() instanceof Color) {
Color circleColor = new Color(((Color) ripplerFill.get()).getRed(),
((Color) ripplerFill.get()).getGreen(),
((Color) ripplerFill.get()).getBlue(),
0.3);
setStroke(circleColor);
setFill(circleColor);
} else {
setStroke(ripplerFill.get());
setFill(ripplerFill.get());
}
}
}
public void clear() {
getChildren().clear();
generating.set(false);
}
}
private void resetOverLay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.inAnimation.stop();
final RippleGenerator.OverLayRipple oldOverlay = rippler.overlayRect;
rippler.overlayRect.outAnimation.setOnFinished((finish) -> rippler.getChildren().remove(oldOverlay));
rippler.overlayRect.outAnimation.play();
rippler.overlayRect = null;
}
}
private void resetClip() {
this.rippler.resetClip = true;
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/**
* Initialize the style class to 'jfx-rippler'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-rippler";
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
/**
* the ripple recenter property, by default it's false.
* if true the ripple effect will show gravitational pull to the center of its control
*/
private StyleableObjectProperty<Boolean> ripplerRecenter = new SimpleStyleableObjectProperty<>(
StyleableProperties.RIPPLER_RECENTER,
JFXRippler.this,
"ripplerRecenter",
false);
public Boolean isRipplerRecenter() {
return ripplerRecenter == null ? false : ripplerRecenter.get();
}
public StyleableObjectProperty<Boolean> ripplerRecenterProperty() {
return this.ripplerRecenter;
}
public void setRipplerRecenter(Boolean radius) {
this.ripplerRecenter.set(radius);
}
/**
* the ripple radius size, by default it will be automatically computed.
*/
private StyleableObjectProperty<Number> ripplerRadius = new SimpleStyleableObjectProperty<>(
StyleableProperties.RIPPLER_RADIUS,
JFXRippler.this,
"ripplerRadius",
Region.USE_COMPUTED_SIZE);
public Number getRipplerRadius() {
return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get();
}
public StyleableObjectProperty<Number> ripplerRadiusProperty() {
return this.ripplerRadius;
}
public void setRipplerRadius(Number radius) {
this.ripplerRadius.set(radius);
}
/**
* the default color of the ripple effect
*/
private StyleableObjectProperty<Paint> ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
JFXRippler.this,
"ripplerFill",
Color.rgb(0,
200,
255));
public Paint getRipplerFill() {
return ripplerFill == null ? Color.rgb(0, 200, 255) : ripplerFill.get();
}
public StyleableObjectProperty<Paint> ripplerFillProperty() {
return this.ripplerFill;
}
public void setRipplerFill(Paint color) {
this.ripplerFill.set(color);
}
/**
* mask property used for clipping the rippler.
* can be either CIRCLE/RECT
*/
private StyleableObjectProperty<RipplerMask> maskType = new SimpleStyleableObjectProperty<>(
StyleableProperties.MASK_TYPE,
JFXRippler.this,
"maskType",
RipplerMask.RECT);
public RipplerMask getMaskType() {
return maskType == null ? RipplerMask.RECT : maskType.get();
}
public StyleableObjectProperty<RipplerMask> maskTypeProperty() {
return this.maskType;
}
public void setMaskType(RipplerMask type) {
this.maskType.set(type);
}
/**
* indicates whether the ripple effect is infront of or behind the node
*/
protected ObjectProperty<RipplerPos> position = new SimpleObjectProperty<>();
public void setPosition(RipplerPos pos) {
this.position.set(pos);
}
public RipplerPos getPosition() {
return position == null ? RipplerPos.FRONT : position.get();
}
public ObjectProperty<RipplerPos> positionProperty() {
return this.position;
}
private static final class StyleableProperties {
private static final CssMetaData<JFXRippler, Boolean> RIPPLER_RECENTER =
new CssMetaData<JFXRippler, Boolean>("-jfx-rippler-recenter",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerRecenter == null || !control.ripplerRecenter.isBound();
}
@Override
public StyleableProperty<Boolean> getStyleableProperty(JFXRippler control) {
return control.ripplerRecenterProperty();
}
};
private static final CssMetaData<JFXRippler, Paint> RIPPLER_FILL =
new CssMetaData<JFXRippler, Paint>("-jfx-rippler-fill",
PaintConverter.getInstance(), Color.rgb(0, 200, 255)) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerFill == null || !control.ripplerFill.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXRippler control) {
return control.ripplerFillProperty();
}
};
private static final CssMetaData<JFXRippler, Number> RIPPLER_RADIUS =
new CssMetaData<JFXRippler, Number>("-jfx-rippler-radius",
SizeConverter.getInstance(), Region.USE_COMPUTED_SIZE) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerRadius == null || !control.ripplerRadius.isBound();
}
@Override
public StyleableProperty<Number> getStyleableProperty(JFXRippler control) {
return control.ripplerRadiusProperty();
}
};
private static final CssMetaData<JFXRippler, RipplerMask> MASK_TYPE =
new CssMetaData<JFXRippler, RipplerMask>("-jfx-mask-type",
RipplerMaskTypeConverter.getInstance(), RipplerMask.RECT) {
@Override
public boolean isSettable(JFXRippler control) {
return control.maskType == null || !control.maskType.isBound();
}
@Override
public StyleableProperty<RipplerMask> getStyleableProperty(JFXRippler control) {
return control.maskTypeProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(Parent.getClassCssMetaData());
Collections.addAll(styleables,
RIPPLER_RECENTER,
RIPPLER_RADIUS,
RIPPLER_FILL,
MASK_TYPE
);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
public Runnable createManualRipple() {
rippler.setGeneratorCenterX(control.getLayoutBounds().getWidth() / 2);
rippler.setGeneratorCenterY(control.getLayoutBounds().getHeight() / 2);
return rippler.createManualRipple();
}
}