diff --git a/.idea/runConfigurations/ResourcesLauncher.xml b/.idea/runConfigurations/ResourcesLauncher.xml new file mode 100644 index 00000000..4237e961 --- /dev/null +++ b/.idea/runConfigurations/ResourcesLauncher.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/AnimationFactory.java b/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/AnimationFactory.java index 114a29b2..108f78d5 100644 --- a/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/AnimationFactory.java +++ b/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/AnimationFactory.java @@ -18,173 +18,218 @@ package io.github.palexdev.mfxeffects.animations; +import io.github.palexdev.mfxeffects.animations.Animations.KeyFrames; +import io.github.palexdev.mfxeffects.animations.Animations.TimelineBuilder; import javafx.animation.Interpolator; -import javafx.animation.KeyFrame; -import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.scene.Node; +import javafx.scene.Parent; import javafx.util.Duration; /** * Convenience factory for various animations applied to {@code Nodes}. * + * @see #extraOffset * @see Timeline */ public enum AnimationFactory { FADE_IN { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.opacityProperty(), 0, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.opacityProperty(), 1.0, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.opacityProperty(), 0.0)) + .add(KeyFrames.of(millis, node.opacityProperty(), 1.0, i)) + .getAnimation(); } }, FADE_OUT { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.opacityProperty(), 1.0, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.opacityProperty(), 0, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.opacityProperty(), 1.0)) + .add(KeyFrames.of(millis, node.opacityProperty(), 0.0, i)) + .getAnimation(); } }, SLIDE_IN_BOTTOM { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateYProperty(), -node.getBoundsInParent().getHeight() * 2, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateYProperty(), 0, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceBottom(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateYProperty(), distance)) + .add(KeyFrames.of(millis, node.translateYProperty(), 0, i)) + .getAnimation(); } }, SLIDE_OUT_BOTTOM { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateYProperty(), 0, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateYProperty(), node.getBoundsInParent().getHeight() * 2, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceBottom(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateYProperty(), 0)) + .add(KeyFrames.of(millis, node.translateYProperty(), distance, i)) + .getAnimation(); } }, SLIDE_IN_LEFT { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateXProperty(), -node.getBoundsInParent().getWidth() * 2, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateXProperty(), 0, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceLeft(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateXProperty(), -distance)) + .add(KeyFrames.of(millis, node.translateXProperty(), 0, i)) + .getAnimation(); } }, SLIDE_OUT_LEFT { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateXProperty(), 0, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateXProperty(), -node.getBoundsInParent().getWidth() * 2, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceLeft(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateXProperty(), 0)) + .add(KeyFrames.of(millis, node.translateXProperty(), -distance, i)) + .getAnimation(); } }, SLIDE_IN_RIGHT { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateXProperty(), node.getBoundsInParent().getWidth() * 2, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateXProperty(), 0, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceRight(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateXProperty(), distance)) + .add(KeyFrames.of(millis, node.translateXProperty(), 0, i)) + .getAnimation(); } }, SLIDE_OUT_RIGHT { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateXProperty(), 0, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateXProperty(), node.getBoundsInParent().getWidth() * 2, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceRight(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateXProperty(), 0)) + .add(KeyFrames.of(millis, node.translateXProperty(), distance, i)) + .getAnimation(); } }, SLIDE_IN_TOP { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateYProperty(), node.getBoundsInParent().getHeight() * 2, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateYProperty(), 0, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceTop(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateYProperty(), distance)) + .add(KeyFrames.of(millis, node.translateYProperty(), 0, i)) + .getAnimation(); } }, SLIDE_OUT_TOP { @Override - public Timeline build(Node node, double durationMillis, Interpolator i) { - AnimationFactory.resetNode(node); - KeyValue keyValue1 = new KeyValue(node.translateYProperty(), 0, i); - KeyFrame keyFrame1 = new KeyFrame(Duration.ZERO, keyValue1); - - KeyValue keyValue2 = new KeyValue(node.translateYProperty(), -node.getBoundsInParent().getHeight() * 2, i); - KeyFrame keyFrame2 = new KeyFrame(Duration.millis(durationMillis), keyValue2); - - return new Timeline(keyFrame1, keyFrame2); + public Timeline build(Node node, double millis, Interpolator i) { + double distance = computeDistanceTop(node); + return TimelineBuilder.build() + .add(KeyFrames.of(0, node.translateYProperty(), 0)) + .add(KeyFrames.of(millis, node.translateYProperty(), distance, i)) + .getAnimation(); } }; public static final Interpolator INTERPOLATOR_V1 = Interpolator.SPLINE(0.25, 0.1, 0.25, 1); public static final Interpolator INTERPOLATOR_V2 = Interpolator.SPLINE(0.0825D, 0.3025D, 0.0875D, 0.9975D); - private static void resetNode(Node node) { - if (node != null) { - node.setTranslateX(0); - node.setTranslateY(0); - } + /** + * This special variable is used in slide animations when the "travel distance" is computed. + * This extra offset is added to the computed value to ensure the node is outside the parent, for a smooth animation. + */ + @SuppressWarnings("NonFinalFieldInEnum") + public static double extraOffset = 5.0; + + /** + * Computes the distance between the node and the left side of its parent by using its + * {@link Node#boundsInParentProperty()}. This distance ensures the node is going to be outside/inside the parent + * towards the animation's ending. + * + * @see #extraOffset + */ + public double computeDistanceLeft(Node node) { + double w = node.getBoundsInParent().getWidth(); + return node.getBoundsInParent().getMinX() + w + extraOffset; + } + + /** + * Computes the distance between the node and the right side of its parent. For this computation the + * {@link Node#parentProperty()} must not return a {@code null} value (the node must be child of some other node). + *

+ * If the parent is {@code null} a 'fallback' value is returned, that is the width of the node plus the extra offset. + *

+ * Otherwise, the value is computed like this: + * {@code parent.getLayoutBounds().getWidth() - node.getBoundsInParent().getMaxX() + node.getBoundsInParent().getWidth() + extraOffset} + * + * @see #extraOffset + */ + public double computeDistanceRight(Node node) { + double w = node.getBoundsInParent().getWidth(); + Parent parent = node.getParent(); + if (parent == null) return w + extraOffset; + return parent.getLayoutBounds().getWidth() - node.getBoundsInParent().getMaxX() + w + extraOffset; + } + + /** + * Computes the distance between the node and the top side of its parent by using its + * {@link Node#boundsInParentProperty()}. This distance ensures the node is going to be outside/inside the parent + * towards the animation's ending. + * + * @see #extraOffset + */ + public double computeDistanceTop(Node node) { + double h = node.getBoundsInParent().getHeight(); + return node.getBoundsInParent().getMinY() + h + extraOffset; + } + + /** + * Computes the distance between the node and the bottom side of its parent. For this computation the + * {@link Node#parentProperty()} must not return a {@code null} value (the node must be child of some other node). + *

+ * If the parent is {@code null} a 'fallback' value is returned, that is the height of the node plus the extra offset. + *

+ * Otherwise, the value is computed like this: + * {@code parent.getLayoutBounds().getHeight() - node.getBoundsInParent().getMaxY() + node.getBoundsInParent().getHeight() + extraOffset} + * + * @see #extraOffset + */ + public double computeDistanceBottom(Node node) { + double h = node.getBoundsInParent().getHeight(); + Parent parent = node.getParent(); + if (parent == null) return h + extraOffset; + return parent.getLayoutBounds().getHeight() - node.getBoundsInParent().getMaxY() + h + extraOffset; } /** * Calls {@link #build(Node, double, Interpolator)} with {@link #INTERPOLATOR_V1} as the default interpolator. */ - public Timeline build(Node node, double durationMillis) { - return build(node, durationMillis, INTERPOLATOR_V1); + public Timeline build(Node node, double millis) { + return build(node, millis, INTERPOLATOR_V1); + } + + /** + * Calls {@link #build(Node, double)} with the given duration converted to milliseconds. + */ + public Timeline build(Node node, Duration duration) { + return build(node, duration.toMillis()); + } + + /** + * Calls {@link #build(Node, double, Interpolator)} with the given duration converted to milliseconds and the given + * interpolator. + */ + public Timeline build(Node node, Duration duration, Interpolator i) { + return build(node, duration.toMillis(), i); } /** * Each enum constant will produce a {@link Timeline} with the given parameters. * - * @param node the {@link Node} on which perform the animation - * @param durationMillis the duration of the animation in milliseconds - * @param i the {@link Interpolator} used by the animations + * @param node the {@link Node} on which perform the animation + * @param millis the duration of the animation in milliseconds + * @param i the {@link Interpolator} used by the animations */ - public abstract Timeline build(Node node, double durationMillis, Interpolator i); + public abstract Timeline build(Node node, double millis, Interpolator i); } diff --git a/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/Animations.java b/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/Animations.java index dce88b0c..3500c0e3 100644 --- a/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/Animations.java +++ b/modules/effects/src/main/java/io/github/palexdev/mfxeffects/animations/Animations.java @@ -21,6 +21,8 @@ import io.github.palexdev.mfxeffects.beans.AnimationsData; import io.github.palexdev.mfxeffects.enums.Interpolators; import javafx.animation.*; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; import javafx.beans.binding.BooleanExpression; import javafx.beans.value.WritableValue; import javafx.event.ActionEvent; @@ -130,6 +132,54 @@ public static AbstractBuilder transitionText(Text text, double millis, String ne return transitionText(text, Duration.millis(millis), nexText); } + /** + * Allows to perform a given action as a {@link Runnable} as soon as the given {@link Animation} reaches the given + * {@link Animation.Status}. This is simply done by attaching an {@link InvalidationListener} to the animation's + * {@link Animation#statusProperty()}. + *

+ * Trivia + *

+ * {@link Animation}s have a feature that allows user to specify an action to perform as soon as it ends, I'm talking + * about the {@link Animation#onFinishedProperty()}. There is an issue though. If the animation is stopped, + * {@link Animation#stop()}, the action won't trigger. 'Stop' and 'Finish' are two different states, but JavaFX devs + * didn't make such distinction explicit, in fact when an animation ends, its status property will be set to + * {@link Animation.Status#STOPPED}. + *

+ * It may be useful in some occasions to perform an action once the animation stops, rather than just on finish. + * And so I created this generic method to fill this gap, working on any state you need. + * + * @param oneShot specifies whether the listener should be removed after the first execution + */ + public static void onStatus(Animation animation, Animation.Status status, Runnable action, boolean oneShot) { + if (animation == null) return; + InvalidationListener l = new InvalidationListener() { + @Override + public void invalidated(Observable observable) { + if (animation.getStatus() == status) { + action.run(); + if (oneShot) animation.statusProperty().removeListener(this); + } + } + }; + animation.statusProperty().addListener(l); + } + + /** + * Convenience method for {@link #onStatus(Animation, Animation.Status, Runnable, boolean)}, performs the given + * action on {@link Animation.Status#PAUSED}. + */ + public static void onPaused(Animation animation, Runnable action, boolean oneShot) { + onStatus(animation, Animation.Status.PAUSED, action, oneShot); + } + + /** + * Convenience method for {@link #onStatus(Animation, Animation.Status, Runnable, boolean)}, performs the given + * action on {@link Animation.Status#STOPPED}. + */ + public static void onStopped(Animation animation, Runnable action, boolean oneShot) { + onStatus(animation, Animation.Status.STOPPED, action, oneShot); + } + /** * @return true if the given animation status is RUNNING, otherwise false */ @@ -236,6 +286,15 @@ public AbstractBuilder add(KeyFrame... keyFrames) { return this; } + /** + * If the given condition returns true, then a new {@link Timeline} is built with the given keyframe and added to + * the 'main' animation by calling {@link #addAnimation(Animation)}. + */ + public AbstractBuilder addConditional(Supplier condition, KeyFrame keyFrame) { + if (condition.get()) addAnimation(new Timeline(keyFrame)); + return this; + } + /** * Builds a {@link Timeline} with the given keyframes, sets the given onFinished action to it and then adds it to the * "main" animation by calling {@link #addAnimation(Animation)}. diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconBuilder.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconBuilder.java index d6fe9677..9de73ed4 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconBuilder.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconBuilder.java @@ -18,6 +18,7 @@ package io.github.palexdev.mfxresources.builders; +import io.github.palexdev.mfxresources.fonts.IconDescriptor; import io.github.palexdev.mfxresources.fonts.IconsProviders; import io.github.palexdev.mfxresources.fonts.MFXFontIcon; import javafx.css.PseudoClass; @@ -78,6 +79,11 @@ public IconBuilder setDescription(String code) { return this; } + public IconBuilder setDescription(IconDescriptor description) { + icon.setDescription(description); + return this; + } + public IconBuilder setSize(double size) { icon.setSize(size); return this; @@ -102,6 +108,8 @@ public IconWrapperBuilder wrap() { return new IconWrapperBuilder(icon.wrap()); } + public IconWrapperBuilder wrapperBuilder() {return icon.wrapperBuilder();} + //================================================================================ // Node Delegate Methods //================================================================================ diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconWrapperBuilder.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconWrapperBuilder.java index 056299ee..9e5a16c7 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconWrapperBuilder.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/builders/IconWrapperBuilder.java @@ -19,9 +19,11 @@ package io.github.palexdev.mfxresources.builders; import io.github.palexdev.mfxeffects.beans.Position; +import io.github.palexdev.mfxresources.fonts.IconDescriptor; import io.github.palexdev.mfxresources.fonts.IconsProviders; import io.github.palexdev.mfxresources.fonts.MFXFontIcon; import io.github.palexdev.mfxresources.fonts.MFXIconWrapper; +import io.github.palexdev.mfxresources.fonts.MFXIconWrapper.AnimationPresets; import javafx.css.PseudoClass; import javafx.event.Event; import javafx.event.EventHandler; @@ -115,6 +117,18 @@ public IconWrapperBuilder setIcon(Font font, Function convert return this; } + public MFXIconWrapper setAnimated(boolean animated) { + return wrapper.setAnimated(animated); + } + + public MFXIconWrapper setIcon(IconDescriptor descriptor) { + return wrapper.setIcon(descriptor); + } + + public MFXIconWrapper setAnimationProvider(AnimationPresets preset) { + return wrapper.setAnimationProvider(preset); + } + //================================================================================ // Node Delegate Methods //================================================================================ diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/IconsProviders.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/IconsProviders.java index df5d79af..857347cd 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/IconsProviders.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/IconsProviders.java @@ -22,8 +22,6 @@ import io.github.palexdev.mfxresources.fonts.fontawesome.FontAwesomeBrands; import io.github.palexdev.mfxresources.fonts.fontawesome.FontAwesomeRegular; import io.github.palexdev.mfxresources.fonts.fontawesome.FontAwesomeSolid; -import io.github.palexdev.mfxresources.utils.EnumUtils; -import javafx.scene.paint.Color; import java.io.InputStream; import java.util.function.Function; @@ -60,85 +58,6 @@ public InputStream load() { return MFXResources.loadFont(font); } - /** - * Creates a new {@link MFXFontIcon} with a random icon description extracted from the values of "this" enumerator - * constant. - * - * @param size the size of the icon - * @param color the color of the icon - */ - public MFXFontIcon randomIcon(double size, Color color) { - MFXFontIcon icon = new MFXFontIcon(); - String desc; - switch (this) { - case FONTAWESOME_BRANDS: { - icon.setIconsProvider(FONTAWESOME_BRANDS); - desc = EnumUtils.randomEnum(FontAwesomeBrands.class).getDescription(); - break; - } - case FONTAWESOME_REGULAR: { - icon.setIconsProvider(FONTAWESOME_REGULAR); - desc = EnumUtils.randomEnum(FontAwesomeRegular.class).getDescription(); - break; - } - case FONTAWESOME_SOLID: { - icon.setIconsProvider(FONTAWESOME_SOLID); - desc = EnumUtils.randomEnum(FontAwesomeSolid.class).getDescription(); - break; - } - default: - return icon; - } - icon.setDescription(desc); - icon.setColor(color); - icon.setSize(size); - return icon; - } - - /** - * Creates a new {@link MFXFontIcon} with a random icon description extracted from the values of "this" enumerator - * constant. - */ - public MFXFontIcon randomIcon() { - MFXFontIcon icon = new MFXFontIcon(); - String desc; - switch (this) { - case FONTAWESOME_BRANDS: { - icon.setIconsProvider(FONTAWESOME_BRANDS); - desc = EnumUtils.randomEnum(FontAwesomeBrands.class).getDescription(); - break; - } - case FONTAWESOME_REGULAR: { - icon.setIconsProvider(FONTAWESOME_REGULAR); - desc = EnumUtils.randomEnum(FontAwesomeRegular.class).getDescription(); - break; - } - case FONTAWESOME_SOLID: { - icon.setIconsProvider(FONTAWESOME_SOLID); - desc = EnumUtils.randomEnum(FontAwesomeSolid.class).getDescription(); - break; - } - default: - return icon; - } - icon.setDescription(desc); - return icon; - } - - /** - * Same as {@link #randomIcon(double, Color)}, allows usage from a static context. - */ - public static MFXFontIcon randomIcon(IconsProviders provider, double size, Color color) { - return provider.randomIcon(size, color); - } - - /** - * Same as {@link #randomIcon()}, allows usage from a static context. - */ - public static MFXFontIcon randomIcon(IconsProviders provider) { - return provider.randomIcon(); - } - /** * @return the default icon provider used by {@link MFXFontIcon}s, currently {@link #FONTAWESOME_SOLID} */ diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXFontIcon.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXFontIcon.java index 1e1f6f2e..c6bff761 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXFontIcon.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXFontIcon.java @@ -48,7 +48,7 @@ *

* Now integrates with {@link MFXIconWrapper} in many ways with fluent API. */ -public class MFXFontIcon extends Text { +public class MFXFontIcon extends Text implements Cloneable { //================================================================================ // Properties //================================================================================ @@ -66,6 +66,18 @@ public MFXFontIcon(IconDescriptor icon) { this(icon.getDescription()); } + public MFXFontIcon(IconDescriptor icon, Color color) { + this(icon.getDescription(), color); + } + + public MFXFontIcon(IconDescriptor icon, double size) { + this(icon.getDescription(), size); + } + + public MFXFontIcon(IconDescriptor icon, double size, Color color) { + this(icon.getDescription(), size, color); + } + public MFXFontIcon(String description) { this(description, 16.0); } @@ -242,8 +254,9 @@ public StyleableObjectProperty colorProperty() { return color; } - public void setColor(Color color) { + public MFXFontIcon setColor(Color color) { this.color.set(color); + return this; } public String getDescription() { @@ -264,6 +277,11 @@ public MFXFontIcon setDescription(String code) { return this; } + public MFXFontIcon setDescription(IconDescriptor description) { + this.description.set(description.getDescription()); + return this; + } + public double getSize() { return size.get(); } @@ -330,6 +348,20 @@ private static class StyleableProperties { return getClassCssMetaData(); } + /** + * Creates a new {@code MFXFontIcon} instance with the same properties from this. + */ + @SuppressWarnings({"MethodDoesntCallSuperMethod", "CloneDoesntDeclareCloneNotSupportedException"}) + @Override + protected MFXFontIcon clone() { + MFXFontIcon clone = new MFXFontIcon(); + clone.setDescriptionConverter(getDescriptionConverter()); + clone.setDescription(getDescription()); + clone.setSize(getSize()); + clone.setColor(getColor()); + return clone; + } + @Override public String toString() { return "MFXFontIcon{" + diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXIconWrapper.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXIconWrapper.java index 9cb54186..cc6a35f4 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXIconWrapper.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/MFXIconWrapper.java @@ -18,9 +18,18 @@ package io.github.palexdev.mfxresources.fonts; +import io.github.palexdev.mfxeffects.animations.AnimationFactory; +import io.github.palexdev.mfxeffects.animations.Animations; +import io.github.palexdev.mfxeffects.animations.Animations.KeyFrames; +import io.github.palexdev.mfxeffects.animations.Animations.SequentialBuilder; +import io.github.palexdev.mfxeffects.animations.Animations.TimelineBuilder; +import io.github.palexdev.mfxeffects.animations.motion.M3Motion; import io.github.palexdev.mfxeffects.beans.Position; import io.github.palexdev.mfxeffects.ripple.MFXRippleGenerator; import io.github.palexdev.mfxresources.base.properties.IconProperty; +import javafx.animation.Animation; +import javafx.animation.Interpolator; +import javafx.animation.Timeline; import javafx.collections.ObservableList; import javafx.css.*; import javafx.event.EventHandler; @@ -29,11 +38,14 @@ import javafx.scene.input.MouseEvent; import javafx.scene.layout.StackPane; import javafx.scene.shape.Circle; +import javafx.scene.shape.Rectangle; import javafx.scene.text.Font; +import javafx.util.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; /** @@ -45,25 +57,93 @@ * The new API makes these two features easier to use for developers. Many times I had to pass the wrapper instance to a * controller just to enable them. Now, they both can be activated in CSS and in FXML, you don't even need their instance * in the control as the icon can be specified by editing manually the FXML, the only limit being that you can't change - * the icons provider. + * the icons' provider. *

* Note that by default the size of the wrapper is always the same for both width and height, it can be specified via the * {@link #sizeProperty()} or you could let this figure it out automatically at layout time. In the latter case, the * size is computed as the maximum between the icon's width and height. + *

+ * Animation API + *

+ * {@code MFXIconWrapper} is now capable of switching the wrapped icon through an animation and here I'm going to explain + * here some crucial details here. The API has been developed to be as flexible as possible while still following some + * critical rules. + *

+ * Following classical ways to implement such thing, there is now a new property, {@link #animatedProperty()}, that allows + * to enable/disable the system. Then a user must specify a {@link BiFunction} that given the old/current icon and the + * new icon builds the animation, to be precise the return type is {@link IconAnimation}, I'll tell why in a moment. + *

+ * The main issue with this wrapper is that it has been developed to keep at max two nodes, the ripple generator and the + * icon. However, some animations may need to operate on both the old and the new. I had different choices at this point, + * like for example give the user the modifiable children list and let the management be manual. But I didn't want to + * break this rule so the here's the solution I came up with. + *

+ * When the icon changes, the new one is added to the children list alongside the old one, but it's set to be invisible. + * At this point, if an animation is already playing it is stopped by invoking {@link IconAnimation#stop(MFXIconWrapper)}. + * This wrapping class, contains three values, the animation playing, the old icon and the new icon. If multiple animations + * are played in rapid succession, by stopping the last one with that method, we ensure {@code MFXIconWrapper} is in a + * consistent state. However it's important to precise that it only ensures the children list consists of the + * ripple generator (if enabled) and the new icon. If any property of the new icon was changed, that is user's responsibility + * to reset when the animation stops (see {@link Animations#onStopped(Animation, Runnable, boolean)}). + *

+ * For example, if the animation is changing the opacity value of a node, and the animation is stopped for whatever + * reason, then the user that coded the animation should take this fact into account and add the proper reset code. + *

+ * After ensuring that no other animation is currently running the set {@link BiFunction} will produce a new {@link IconAnimation} + * object, and play the new animation. + *

+ * Final note, the {@link IconAnimation} object is also responsible for removing the old icon from the wrapper once + * the animation ends/stops (again see {@link Animations#onStatus(Animation, Animation.Status, Runnable, boolean)} to + * understand the difference). + *

+ * Last but not least, I understand that in some occasions it may be difficult to set an animation via code, since often + * the wrapper is part of the view/skin. So, just like the ripple generator and the round feature, I added a property + * {@link #animationPresetProperty()} that allows to set one of the predefined animations in {@link AnimationPresets} + * from CSS by setting the '-mfx-animation-preset' property. + *

+ * A very important note about this: the property is just a bridge to CSS. DO NOT use it via code, there's no + * point in it. If you use {@link #setAnimationProvider(BiFunction)} and {@link #setAnimationPreset(AnimationPresets)} + * together the one will overwrite the other! */ -// TODO add icon switch with animation public class MFXIconWrapper extends StackPane { //================================================================================ // Properties //================================================================================ private final String STYLE_CLASS = "mfx-icon-wrapper"; - private final IconProperty icon = new IconProperty(); + private final IconProperty icon = new IconProperty() { + @Override + public void set(MFXFontIcon newValue) { + MFXFontIcon oldValue = get(); + if (isAnimated() && newValue != null) { + super.set(newValue); + newValue.setVisible(false); + addChild(newValue); + if (animation != null) animation.stop(MFXIconWrapper.this); + if (animationProvider != null) { + animation = animationProvider.apply(oldValue, newValue); + animation.play(); + return; + } + } + removeChild(oldValue); + super.set(newValue); + if (newValue != null) addChild(newValue); + } + + @Override + protected void invalidated() { + updateChildren(); + } + }; private MFXRippleGenerator rg; private EventHandler pressHandler; private EventHandler releaseHandler; private EventHandler exitHandler; + private IconAnimation animation; + private BiFunction animationProvider; + //================================================================================ // Constructors //================================================================================ @@ -89,11 +169,7 @@ private void initialize() { setMinSize(USE_PREF_SIZE, USE_PREF_SIZE); setMaxSize(USE_PREF_SIZE, USE_PREF_SIZE); addEventHandler(MouseEvent.MOUSE_PRESSED, e -> requestFocus()); - icon.addListener((observable, oldValue, newValue) -> { - super.getChildren().remove(oldValue); - manageChildren(); - }); - size.addListener((observable, oldValue, newValue) -> setPrefSize(newValue.doubleValue(), newValue.doubleValue())); + setAnimationPreset(AnimationPresets.FADE); } /** @@ -140,11 +216,16 @@ public MFXIconWrapper enableRippleGenerator(boolean enable, Function + * However, instead of directly managing the children list, it's best for performance to use the + * {@link Node#viewOrderProperty()}. The ripple generator is always given order 0, while the icon has order 1, + * this way the latter always appears in front of the first. */ - private void manageChildren() { - ObservableList children = super.getChildren(); - children.clear(); + protected void updateChildren() { MFXFontIcon icon = getIcon(); - if (rg != null) children.add(rg); - if (icon != null) children.add(icon); + if (rg != null) rg.setViewOrder(0); + if (icon != null) icon.setViewOrder(1); + } + + /** + * Convenience method for calling {@code super.getChildren().add(child)}. + */ + protected void addChild(Node child) { + super.getChildren().add(child); + } + + /** + * Convenience method for calling {@code super.getChildren().remove(child)}. + */ + protected void removeChild(Node child) { + super.getChildren().remove(child); } //================================================================================ @@ -235,6 +332,7 @@ public ObservableList getChildren() { @Override protected void layoutChildren() { super.layoutChildren(); + if (rg != null) rg.resizeRelocate(0, 0, getWidth(), getHeight()); MFXFontIcon icon = getIcon(); if (icon != null && icon.getDescription() != null && !icon.getDescription().isBlank() && getSize() == -1) { @@ -251,12 +349,48 @@ protected void layoutChildren() { //================================================================================ // Styleable Properties //================================================================================ + private final StyleableBooleanProperty animated = new SimpleStyleableBooleanProperty( + StyleableProperties.ANIMATED, + this, + "animated", + false + ) { + @Override + public StyleOrigin getStyleOrigin() { + return StyleOrigin.USER_AGENT; + } + }; + + private final StyleableObjectProperty animationPreset = new SimpleStyleableObjectProperty<>( + StyleableProperties.ANIMATION_PRESET, + this, + "animationPreset", + null + ) { + @Override + protected void invalidated() { + AnimationPresets p = get(); + if (p == null) return; + setAnimationProvider(p); + } + + @Override + public StyleOrigin getStyleOrigin() { + return StyleOrigin.USER_AGENT; + } + }; + private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty( StyleableProperties.SIZE, this, "size", -1.0 ) { + @Override + protected void invalidated() { + setPrefSize(get(), get()); + } + @Override public StyleOrigin getStyleOrigin() { return StyleOrigin.USER_AGENT; @@ -274,6 +408,7 @@ protected void invalidated() { boolean state = get(); if (!state && rg != null) enableRippleGenerator(false); if (state && rg == null) enableRippleGenerator(true); + updateChildren(); } @Override @@ -300,6 +435,39 @@ public StyleOrigin getStyleOrigin() { } }; + public boolean isAnimated() { + return animated.get(); + } + + /** + * Specifies whether icon switching should be animated. + *

+ * Can be set in CSS via the property: '-mfx-animated'. + * + * @see #setAnimationProvider(BiFunction) + * @see #setAnimationProvider(AnimationPresets) + */ + public StyleableBooleanProperty animatedProperty() { + return animated; + } + + public MFXIconWrapper setAnimated(boolean animated) { + this.animated.set(animated); + return this; + } + + public AnimationPresets getAnimationPreset() { + return animationPreset.get(); + } + + public StyleableObjectProperty animationPresetProperty() { + return animationPreset; + } + + public void setAnimationPreset(AnimationPresets animationPreset) { + this.animationPreset.set(animationPreset); + } + public double getSize() { return size.get(); } @@ -363,6 +531,21 @@ private static class StyleableProperties { private static final StyleablePropertyFactory FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData()); private static final List> cssMetaDataList; + private static final CssMetaData ANIMATED = + FACTORY.createBooleanCssMetaData( + "-mfx-animated", + MFXIconWrapper::animatedProperty, + false + ); + + private static final CssMetaData ANIMATION_PRESET = + FACTORY.createEnumCssMetaData( + AnimationPresets.class, + "-mfx-animation-preset", + MFXIconWrapper::animationPresetProperty, + null + ); + private static final CssMetaData SIZE = FACTORY.createSizeCssMetaData( "-mfx-size", @@ -386,7 +569,7 @@ private static class StyleableProperties { static { List> data = new ArrayList<>(StackPane.getClassCssMetaData()); - Collections.addAll(data, SIZE, ENABLE_RIPPLE, ROUND); + Collections.addAll(data, ANIMATED, ANIMATION_PRESET, SIZE, ENABLE_RIPPLE, ROUND); cssMetaDataList = List.copyOf(data); } } @@ -458,4 +641,400 @@ public MFXIconWrapper setIcon(Font font, Function converter, setIcon(new MFXFontIcon().setIconsProvider(font, converter).setDescription(desc)); return this; } + + /** + * Convenience method to set the {@link #iconProperty()} to a new {@link MFXFontIcon} instance given an {@link IconDescriptor}. + */ + public MFXIconWrapper setIcon(IconDescriptor descriptor) { + setIcon(new MFXFontIcon(descriptor)); + return this; + } + + /** + * @see #setAnimationProvider(BiFunction) + */ + public BiFunction getAnimationProvider() { + return animationProvider; + } + + /** + * Sets the function responsible for building the animation used when switching icons, this must be used in combination + * with {@link #animatedProperty()}, if that is not enabled than no animation will play. + *

+ * Also, to be precise, the function must return an {@link IconAnimation} object, see its docs for the why. + */ + public MFXIconWrapper setAnimationProvider(BiFunction animationProvider) { + this.animationProvider = animationProvider; + return this; + } + + /** + * This can be used to convert one of the predefined animations in {@link AnimationPresets} to the {@link BiFunction} + * needed by {@link #setAnimationProvider(BiFunction)}. + */ + public MFXIconWrapper setAnimationProvider(AnimationPresets preset) { + this.animationProvider = (o, n) -> preset.animate(this, o, n); + return this; + } + + //================================================================================ + // Internal Classes + //================================================================================ + + /** + * This wrapper class is used by {@link MFXIconWrapper} to manage its state when switching icons through animations. + * It has three properties: the animation used for the switch, the old/current icon, and the new icon. + *

+ * There are two main functionalities here: + *

1) When the animation ends, makes sure to remove the old icon from the children list + *

2) When the animation is stopped, ensures that {@link MFXIconWrapper} is in a consistent state by calling + * {@link MFXIconWrapper#updateChildren()} + * + * @see #stop(MFXIconWrapper) + */ + public static class IconAnimation { + private final Animation animation; + private final MFXFontIcon oldIcon; + private final MFXFontIcon newIcon; + + public IconAnimation(MFXIconWrapper wrapper, Animation animation, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + this.animation = animation; + this.oldIcon = oldIcon; + this.newIcon = newIcon; + Animations.onStopped(animation, () -> wrapper.removeChild(oldIcon), true); + } + + /** + * Delegate for {@link Animation#play()}. + */ + public IconAnimation play() { + if (animation != null) animation.play(); + return this; + } + + /** + * This method is automatically invoked by {@link MFXIconWrapper#iconProperty()} when it changes, if another + * animation is playing. + *

+ * If the animation is stopped before its end this method makes sure that the {@link MFXIconWrapper} is in a + * consistent state. In particular two things need to be done: + *

1) One thing is to ensure the children list contains the right nodes, it's enough to call + * {@link MFXIconWrapper#updateChildren()} + *

2) The other is not responsibility of this method but rather the animation. When their state transitions + * to {@link Animation.Status#STOPPED}, they should restore changes that otherwise may leave the {@link MFXIconWrapper} + * and its children in an inconsistent state. More on the difference between 'stopped' and 'finished' here: + * {@link Animations#onStatus(Animation, Animation.Status, Runnable, boolean)}. + *

An example for point 2 may be an animation that scales some content, say from 1.0 to 0.0. If for whatever + * reason (like the start of a new animation) the animation is stopped, it's needed to reset the changes, so set + * the scale back to 1.0. + *

+ * The automatic call of this method is to ensure no strange effects occur if multiple animations are started in + * rapid succession. For this reason, it's also recommended to keep animations as short as possible. For instance + * most of the predefined animations in {@link AnimationPresets} last 200ms. + */ + public void stop(MFXIconWrapper wrapper) { + if (!Animations.isStopped(animation)) { + animation.stop(); + wrapper.removeChild(oldIcon); + wrapper.updateChildren(); + } + } + + public Animation getAnimation() { + return animation; + } + + public MFXFontIcon getOldIcon() { + return oldIcon; + } + + public MFXFontIcon getNewIcon() { + return newIcon; + } + } + + /** + * Enumeration that allows building a series of predefined animations to be used with + * {@link MFXIconWrapper#setAnimationProvider(AnimationPresets)}. + */ + public enum AnimationPresets { + /** + * This animation fades out the old/current icon and fades in the new icon. + */ + FADE { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + newIcon.setOpacity(0.0); + newIcon.setVisible(true); + Duration d = M3Motion.SHORT4; + Interpolator i = M3Motion.STANDARD; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) sb.add(AnimationFactory.FADE_OUT.build(oldIcon, d, i)); + sb.add(AnimationFactory.FADE_IN.build(newIcon, d, i)); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides up the old/current icon and then down the new icon. + */ + SLIDE_UP { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_TOP.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_TOP.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides down the old/current icon and then up the new icon. + */ + SLIDE_BOTTOM { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_BOTTOM.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_BOTTOM.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides down both the old/current and new icons. + */ + SLIDE_BOTTOM_UP { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_BOTTOM.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_TOP.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides down both the old/current and new icons. + */ + SLIDE_UP_BOTTOM { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_TOP.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_BOTTOM.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides right the old/current icon and then left the new icon. + */ + SLIDE_RIGHT { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_RIGHT.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_RIGHT.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides left the old/current icon and then right the new icon. + */ + SLIDE_LEFT { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_LEFT.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_LEFT.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides right both the old/current and new icons + */ + SLIDE_RIGHT_LEFT { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_RIGHT.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_RIGHT.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation slides left both the old/current and new icons + */ + SLIDE_LEFT_RIGHT { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + clip(wrapper); + Duration d = M3Motion.MEDIUM1; + SequentialBuilder sb = new SequentialBuilder(); + if (oldIcon != null) + sb.add(AnimationFactory.SLIDE_OUT_LEFT.build(oldIcon, d, M3Motion.EMPHASIZED_ACCELERATE)); + Timeline t = AnimationFactory.SLIDE_IN_LEFT.build(newIcon, d, M3Motion.EMPHASIZED_DECELERATE); + t.getKeyFrames().add(KeyFrames.of(0, e -> newIcon.setVisible(true))); + sb.add(t); + Animations.onStopped(sb.getAnimation(), () -> { + if (!wrapper.isRound()) wrapper.setClip(null); + }, true); + return new IconAnimation(wrapper, sb.getAnimation(), oldIcon, newIcon); + } + }, + + /** + * This animation is similar to the one seen in standard FABs. It animates the scale properties of the + * {@link MFXIconWrapper} as well as the opacity of the two icons. It is composed of two phases: the first animates + * out the old/current icon, the second animates in the new one. + *

+ * A thing worth mentioning is that the animation scales the {@link MFXIconWrapper} directly because for some reason + * scaling the two icons results in a wobbly layout. + */ + SCALE { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + newIcon.setOpacity(0.0); + newIcon.setVisible(true); + Duration d = M3Motion.SHORT4; + Interpolator downCurve = M3Motion.EMPHASIZED_ACCELERATE; + Interpolator upCurve = M3Motion.EMPHASIZED_DECELERATE; + Animation animation = SequentialBuilder.build() + .add(TimelineBuilder.build() + .add(KeyFrames.of(d, oldIcon.opacityProperty(), 0.0, downCurve)) + .add(KeyFrames.of(d, wrapper.scaleXProperty(), 0.0, downCurve)) + .add(KeyFrames.of(d, wrapper.scaleYProperty(), 0.0, downCurve)) + .getAnimation() + ) + .add(TimelineBuilder.build() + .add(KeyFrames.of(d, newIcon.opacityProperty(), 1.0, upCurve)) + .add(KeyFrames.of(d, wrapper.scaleXProperty(), 1.0, upCurve)) + .add(KeyFrames.of(d, wrapper.scaleYProperty(), 1.0, upCurve)) + .getAnimation() + ) + .getAnimation(); + Animations.onStopped(animation, () -> { + newIcon.setOpacity(1.0); + wrapper.setScaleX(1.0); + wrapper.setScaleY(1.0); + }, true); + return new IconAnimation(wrapper, animation, oldIcon, newIcon); + } + }, + + /** + * This is animation in quite particular and looks good in very few occasions. It uses two Rectangle clips to + * hide the old/current icon and at the same time show the new icon. + *

+ * The old icon's clip is set to have the width and height of the old icon, while the new one's clip is set to have + * a width of 0 and the height of the new icon. The animation sets the first's width progressively to 0, while + * the other progressively to the new icon's width. + *

+ * Note that in order to get the new icon's width in the wrapper, it's necessary to force a CSS pass by calling + * {@link MFXIconWrapper#applyCss()}. + */ + CLIP { + @Override + public IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon) { + TimelineBuilder aBuilder = new TimelineBuilder(); + Duration d = M3Motion.LONG1; + Interpolator i = M3Motion.EMPHASIZED; + double wh = wrapper.getHeight(); + + if (oldIcon != null) { + Rectangle oClip = new Rectangle(oldIcon.prefWidth(-1), oldIcon.prefHeight(-1)); + oClip.setLayoutY(-oClip.getHeight()); + oldIcon.setClip(oClip); + aBuilder.add(KeyFrames.of(d, oClip.widthProperty(), 0.0, i)); + } + + wrapper.applyCss(); + double nSize = newIcon.prefWidth(-1); + Rectangle nClip = new Rectangle(0, wh); + nClip.setLayoutY(-wh); + newIcon.setClip(nClip); + newIcon.setVisible(true); + aBuilder.add(KeyFrames.of(d, nClip.widthProperty(), nSize, i)); + Animations.onStopped(aBuilder.getAnimation(), () -> { + if (oldIcon != null) oldIcon.setClip(null); + newIcon.setClip(null); + }, true); + return new IconAnimation(wrapper, aBuilder.getAnimation(), oldIcon, newIcon); + } + }; + + public abstract IconAnimation animate(MFXIconWrapper wrapper, MFXFontIcon oldIcon, MFXFontIcon newIcon); + + /** + * Used by the slide animations to clip the {@link MFXIconWrapper} so that icons that go outside its bounds are + * hidden. The animations automatically remove it once they stop/end. + */ + protected void clip(MFXIconWrapper wrapper) { + if (wrapper.isRound()) return; + Rectangle r = new Rectangle(); + r.widthProperty().bind(wrapper.widthProperty()); + r.heightProperty().bind(wrapper.heightProperty()); + wrapper.setClip(r); + } + } } diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeBrands.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeBrands.java index 9a2f5951..42d4c970 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeBrands.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeBrands.java @@ -19,6 +19,8 @@ package io.github.palexdev.mfxresources.fonts.fontawesome; import io.github.palexdev.mfxresources.fonts.IconDescriptor; +import io.github.palexdev.mfxresources.fonts.MFXFontIcon; +import io.github.palexdev.mfxresources.utils.EnumUtils; import java.util.Arrays; import java.util.Map; @@ -520,6 +522,21 @@ public Map getCache() { return cache(); } + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration + */ + public static MFXFontIcon random() { + FontAwesomeBrands desc = EnumUtils.randomEnum(FontAwesomeBrands.class); + return new MFXFontIcon(desc); + } + + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration and the given size + */ + public static MFXFontIcon random(double size) { + return random().setSize(size); + } + /** * Converts the given icon description/name to its corresponding unicode character. * diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeRegular.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeRegular.java index 67503950..3a36a87f 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeRegular.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeRegular.java @@ -19,6 +19,9 @@ package io.github.palexdev.mfxresources.fonts.fontawesome; import io.github.palexdev.mfxresources.fonts.IconDescriptor; +import io.github.palexdev.mfxresources.fonts.MFXFontIcon; +import io.github.palexdev.mfxresources.utils.EnumUtils; +import javafx.scene.paint.Color; import java.util.Arrays; import java.util.Map; @@ -218,6 +221,28 @@ public Map getCache() { return cache(); } + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration + */ + public static MFXFontIcon random() { + FontAwesomeRegular desc = EnumUtils.randomEnum(FontAwesomeRegular.class); + return new MFXFontIcon(desc); + } + + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration and the given size + */ + public static MFXFontIcon random(double size) { + return random().setSize(size); + } + + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration, the given color and size + */ + public static MFXFontIcon random(Color color, double size) { + return random().setColor(color).setSize(size); + } + /** * Converts the given icon description/name to its corresponding unicode character. * diff --git a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeSolid.java b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeSolid.java index acece743..7b3835e6 100644 --- a/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeSolid.java +++ b/modules/resources/src/main/java/io/github/palexdev/mfxresources/fonts/fontawesome/FontAwesomeSolid.java @@ -19,6 +19,9 @@ package io.github.palexdev.mfxresources.fonts.fontawesome; import io.github.palexdev.mfxresources.fonts.IconDescriptor; +import io.github.palexdev.mfxresources.fonts.MFXFontIcon; +import io.github.palexdev.mfxresources.utils.EnumUtils; +import javafx.scene.paint.Color; import java.util.Arrays; import java.util.Map; @@ -1445,6 +1448,28 @@ public Map getCache() { return cache(); } + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration + */ + public static MFXFontIcon random() { + FontAwesomeSolid desc = EnumUtils.randomEnum(FontAwesomeSolid.class); + return new MFXFontIcon(desc); + } + + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration and the given size + */ + public static MFXFontIcon random(double size) { + return random().setSize(size); + } + + /** + * @return a new {@link MFXFontIcon} with a random {@link IconDescriptor} from this enumeration, the given color and size + */ + public static MFXFontIcon random(Color color, double size) { + return random().setColor(color).setSize(size); + } + /** * Converts the given icon description/name to its corresponding unicode character. * diff --git a/modules/resources/src/test/java/app/IconAnimation.java b/modules/resources/src/test/java/app/IconAnimation.java new file mode 100644 index 00000000..d7622199 --- /dev/null +++ b/modules/resources/src/test/java/app/IconAnimation.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 Parisi Alessandro - alessandro.parisi406@gmail.com + * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX) + * + * MaterialFX is free software: you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public License + * as published by the Free Software Foundation; either version 3 of the License, + * or (at your option) any later version. + * + * MaterialFX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with MaterialFX. If not, see . + */ + +package app; + +import io.github.palexdev.mfxresources.fonts.MFXFontIcon; +import io.github.palexdev.mfxresources.fonts.MFXIconWrapper; +import io.github.palexdev.mfxresources.fonts.fontawesome.FontAwesomeSolid; +import javafx.application.Application; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.layout.Border; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; +import org.scenicview.ScenicView; + +import java.util.Base64; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class IconAnimation extends Application { + + @Override + public void start(Stage primaryStage) { + VBox pane = new VBox(20); + pane.setAlignment(Pos.TOP_CENTER); + pane.setPadding(new Insets(20)); + + MFXFontIcon startIcon = new MFXFontIcon(FontAwesomeSolid.CHECK); + MFXIconWrapper wrapper = new MFXIconWrapper(startIcon, 64) + .setAnimated(true) + .setAnimationProvider(MFXIconWrapper.AnimationPresets.CLIP) + .enableRippleGenerator(true); + wrapper.setBorder(Border.stroke(Color.RED)); + //wrapper.setBackground(Background.fill(Color.PURPLE)); + + String css = """ + .mfx-icon-wrapper .mfx-ripple-generator { + -mfx-ripple-color: lightgrey; + -mfx-ripple-radius: 64px; + } + + .mfx-icon-wrapper .mfx-font-icon { + -mfx-size: 64px; + } + """; + String data = "data:base64," + new String(Base64.getEncoder().encode(css.getBytes(UTF_8)), UTF_8); + wrapper.getStylesheets().add(data); + + Button b1 = new Button("To Check"); + b1.setOnAction(e -> wrapper.setIcon(FontAwesomeSolid.CHECK)); + Button b2 = new Button("To Minus"); + b2.setOnAction(e -> wrapper.setIcon(FontAwesomeSolid.MINUS)); + Button b3 = new Button("To X"); + b3.setOnAction(e -> wrapper.setIcon(FontAwesomeSolid.XMARK)); + HBox box = new HBox(30, b1, b2, b3); + box.setAlignment(Pos.CENTER); + + pane.getChildren().addAll(wrapper, box); + Scene scene = new Scene(pane, 800, 600); + primaryStage.setScene(scene); + primaryStage.show(); + ScenicView.show(scene); + } +} diff --git a/modules/resources/src/test/java/app/IconWrapperTest.java b/modules/resources/src/test/java/app/IconWrapperTest.java new file mode 100644 index 00000000..8a8d9d58 --- /dev/null +++ b/modules/resources/src/test/java/app/IconWrapperTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 Parisi Alessandro - alessandro.parisi406@gmail.com + * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX) + * + * MaterialFX is free software: you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public License + * as published by the Free Software Foundation; either version 3 of the License, + * or (at your option) any later version. + * + * MaterialFX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with MaterialFX. If not, see . + */ + +package app; + +import io.github.palexdev.mfxeffects.ripple.MFXRippleGenerator; +import io.github.palexdev.mfxresources.fonts.MFXFontIcon; +import io.github.palexdev.mfxresources.fonts.MFXIconWrapper; +import io.github.palexdev.mfxresources.fonts.fontawesome.FontAwesomeSolid; +import javafx.application.Application; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +public class IconWrapperTest extends Application { + + @Override + public void start(Stage primaryStage) { + StackPane pane = new StackPane(); + + // Set up like this, the ripple is in front of icon + // But the set view order should fix it + // Expect the effect behind the icon + MFXIconWrapper icon = new MFXIconWrapper(FontAwesomeSolid.random(64), 128) + .enableRippleGenerator(true) + .makeRound(true); + + // Check + ObservableList children = icon.getChildren(); + assert children.get(0) instanceof MFXFontIcon; + assert children.get(1) instanceof MFXRippleGenerator; + + pane.getChildren().add(icon); + Scene scene = new Scene(pane, 400, 400); + primaryStage.setScene(scene); + primaryStage.show(); + //ScenicView.show(scene); + } +} diff --git a/modules/resources/src/test/java/app/Launcher.java b/modules/resources/src/test/java/app/ResourcesLauncher.java similarity index 91% rename from modules/resources/src/test/java/app/Launcher.java rename to modules/resources/src/test/java/app/ResourcesLauncher.java index e84db7bd..a9fe48db 100644 --- a/modules/resources/src/test/java/app/Launcher.java +++ b/modules/resources/src/test/java/app/ResourcesLauncher.java @@ -20,9 +20,9 @@ import javafx.application.Application; -public class Launcher { +public class ResourcesLauncher { public static void main(String[] args) { - Application.launch(IconsApp.class, args); + Application.launch(IconAnimation.class, args); } }