All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.github.palexdev.mfxeffects.ripple.MFXRippleGenerator Maven / Gradle / Ivy

There is a newer version: 11.26.0
Show newest version
package io.github.palexdev.mfxeffects.ripple;

import io.github.palexdev.mfxeffects.beans.Position;
import io.github.palexdev.mfxeffects.beans.Size;
import io.github.palexdev.mfxeffects.beans.properties.styleable.StyleableSizeProperty;
import io.github.palexdev.mfxeffects.enums.MouseMode;
import io.github.palexdev.mfxeffects.enums.RippleState;
import io.github.palexdev.mfxeffects.ripple.base.Ripple;
import io.github.palexdev.mfxeffects.ripple.base.RippleGenerator;
import io.github.palexdev.mfxeffects.ripple.base.RippleGeneratorBase;
import io.github.palexdev.mfxeffects.utils.StyleUtils;
import javafx.beans.binding.Bindings;
import javafx.css.*;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;

import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;

/**
 * Component capable of generating the ripple effect described by the Material Design Guidelines.
 * Adapted to JavaFX, this generator is capable, in combination with {@link CircleRipple}, of creating an
 * effect that is very close to the one seen in Material 3.
 * 

* Extends {@link RippleGeneratorBase} and thus also implementing {@link RippleGenerator}, with some minor additions. *

* The generator is a contained surface, meaning that all the animations/effects are actuated on itself or in contained * nodes. Yet, it is not an independent component. A {@link Region} that will be the generator's {@code owner} is a must * requirement. Not only that, it's important to also notice that the owner must add the generator among its children * and properly layout it (mostly resize it, location will usually always be at [0, 0]). * There are alternative ways (for example wrapping both the generator and the node on which the user wants the effect * in a StackPane or similar, the owner will be the wrapping container), but the one described is the most usual way. *

* A generator and this is part of the base API, also needs to know where to generate the ripple effect. Most of the time, * the position will come from a {@link MouseEvent} occurred on the owner. However, consider this example, you have a button * with the generator incorporated, the button can be triggered by pressing the ENTER key too, in such case you probably still * want to communicate the interaction to the user through a ripple effect. In such cases it's enough to add a handler for * the desired event type, and generate the ripple at the center of the component. *

* Ideally the generator should be 'de-/activated' through the methods {@link #enable()} and {@link #disable()}, if disposal * is needed instead of just disable, call {@link #dispose()}. However, nothing prevents you from specifying your own * handlers. *

*

* I want to spend some lines describing the generator's working too. *

* Compared to old implementations this is more efficient, simpler but also a bit more complex. *

- It's more efficient because now everything is cached, from the ripple to the clip to all the animations. Everything is * re-built only when needed and this is huge for performance, great. Everything needed by the generator is kept in a * 'wrapping' class, that represents its state, {@link GeneratorState}. *

- It's simpler because many properties and methods have been removed, the generator has not been stripped of its features, * they were just useless *

- It's a bit more complex because now the effect generation is divided in two times: * there's the generation phase and the release phase. This has been done to mimic very closely the behavior of the original * Material Design ripple effect, more info here: {@link #generate(double, double)}, {@link #release()}, {@link CircleRipple}; * That said, one could also implement a ripple that does everything during just one of the two phases, there are many ways to * do it. *

* Last but not least about the generation, there are a few conditions that can prevent the generator from 'activating'. *

1) Users can disable the generator in various ways: by disabling it, by setting its visibility to hidden * (can be done in CSS too, very convenient), or setting its opacity to 0.0 (also settable in CSS) *

2) By default the generator will check if the given coordinates are withing the owner's bounds, if that is not the * case the effect won't be generated, check {@link #checkBoundsProperty()} and {@link #canGenerateAt(double, double)} for * more info *

* The public API which defines the common features and behaviors of ripple generators ({@link RippleGenerator}), only * specifies a {@link #generate(double, double)} method that takes the two needed coordinates. One small addition in this * implementation is an easy way to generate effects from a {@link MouseEvent}, see {@link #generate(MouseEvent)}. *

* Typically, ripple effects are just circles that fade in/out and grow, this is the default type used by the generator * and specified by {@link #defaultRippleSupplier()}. Users can define new shapes implementing the base API for ripples, * {@link Ripple}, and set it on the generator through {@link #setRippleSupplier(Supplier)}. *

* Material Design show that the effect also animates the background surface, this is probably for a smoother transition. * This feature here is disabled by default and can be enabled/customized as needed with {@link #animateBackgroundProperty()} * and {@link #backgroundColorProperty()} *

* Last but not least, the generator is also clipped to avoid the effect trespassing the generator's bounds (which typically * correspond to the ones of the owner). There are three ways to clip the generator by setting the {@link Supplier} responsible * for creating the clip node: *

1) If you set a {@code null} supplier, the effect will be unbound, it will be able to trespass the bounds *

2) If you set the supplier to return {@code null} then the clip node will be automatically generated based on the * owner's geometry *

3) If you set the supplier to return a valid node than it will be used as the clip *

See also {@link #buildClip()} */ public class MFXRippleGenerator extends RippleGeneratorBase { //================================================================================ // Static Properties //================================================================================ public static final Color DEFAULT_RIPPLE_COLOR = Color.rgb(107, 107, 107, 0.12); //================================================================================ // Properties //================================================================================ private Region owner; protected GeneratorState state; private Supplier clipSupplier = () -> null; private Supplier> rippleSupplier = defaultRippleSupplier(); private Function meToPosConverter = e -> Position.of(e.getX(), e.getY()); private EventHandler pressed; private EventHandler released; private EventHandler exited; private boolean disposed = false; //================================================================================ // Constructors //================================================================================ public MFXRippleGenerator(Region owner) { this.owner = owner; initialize(); } //================================================================================ // Methods //================================================================================ private void initialize() { getStyleClass().setAll(defaultStyleClass()); setManaged(false); onMouseModeChanged(); } /** * Uses the {@link Function} set by {@link #setMeToPosConverter(Function)} to convert the give {@link MouseEvent} * and feed it to {@link #generate(Position)}. */ public void generate(MouseEvent me) { generate(meToPosConverter.apply(me)); } /** * Responsible for updating the generator's state when needed. This will also trigger a layout pass to ensure that * the generator's clip is positioned and sized correctly. */ protected void updateState() { state = new GeneratorState(this); state.init(); requestLayout(); // In case the clip changes, this ensures it is positioned and sized } /** * Automatically called when {@link #mouseModeProperty()} changes to update the way {@link MouseEvent}s are handled. *

*

1) {@link MouseMode#OFF}: mouseTransparent 'false' and pickOnBounds 'true' *

2) {@link MouseMode#DONT_PICK_ON_BOUNDS}: mouseTransparent 'false' and pickOnBounds 'false' *

3) {@link MouseMode#MOUSE_TRANSPARENT}: both mouseTransparent and pickOnBounds 'true' */ protected void onMouseModeChanged() { MouseMode mode = getMouseMode(); switch (mode) { case OFF: { setPickOnBounds(true); setMouseTransparent(false); break; } case DONT_PICK_ON_BOUNDS: { setPickOnBounds(false); setMouseTransparent(false); break; } case MOUSE_TRANSPARENT: setPickOnBounds(true); setMouseTransparent(true); break; } } /** * @return whether the generator has been disabled, set to be hidden, or it's opacity set to 0.0 * @see #disableProperty() * @see #visibleProperty() * @see #opacityProperty() */ public boolean isGeneratorDisabled() { return isDisabled() || !isVisible() || getOpacity() == 0; } /** * @return whether the given position for the ripple in inside the bounds of the generator's owner */ public boolean canGenerateAt(double x, double y) { if (!isCheckBounds()) return true; return owner.getLayoutBounds().contains(x, y); } //================================================================================ // Overridden Methods //================================================================================ /** * Adds these three handlers on the owner region: *

1) An handler for {@link MouseEvent#MOUSE_PRESSED} events that will trigger {@link #generate(MouseEvent)} *

2) An handler for {@link MouseEvent#MOUSE_RELEASED} events that will trigger {@link #release()} *

3) An handler for {@link MouseEvent#MOUSE_EXITED} events that also will trigger {@link #release()}; * Material Design Guidelines show that the ripple effect should disappear if the mouse is still pressed but goes outside * the node *

* Little side note, if this was already enabled before, or it was disposed, does nothing. */ @Override public void enable() { if (disposed) return; if (pressed == null) { pressed = this::generate; released = e -> release(); exited = e -> release(); owner.addEventHandler(MouseEvent.MOUSE_PRESSED, pressed); owner.addEventHandler(MouseEvent.MOUSE_RELEASED, released); owner.addEventHandler(MouseEvent.MOUSE_EXITED, exited); } } /** * Removes and sets to {@code null} the handlers build and added by {@link #enable()}. */ @Override public void disable() { if (pressed != null) { owner.removeEventHandler(MouseEvent.MOUSE_PRESSED, pressed); owner.removeEventHandler(MouseEvent.MOUSE_RELEASED, released); owner.removeEventHandler(MouseEvent.MOUSE_EXITED, exited); } pressed = null; released = null; exited = null; } /** * {@inheritDoc} *

* Exits immediately if the generator has been disabled ({@link #isGeneratorDisabled()}) or if the ripple cannot * be positioned at the given coordinated, {@link #canGenerateAt(double, double)}. *

* Sets the ripple state to {@link RippleState#WAITING_FOR_CLICK}, then asks the ripple node to reposition through * {@link Ripple#position(double, double)} and finally asks the ripple to play the in animations, {@link Ripple#playIn()}. */ @Override public void generate(double x, double y) { if (isGeneratorDisabled() || !canGenerateAt(x, y)) return; state.rippleState = RippleState.WAITING_FOR_CLICK; state.ripple.position(x, y); state.ripple.playIn(); } /** * {@inheritDoc} *

* Exits immediately if the current ripple state is {@link RippleState#INACTIVE}. *

* Otherwise calls {@link Ripple#playOut()} and finally set the ripple state to {@link RippleState#INACTIVE}, thus * preventing further 'releases' occurring. */ @Override public void release() { if (state.rippleState == RippleState.INACTIVE) return; state.ripple.playOut(); state.rippleState = RippleState.INACTIVE; } /** * Creates the generator's clip node depending on the set {@link #getClipSupplier()}. *

*

1) If you set a {@code null} supplier, the effect will be unbound, it will be able to trespass the bounds *

2) If you set the supplier to return {@code null} then the clip node will be automatically generated based on the * owner's geometry *

3) If you set the supplier to return a valid node than it will be used as the clip *

* To further explain the second case. This automatic geometry detection algorithm is not perfect but should work most * of the time. The clip will look at the owner's background and border and check whether a radius is applied on either * one of them. This detection is done by {@link StyleUtils#parseCornerRadius(Region)}. */ @Override public Region buildClip() { Supplier supplier = getClipSupplier(); if (supplier == null) return null; Region clip = supplier.get(); if (clip != null) return clip; clip = new Region(); clip.backgroundProperty().bind(Bindings.createObjectBinding( () -> { CornerRadii radius = StyleUtils.parseCornerRadius(owner); BackgroundFill fill = new BackgroundFill(Color.WHITE, radius, Insets.EMPTY); return new Background(fill); }, owner.backgroundProperty(), owner.borderProperty() )); return clip; } /** * Removes the ripple node, disables the generator, disposes the state and finally sets the owner to null, * making the generator not usable anymore! */ @Override public void dispose() { disposed = true; getChildren().clear(); disable(); if (state != null) state.dispose(); owner = null; } @Override protected void layoutChildren() { if (disposed) return; double w = getWidth(); double h = getHeight(); if (state == null) { updateState(); state.clip.resizeRelocate(0, 0, w, h); return; } if (Size.of(w, h).equals(state.bounds)) { if (state.clipUpdated) { state.clip.resizeRelocate(0, 0, w, h); state.clipUpdated = false; } return; } updateState(); state.clip.resizeRelocate(0, 0, w, h); } //================================================================================ // Styleable Properties //================================================================================ private final StyleableBooleanProperty animateBackground = new SimpleStyleableBooleanProperty( StyleableProperties.ANIMATE_BACKGROUND, this, "animateBackground", false ) { @Override protected void invalidated() { if (state != null) state.updateRipple(); } @Override public StyleOrigin getStyleOrigin() { return StyleOrigin.USER_AGENT; } }; private final StyleableObjectProperty backgroundColor = new SimpleStyleableObjectProperty<>( StyleableProperties.BACKGROUND_COLOR, this, "backgroundColor", DEFAULT_RIPPLE_COLOR ) { @Override protected void invalidated() { if (state != null && doAnimateBackground()) state.updateRipple(); } @Override public StyleOrigin getStyleOrigin() { return StyleOrigin.USER_AGENT; } }; private final StyleableObjectProperty rippleColor = new SimpleStyleableObjectProperty<>( StyleableProperties.RIPPLE_COLOR, this, "rippleColor", DEFAULT_RIPPLE_COLOR ) { @Override protected void invalidated() { if (state != null) state.updateRipple(); } @Override public StyleOrigin getStyleOrigin() { return StyleOrigin.USER_AGENT; } }; private final StyleableSizeProperty ripplePrefSize = new StyleableSizeProperty( StyleableProperties.RIPPLE_PREF_SIZE, this, "ripplePrefSize", Size.invalid() ) { @Override protected void invalidated() { if (state != null) state.updateRipple(); } }; private final StyleableBooleanProperty checkBounds = new SimpleStyleableBooleanProperty( StyleableProperties.CHECK_BOUNDS, this, "checkBounds", true ) { @Override public StyleOrigin getStyleOrigin() { return StyleOrigin.USER_AGENT; } }; private final StyleableObjectProperty mouseMode = new SimpleStyleableObjectProperty<>( StyleableProperties.MOUSE_MODE, this, "mouseMode", MouseMode.MOUSE_TRANSPARENT ) { @Override protected void invalidated() { onMouseModeChanged(); } @Override public StyleOrigin getStyleOrigin() { return StyleOrigin.USER_AGENT; } }; @Override public boolean doAnimateBackground() { return animateBackground.get(); } /** * {@inheritDoc} *

* Can be set in CSS via the property: '-mfx-animate-background"; */ @Override public StyleableBooleanProperty animateBackgroundProperty() { return animateBackground; } @Override public void setAnimateBackground(boolean animateBackground) { this.animateBackground.set(animateBackground); } @Override public Color getBackgroundColor() { return backgroundColor.get(); } /** * {@inheritDoc} *

* Can be set in CSS via the property: '-mfx-background-color"; */ @Override public StyleableObjectProperty backgroundColorProperty() { return backgroundColor; } @Override public void setBackgroundColor(Color backgroundColor) { this.backgroundColor.set(backgroundColor); } @Override public Color getRippleColor() { return rippleColor.get(); } /** * {@inheritDoc} *

* Can be set in CSS via the property: '-mfx-ripple-color"; */ @Override public StyleableObjectProperty rippleColorProperty() { return rippleColor; } @Override public void setRippleColor(Color rippleColor) { this.rippleColor.set(rippleColor); } @Override public Size getRipplePrefSize() { return ripplePrefSize.get(); } /** * {@inheritDoc} *

* Can be set in CSS via the property: '-mfx-ripple-pref-size"; */ @Override public StyleableSizeProperty ripplePrefSizeProperty() { return ripplePrefSize; } @Override public void setRipplePrefSize(Size ripplePrefSize) { this.ripplePrefSize.set(ripplePrefSize); } public boolean isCheckBounds() { return checkBounds.get(); } /** * Specifies whether the generator should first check the coordinates given to {@link #generate(double, double)} before * generating the effect. See {@link #canGenerateAt(double, double)}. *

* Can be set in CSS via the property: '-mfx-check-bounds"; */ public StyleableBooleanProperty checkBoundsProperty() { return checkBounds; } public void setCheckBounds(boolean checkBounds) { this.checkBounds.set(checkBounds); } public MouseMode getMouseMode() { return mouseMode.get(); } /** * Allows to specify the behavior of the generator regarding {@link MouseEvent}s. It may happen (for rounded components in particular) * that Mouse events are intercepted outside the node and thus triggering pseudo states when not intended. *

* By default, it's set to {@link MouseMode#MOUSE_TRANSPARENT} so that the generator ignores any {@link MouseEvent}, * in the vast majority of cases, other nodes are responsible for intercepting the event that will generate the ripple * anyway, so I don't see why the generator should intercept the events. However, I still wanted to keep this configurable * for any exception. *

* Can be set in CSS via the property: '-mfx-mouse-mode'. */ public StyleableObjectProperty mouseModeProperty() { return mouseMode; } public void setMouseMode(MouseMode mouseMode) { this.mouseMode.set(mouseMode); } private static class StyleableProperties { private static final StyleablePropertyFactory FACTORY = new StyleablePropertyFactory<>(Region.getClassCssMetaData()); private static final List> cssMetaDataList; private static final CssMetaData ANIMATE_BACKGROUND = FACTORY.createBooleanCssMetaData( "-mfx-animate-background", MFXRippleGenerator::animateBackgroundProperty, false ); private static final CssMetaData BACKGROUND_COLOR = FACTORY.createColorCssMetaData( "-mfx-background-color", MFXRippleGenerator::backgroundColorProperty, DEFAULT_RIPPLE_COLOR ); private static final CssMetaData RIPPLE_COLOR = FACTORY.createColorCssMetaData( "-mfx-ripple-color", MFXRippleGenerator::rippleColorProperty, DEFAULT_RIPPLE_COLOR ); private static final CssMetaData RIPPLE_PREF_SIZE = StyleableSizeProperty.metaDataFor( "-mfx-ripple-pref-size", MFXRippleGenerator::ripplePrefSizeProperty, Size.invalid() ); private static final CssMetaData CHECK_BOUNDS = FACTORY.createBooleanCssMetaData( "-mfx-check-bounds", MFXRippleGenerator::checkBoundsProperty, true ); private static final CssMetaData MOUSE_MODE = FACTORY.createEnumCssMetaData( MouseMode.class, "-mfx-mouse-mode", MFXRippleGenerator::mouseModeProperty, MouseMode.MOUSE_TRANSPARENT ); static { cssMetaDataList = StyleUtils.cssMetaDataList( Region.getClassCssMetaData(), ANIMATE_BACKGROUND, BACKGROUND_COLOR, RIPPLE_COLOR, RIPPLE_PREF_SIZE, CHECK_BOUNDS, MOUSE_MODE ); } } public static List> getClassCssMetaData() { return StyleableProperties.cssMetaDataList; } @Override public List> getCssMetaData() { return getClassCssMetaData(); } //================================================================================ // Getters/Setters //================================================================================ /** * @return the default style class of this generator */ public final String defaultStyleClass() { return "mfx-ripple-generator"; } @Override public Region getOwner() { return owner; } /** * @return the current ripple effect state, see {@link RippleState} */ public RippleState getRippleState() { return Optional.ofNullable(state) .map(GeneratorState::getRippleState) .orElse(RippleState.INACTIVE); } @Override public Supplier getClipSupplier() { return clipSupplier; } /** * {@inheritDoc} *

* If the generator' state is not null, this is also responsible for calling {@link GeneratorState#updateClip()}. */ @Override public void setClipSupplier(Supplier clipSupplier) { this.clipSupplier = clipSupplier; if (state != null) state.updateClip(); } @Override public Supplier> getRippleSupplier() { return rippleSupplier; } /** * {@inheritDoc} *

* If the generator' state is not null, this is also responsible for calling {@link GeneratorState#updateRipple()}. */ @Override public void setRippleSupplier(Supplier> rippleSupplier) { this.rippleSupplier = rippleSupplier; if (state != null) state.updateRipple(); } /** * @return the {@link Function} used by the generator to convert a {@link MouseEvent} to a {@link Position} * bean, which will be used as the coordinates at which animate the ripple */ public Function getMeToPosConverter() { return meToPosConverter; } /** * Sets the {@link Function} used by the generator to convert a {@link MouseEvent} to a {@link Position} * bean, which will be used as the coordinates at which animate the ripple */ public void setMeToPosConverter(Function meToPosConverter) { this.meToPosConverter = meToPosConverter; } /** * @return whether the generator was disposed before */ public boolean isDisposed() { return disposed; } //================================================================================ // Internal Classes //================================================================================ /** * Little wrapper class that holds info about a ripple generator' state such as: *

- the current state of the effect, see {@link RippleState} *

- the generator' sizes (width and height) *

- the generator's clip node *

- the generator's ripple node *

* When a new state is created, {@link #init()} must be called after so that the aforementioned properties can be * initialized. A new state is not always required and mostly happens when the generator's bounds change, since these * values may also influence animations. If properties such as {@link RippleGenerator#getRippleSupplier()} or * {@link RippleGenerator#getClipSupplier()} change, then it's enough to update just what's needed. */ public static class GeneratorState { private MFXRippleGenerator generator; private RippleState rippleState = RippleState.INACTIVE; private Size bounds; private Node clip; private Ripple ripple; private boolean clipUpdated = false; public GeneratorState(MFXRippleGenerator generator) { this.generator = generator; } /** * Takes and saves the generator's bounds, calls {@link #updateClip()} and then {@link #buildRipple()}. */ protected void init() { bounds = Size.of(generator.getWidth(), generator.getHeight()); updateClip(); updateRipple(); } /** * Calls {@link RippleGeneratorBase#buildClip()} to create the generator's clip, then sets it. */ protected void updateClip() { clip = generator.buildClip(); generator.setClip(clip); } /** * Calls {@link RippleGeneratorBase#buildRipple()} to create the ripple node, then initializes it through * {@link Ripple#init()}, and finally calls {@link RippleGeneratorBase#setRipple(Ripple)}. */ protected void updateRipple() { ripple = generator.buildRipple(); ripple.init(); generator.setRipple(ripple); } /** * Disposes this state by setting everything to null and removing the generator's clip. */ protected void dispose() { bounds = null; generator.setClip((null)); clip = null; ripple = null; generator = null; } /** * @return the generator's instance this state refers to */ public RippleGeneratorBase getGenerator() { return generator; } /** * @return the ripple effect state, see {@link RippleState} */ public RippleState getRippleState() { return rippleState; } /** * @return the generator's sizes */ public Size getBounds() { return bounds; } /** * @return the generator's clip node */ public Node getClip() { return clip; } /** * @return the generator's ripple node */ public Ripple getRipple() { return ripple; } /** * @return whether the clip node needs to be updated */ public boolean wasClipUpdated() { return clipUpdated; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy