darklaf icon indicating copy to clipboard operation
darklaf copied to clipboard

Animated icons

Open Z3R0x24 opened this issue 2 years ago • 17 comments

General description First of all, I don't know if this was already implemented somehow, but I couldn't find anything, maybe I'm just blind. But I dug through the code of both darklaf and svgSalamander and found that it was actually possible to animate it on mouse hover, but it was kinda difficult to get it working. So, my question is if it'd be possible to include it somehow so it's easier to do? Like a property or something that determines if the component will animate the icon or not when hovered. Direct rendering mode needs to be true in the DarkSVGIcon for this to work, so I assume it would cause a slight performance penalty since it's sacrificing the cache, but I figured that wasn't necessarily a problem if direct rendering mode is enabled only when animations were enabled.

This suggestion is:

  • [x] Visual
  • [x] Behavioral

For visual suggestions This is how it looks while running. I also tested changing theme and theme colors and it didn't seem to have any issues with that. ezgif-7-37d3e7791646

For behavioral suggestions The icon animates when the mouse hovers the component. The animation is given in the SVG file, of course.

Z3R0x24 avatar Sep 06 '21 12:09 Z3R0x24

I have to say until now so haven’t really given much thought about animated svg icons (which is why you haven’t found any built-in way to achieve it) but I'm definitely open to it.

Would you mind sharing the code you used to make this working?

weisJ avatar Sep 06 '21 14:09 weisJ

Sure, I did a little example with a button using a MouseListener and a timer. FPS is also adjustable changing the corresponding value. Icon is loaded as usual, and the listener remembers the time when the animation stopped so it won't reset and continue from where it left off instead just like in my GIF up there. Edit: I totally forgot the call to invokeLater() on that button repaint, perhaps a Swing Timer would be more suitable for this?

button.addMouseListener(new MouseAdapter() {
            java.util.Timer timer;
            double time;
            int fps = 60;

            @Override
            public void mouseEntered(MouseEvent e) {
                Icon icon = button.getIcon();

                if (icon instanceof DarkSVGIcon) {
                    DarkSVGIcon darkSVGIcon = (DarkSVGIcon) icon;
                    darkSVGIcon.setDirectRenderingMode(true);
                    SVGUniverse universe = darkSVGIcon.getSVGIcon().getSvgUniverse();
                    long start = System.currentTimeMillis();
                    double offset = time;
                    timer = new java.util.Timer();

                    timer.schedule(new TimerTask() {
                        @Override
                        public void run() {
                            try {
                                time = offset + (System.currentTimeMillis() - start)/1000d;
                                universe.setCurTime(time);
                                universe.updateTime();
                                button.repaint();
                            } catch (SVGException ex) {
                                ex.printStackTrace();
                            }
                        }
                    }, 0, 1000/fps);
                }
            }

            @Override
            public void mouseExited(MouseEvent e) {
                Icon icon = button.getIcon();

                if (icon instanceof DarkSVGIcon) {
                    SVGUniverse universe = ((DarkSVGIcon) icon).getSVGIcon().getSvgUniverse();
                    time = universe.getCurTime();
                }

                if (timer != null)
                    timer.cancel();
            }
        });

Z3R0x24 avatar Sep 06 '21 15:09 Z3R0x24

I just thought of something else. Remembering the animation time is good for animations that loop indefinitely and freeze like the one in my example, but not for those that play once or a limited number of times. Maybe that should also be a flag so in mouseExited:

if (rememberTime)
    time = universe.getCurTime();
else
    time = 0;

Edit: I'll try to get a more elegant solution that's able to handle this, and can be applied more easily to components and post it here.

Z3R0x24 avatar Sep 06 '21 21:09 Z3R0x24

Okay, I got it working and I think it's good enough, now it can also play the animation backwards on mouse exit (I'll include an example GIF below). Should be easy enough to implement in any given component that uses an SVG icon as an image source. Now, there's only a little problem: In order for the reverse to work, it needs to know the full animation time and whether or not it's an infinite loop. I hard-coded it for the time being, but it could either be received by the function as an argument or extracted from the SVG (however, extracting the animation duration from the SVG could maybe be a little bit tricky with chained animations and that stuff?). For whatever reason sometimes it flickers, but it's uncommon and lasts so little that I decided not to worry about it.

Here's the full example code (GUIUtils.loadIcon() just returns an Icon using the icon loader):

public static void main(String[] args) {
        LafManager.install(new OneDarkTheme());

        DarkSVGIcon icon = (DarkSVGIcon) GUIUtils.loadIcon("heart.svg", 200, 200);

        JPanel animatedPanel = new JPanel() {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g);

                int size = Math.min(getWidth(), getHeight());

                int x = (getWidth() - size)/2, y = (getHeight() - size)/2;

                g.drawImage(icon.createImage(size, size), x, y, null);
            }
        };

        animatedPanel.setPreferredSize(new Dimension(200, 200));
        setComponentAnimated(() -> icon, animatedPanel, false, true, 0.5);

        testComponent(animatedPanel);
    }

    private static void testComponent(Component component) {
        JFrame frame = new JFrame();
        JPanel panel = new JPanel(new BorderLayout());
        panel.add(component, BorderLayout.CENTER);
        frame.setContentPane(panel);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    static int fps = 60;

    private static MouseListener createAnimationListener(Supplier<Icon> supplier, JComponent component,
                                                         boolean rememberTime, boolean reverseOnExit, double reverseDelay) {
        return new MouseAdapter() {
            private java.util.Timer timer;
            private DarkSVGIcon svgIcon;

            @Override
            public void mouseEntered(MouseEvent e) {
                Icon icon = supplier.get();

                if (icon instanceof DarkSVGIcon) {
                    if (timer != null)
                        timer.cancel();

                    svgIcon = (DarkSVGIcon) icon;
                    svgIcon.setDirectRenderingMode(true);
                    SVGUniverse universe = svgIcon.getSVGIcon().getSvgUniverse();
                    long start = System.currentTimeMillis();
                    double offset = universe.getCurTime();
                    timer = new java.util.Timer();

                    timer.schedule(new TimerTask() {
                        @Override
                        public void run() {
                            try {
                                universe.setCurTime(offset + (System.currentTimeMillis() - start)/1000d);
                                universe.updateTime();
                                SwingUtilities.invokeLater(component::repaint);
                            } catch (SVGException ex) {
                                ex.printStackTrace();
                            }
                        }
                    }, 0, 1000/fps);
                }
            }

            @Override
            public void mouseExited(MouseEvent e) {
                if (timer != null)
                    timer.cancel();

                if (!rememberTime) {
                    SVGUniverse universe = svgIcon.getSVGIcon().getSvgUniverse();

                    if (reverseOnExit) {
                        double animTime = 2.5;
                        boolean infiniteLoop = false;
                        double delta = infiniteLoop ? universe.getCurTime()%animTime : Math.min(universe.getCurTime(), animTime + reverseDelay);
                        long start = System.currentTimeMillis();
                        timer = new java.util.Timer();

                        timer.schedule(new TimerTask() {
                            @Override
                            public void run() {
                                double newTime;
                                newTime = delta - (System.currentTimeMillis() - start)/1000d;

                                if (newTime < 0) {
                                    newTime = 0;
                                    timer.cancel();
                                }

                                universe.setCurTime(newTime);
                                try {
                                    universe.updateTime();
                                    SwingUtilities.invokeLater(component::repaint);
                                } catch (SVGException ex) {
                                    ex.printStackTrace();
                                }
                            }
                        }, 0, 1000/fps);
                    } else {

                        universe.setCurTime(0);

                        try {
                            universe.updateTime();
                        } catch (SVGException ex) {
                            ex.printStackTrace();
                        }
                        component.repaint();
                    }
                }
            }
        };
    }

    public static void setComponentAnimated(Supplier<Icon> iconSupplier, JComponent component, boolean rememberTime,
                                            boolean reverseOnExit, double reverseDelay) {
        component.addMouseListener(createAnimationListener(iconSupplier, component, rememberTime, reverseOnExit, reverseDelay));
    }

The SVG I used in the example:

<svg viewBox="0 0 530 530" xmlns="http://www.w3.org/2000/svg">
    <path transform="translate(9, 37)" stroke="#31E8FF" stroke-width="10" fill="#31E8FF" fill-opacity="0" stroke-dasharray="1570" stroke-dashoffset="0" d="m471.382812 44.578125c-26.503906-28.746094-62.871093-44.578125-102.410156-44.578125-29.554687 0-56.621094 9.34375-80.449218 27.769531-12.023438 9.300781-22.917969 20.679688-32.523438 33.960938-9.601562-13.277344-20.5-24.660157-32.527344-33.960938-23.824218-18.425781-50.890625-27.769531-80.445312-27.769531-39.539063 0-75.910156 15.832031-102.414063 44.578125-26.1875 28.410156-40.613281 67.222656-40.613281 109.292969 0 43.300781 16.136719 82.9375 50.78125 124.742187 30.992188 37.394531 75.535156 75.355469 127.117188 119.3125 17.613281 15.011719 37.578124 32.027344 58.308593 50.152344 5.476563 4.796875 12.503907 7.4375 19.792969 7.4375 7.285156 0 14.316406-2.640625 19.785156-7.429687 20.730469-18.128907 40.707032-35.152344 58.328125-50.171876 51.574219-43.949218 96.117188-81.90625 127.109375-119.304687 34.644532-41.800781 50.777344-81.4375 50.777344-124.742187 0-42.066407-14.425781-80.878907-40.617188-109.289063zm0 0">
        <animate attributeName="stroke-dashoffset" from="0" to="1570" dur="2s" fill="freeze"/>
        <animate attributeName="fill-opacity" from="0" to="1" dur="0.5s" begin="2s" fill="freeze"/>
    </path>
</svg>

And the example GIF: heart

The icon did flicker during the demo, but due to the GIF's framerate it doesn't show. I'll try to fix the flickering and update the code if I manage to find a solution.

Z3R0x24 avatar Sep 07 '21 00:09 Z3R0x24

Sorry I haven’t gotten around looking into this, but your progress looks great! You might want to look into the Animator class for the time handling. It supports pausing (suspending) and reverting the timer.

As for the issue with the need for hard-coding the animation duration I don’t really want to give a convenience api just now (it’s probably possible to compute it from the svg dom). I plan on replacing the underlying svg implementation in the future and don’t want to implement something which I may not be able to support then.

weisJ avatar Sep 07 '21 16:09 weisJ

I'll look into it and see what I can do. Also, I'll move the duration to the function params so it shouldn't be a problem.

Z3R0x24 avatar Sep 07 '21 20:09 Z3R0x24

Okay, got an update. I found the Animator class difficult to implement for this specific case because of the way it handles reversing which messed the icon timing (say you tried to reverse the animation at fraction 0.2, it then started playing as if it was on 0.8). I don't discard the possibility that I didn't quite get how to use it properly but at that point I decided that it'd just be faster to create my own simpler implementation based on that one. This one operates based on framerate and fraction delta instead of times, so reversing it at any point is extremely easy and it still looks good on the animation. Anyway, I got it working and I think I found the reason of the flickering: Whenever I set the animator duration to the same duration the full animation has on the SVG, the ~~image~~ (← that one's on me, fixed it in the code at the bottom) fill disappears on the final frame, if I go beyond, it comes back, if I stay behind, it stays. So my guess is that it's a bug related to svgSalamander rather than the animator itself. I'll include some GIFs to exemplify this.

SVG file with 2s stroke + 0.5s opacity animations (2.5s total) with animator set to exactly 2.5s duration:

Edit: changed the GIF to show the actual bug, stroke animation is reversed so it can be appreciated properly, the other two cases behave the same, so I won't replace the GIFs. Maybe svgS ignores the freeze property on the last animation assuming it will reset?

Yet another edit: Did some testing. Apparently, svgS ignores the freeze property on the last frame of all animations and paints them back in the next one. That's bad news for SVG files with multiple freeze animations, you might get flickering if the animator lands at the right time. The good news are that since my animator is based on fraction delta, as long as you don't change the framerate on runtime, it should be consistent across all runs of the animation, so if you can tweak the timing a little until you don't see any flickering anymore, it should mean that you effectively got rid of it in your app. bug

With animator set to 2.6s duration (going beyond animation time with the animation fill set to freeze in the file): nobug

And finally with the animator set to 2.5s, but adding a useless (opacity from 1 to 1) extra animation on the file that takes an extra 0.1s (so 2.6s total), essentially staying behind time limit: nobug2

My theory is that the flickering seen in the previous implementations I posted (which were based on timers) ocurred when the time landed at exactly 2.5s for one frame, where the animation was supposed to end, and then moved a little bit further due to inexactitude. I also experienced this when I was testing the Animator class along with the SVG.

So it doesn't look like I can do anything to solve the problem itself, but it seems to be easy enough to work around.

That aside, I believe I got everything properly set up with the animator, but I haven't tested all features yet, so I'm gonna do that now and make sure everything's working. After that, I'll post the code of both (the CustomAnimator and SVGAnimator) classes I made.

Z3R0x24 avatar Sep 08 '21 11:09 Z3R0x24

All right, tested a few things and debugged a couple other and everything seems to be in order, I'm posting the code with the features below, plus another GIF where I'll try to do my best to exemplify most things.

  • The animation can be paused and resumed easily.
  • Can be attached to several components so they will all act like triggers (and displays, assuming the icon is painted on them).
  • Similarly, components can be detached to stop acting as triggers and stop being udpated.
  • It can be used with the included handlers for hovering or clicking or using the play() pause() stop() forward() and backward() methods to implement them with custom logic. Calling the latter two will instantly adjust the direction without having to manually pause it and set it. Same goes for the revert() method, but that one will respect the current state (paused/stopped or running) while the others will start playing the animation.
  • Icon can be changed at any point during the animation (but keep in mind it has to have the same animation duration in order to properly fit in the timing).
  • Interpolators are a thing in this implementation too, so easing functions can be applied easily.
  • Repeating animations that are running backwards can be set to either loop indefinitely or only to the starting point.

As for the "downsides", well, it will override the repeating on the SVG, so, for SVGs with a set amount of repetitions, you will have to calculate the total time yourself and set it as a non-repeating animator (however this also means you can force repeating but considering that's just a word in a file, I'd say it's pretty useless?). Also you'll have to input the total duration into the function yourself, and add a useless extra animation to avoid the flickering (going beyond might have a frame landing on that spot, so I'd say staying behind is a better option).

I've put together a little example using two components attached to the same animator, with a null trigger, using an action listener and a boolean to play/pause instead, and another button that can easily revert the animation: demo

Code for both classes I created complete with some documentation to explain:

The CustomAnimator class is the base animator on which SVGAnimator is dependant.

import com.github.weisj.darklaf.graphics.DefaultInterpolator;
import com.github.weisj.darklaf.graphics.Interpolator;

import javax.swing.*;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Animator utility. It's based on an application-wide target FPS and fraction delta. Essentially, each update adds or
 * subtracts delta from the fraction instead of calculating it based on time elapsed.
 */
public abstract class CustomAnimator {
    private static final ScheduledExecutorService scheduler = createScheduler();

    private static int targetFPS = 60;

    protected int cycleDuration;
    protected boolean repeats;
    protected int delay;
    protected volatile Interpolator interpolator;
    protected volatile boolean reverse = false;

    protected ScheduledFuture<?> ticker;

    protected volatile float fraction;
    protected float fractionDelta;

    protected boolean running = false;
    protected boolean stopLater = false;
    protected volatile boolean backToStart = false;
    protected volatile boolean keepEndState = false;

    public CustomAnimator(int cycleDuration, int delay) {
        this(cycleDuration, delay, false);
    }

    public CustomAnimator(int cycleDuration, int delay, boolean repeats) {
        this(cycleDuration, delay, repeats, DefaultInterpolator.LINEAR);
    }

    public CustomAnimator(int cycleDuration, int delay, boolean repeats, Interpolator interpolator) {
        this.cycleDuration = cycleDuration;
        this.delay = delay;
        this.repeats = repeats;
        this.interpolator = interpolator;
    }

    public void play() {
        play(true);
    }

    /**
     * Plays the animation in whichever direction it was set before calling this method.
     * @param skipDelay If true, will skip the initial delay.
     */
    public void play(boolean skipDelay) {
        if (ticker == null) {

            running = true;

            int delayTime = skipDelay ? 0 : delay;

            fractionDelta = 1f/(targetFPS * cycleDuration/1000f);

            ticker = scheduler.scheduleWithFixedDelay(new Runnable() {
                final AtomicBoolean updateScheduled = new AtomicBoolean(false);

                @Override
                public void run() {
                    if (!updateScheduled.get()) {
                        updateScheduled.set(false);
                        tick();

                        final float frameFraction = fraction;

                        SwingUtilities.invokeLater(()-> {
                            updateScheduled.set(true);
                            paint(interpolator.interpolate(frameFraction));
                            updateScheduled.set(false);
                        });
                    }
                }
            }, delayTime, 1000/targetFPS, TimeUnit.MILLISECONDS);
        }
    }

    public void forward() {
        forward(true);
    }

    /**
     * Plays the animation ensuring it will go forward.
     */
    public void forward(boolean skipDelay) {
        if (reverse || !running) {
            pause();
            reverse = false;
            play(skipDelay);
        }
    }

    public void backward() {
        backward(true);
    }

    /**
     * Plays the animation ensuring it will go backward.
     */
    public void backward(boolean skipDelay) {
        if (!reverse || !running) {
            pause();
            reverse = true;
            play(skipDelay);
        }
    }

    /**
     * Pauses the animation. Can be resumed.
     */
    public void pause() {
        if (ticker != null) {
            ticker.cancel(false);
            ticker = null;
            running = false;
        }
    }

    /**
     * Stops the animation setting it back to the beginning and calls {@code onAnimationFinished()}.
     * @see CustomAnimator#cancel()
     */
    public void stop() {
        cancel();
        onAnimationFinished();
    }

    /**
     * Stops the animation without calling {@code onAnimationFinished()}.
     * @see CustomAnimator#stop()
     */
    public void cancel() {
        pause();
        fraction = 0;
        SwingUtilities.invokeLater(() -> paint(0));
    }

    // Fraction update
    protected void tick() {
        final float oldFraction = fraction;

        if (reverse)
            fraction = oldFraction - fractionDelta;
        else
            fraction = oldFraction + fractionDelta;

        if (repeats) {
            if (backToStart && fraction <= 0) {
                stop();
            }
            loop();
        } else if (fraction > 1) {
            fraction = 1;

            if (keepEndState)
                pause();
            else
                stop();
        } else if (fraction < 0) {
            stop();
        }
    }

    // Loops the fraction
    private void loop() {
        final float oldFraction = fraction;

        fraction = oldFraction % 1;

        if (fraction < 0)
            fraction = 1;
    }

    public abstract void paint(float fraction);

    protected void onAnimationFinished(){}

    public void setDelay(int delay) {
        this.delay = delay;
    }

    public void setInterpolator(Interpolator interpolator) {
        this.interpolator = interpolator;
    }

    public Interpolator getInterpolator() {
        return interpolator;
    }

    public int getCycleDuration() {
        return cycleDuration;
    }

    public void setRepeats(boolean repeats) {
        this.repeats = repeats;
    }

    public static int getGlobalTargetFPS() {
        return targetFPS;
    }

    public static void setGlobalTargetFPS(int targetFPS) {
        CustomAnimator.targetFPS = targetFPS;
    }

    public boolean isGoingBackward() {
        return reverse;
    }

    public boolean isGoingForward() {
        return reverse;
    }

    public void setReverse(boolean reverse) {
        if (running)
            throw new IllegalStateException("Can't manually change direction while running");

        this.reverse = reverse;
    }

    /**
     * Reverts the current direction while keeping the running state. That is, it won't start the animation it was
     * paused/stopped, and it won't stop it if it was running.
     */
    public void revert() {
        if (running) {
            if (reverse)
                forward();
            else
                backward();
        } else
            setReverse(!reverse);
    }

    /**
     * Setting this value to true when repeats are on will indicate the animation that it should run back only to the
     * start instead of looping backwards infinitely.
     */
    public void setBackToStart(boolean backToStart) {
        this.backToStart = backToStart;
    }

    public boolean isRunning() {
        return running;
    }

    //Borrowed this from the Animator class
    private static ScheduledExecutorService createScheduler() {
        ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> {
            final Thread thread = new Thread(r, "Custom Animator Thread");
            thread.setDaemon(true);
            thread.setPriority(Thread.MAX_PRIORITY);
            return thread;
        });
        executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
        executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
        return executor;
    }
}

And the SVGAnimator class that is responsible for the actual SVG magic, I designed it to be super easy to use, but simultaneously open to more specific cases if it's required. Edit: Forgot to mention that I used floats for duration and delay as they are intended to be passed as time in seconds, so you can simply input the same times as the ones in your SVG files. I thought it'd be more convenient that way.

import com.github.weisj.darklaf.icons.DarkSVGIcon;
import com.kitfox.svg.SVGException;
import com.kitfox.svg.SVGUniverse;

import javax.swing.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.HashSet;
import java.util.function.Supplier;

public class SVGAnimator extends CustomAnimator {
    public enum Event {
        onClick, onHover
    }

    // Keep a single instance for easy removal on component detaching
    private final AnimationListener listener;

    private final Supplier<Icon> iconSupplier;
    private DarkSVGIcon darkSVGIcon;
    private volatile SVGUniverse universe;
    private final HashSet<JComponent> attachedTo;

    private Event trigger;
    private boolean reverseOnExit;
    private int mouseButton = MouseEvent.BUTTON1;

    private final float animationDuration;

    public SVGAnimator(DarkSVGIcon icon, float duration, float delay, boolean repeats, Event trigger) {
        this(() -> icon, duration, delay, repeats, trigger);
    }

    // Set a null trigger in order to make it manual (via play(), forward() or backward()) only.
    public SVGAnimator(Supplier<Icon> iconSupplier, float duration, float delay, boolean repeats, Event trigger) {
        super(Math.round(duration*1000), Math.round(delay*1000), repeats);

        this.iconSupplier = iconSupplier;
        attachedTo = new HashSet<>(1); // Most likely used for single components
        listener = new AnimationListener();
        animationDuration = duration;
        this.trigger = trigger;

        updateIcon(false);
    }

    //These two functions only work if the Animator was given a supplier instead of a static icon ======================
    public void updateIcon() {
        updateIcon(true);
    }

    /*I included the option to not turn back to non-direct rendering in case the icon is used somewhere else. I'm not
    completely sure, but I believe darklaf keeps the icons themselves cached, if that's the case, other component
    using the animated icon will suddenly stop being animated. Anyway it doesn't hurt.*/

    /**
     * Retrieves the new icon from the supplier and updates the universe.
     * @param updateRenderingMode If true, the old icon will revert to non-direct rendering mode, enabling the cache.
     */
    public void updateIcon(boolean updateRenderingMode) {
        Icon icon = iconSupplier.get();

        if (icon instanceof DarkSVGIcon) {
            updateIcon0((DarkSVGIcon) icon, updateRenderingMode);
        }
    }
    //==================================================================================================================

    //These two will take an icon and update it directly ===============================================================
    public void updateIcon(Icon icon) {
        updateIcon(icon, true);
    }

    /**
     * Changes the icon for a new one. Supplier is unaffected, so the previous icon can potentially be recovered by
     * calling {@code updateIcon()}
     * @param icon New icon
     * @param updateRenderingMode If true, the old icon will revert to non-direct rendering mode, enabling the cache.
     */
    public void updateIcon(Icon icon, boolean updateRenderingMode) {
        if (icon instanceof DarkSVGIcon)
            updateIcon0((DarkSVGIcon) icon, updateRenderingMode);
    }
    //==================================================================================================================

    private void updateIcon0(DarkSVGIcon icon, boolean updateRenderingMode) {
        if (!icon.equals(darkSVGIcon)) {
            if (updateRenderingMode && darkSVGIcon != null)
                darkSVGIcon.setDirectRenderingMode(false);

            darkSVGIcon = icon;
            darkSVGIcon.setDirectRenderingMode(true);

            universe = new SVGUniverse();
            darkSVGIcon.getSVGIcon().setSvgUniverse(universe);
            System.out.println(universe);
        }
    }

    /**
     * Adds the component to the collection of components to be repainted each frame, also adds the animation listener
     * to the component. Essentially, the component becomes both a trigger and a display (assuming the component paints
     * the icon in some way) for the icon after calling this method.
     * @see SVGAnimator#detachFrom(JComponent)
     * @param c Component
     */
    public void attachTo(JComponent c) {
        synchronized (attachedTo) {
            attachedTo.add(c);
        }

        c.addMouseListener(listener);
    }

    /**
     * The opposite of {@code attachTo()}. Removes the component from the collection and the animation listener from the
     * component, which means the component stops triggering the icon and being repainted. However, if the component
     * still displays the icon, and other component triggers the animation, it will still reflect the changes after
     * repainting.
     * @see SVGAnimator#attachTo(JComponent)
     * @param c Component
     */
    public void detachFrom(JComponent c) {
        synchronized (attachedTo) {
            attachedTo.remove(c);
        }

        c.removeMouseListener(listener);
    }

    /**
     * Sets the mouse button to trigger the animation when the {@code trigger} variable is set to {@code Event.onClick},
     * it has no effect for hovering animations.
     * @see Event
     * @see AnimationListener#mouseClicked(MouseEvent)
     * @param mouseButton Which mouse button will trigger the animation on clicking the component.
     */
    public void setMouseButton(int mouseButton) {
        this.mouseButton = mouseButton;
    }

    /**
     * If the {@code trigger} variable is set to {@code Event.onHover}, determines whether the animation will play
     * backwards on mouse exiting the component.
     * @see Event
     * @see AnimationListener#mouseExited(MouseEvent)
     * @param reverseOnExit Animation reverses on mouse exit if true
     */
    public void setReverseOnExit(boolean reverseOnExit) {
        this.reverseOnExit = reverseOnExit;
        // Indicates that the fraction should be kept at 1 after finishing the animation, so it can be
        // reversed afterwards. No effect on repeating animations.
        keepEndState = reverseOnExit;
        // I should be able to use keepEndState as a reversing indicator too, but I'll keep  the other variable for
        // clarity
    }

    /**
     * Sets the trigger of the animation.
     * @see Event
     * @param trigger A value from the {@code Event} enum
     */
    public void setTrigger(Event trigger) {
        this.trigger = trigger;
    }

    @Override
    public void paint(float fraction) {
        universe.setCurTime(fraction * animationDuration);
        try {
            universe.updateTime();

            synchronized (attachedTo) {
                for (JComponent c: attachedTo)
                    c.repaint();
            }
        } catch (SVGException e) {
            e.printStackTrace();
        }
    }

    private class AnimationListener extends MouseAdapter {
        @Override
        public void mouseEntered(MouseEvent e) {
            if (Event.onHover.equals(trigger)) { //Looks backwards but prevents NullPointerException if trigger is null
                forward();
            }
        }

        @Override
        public void mouseExited(MouseEvent e) {
            if (Event.onHover.equals(trigger)) {
                if (reverseOnExit) {
                    // If the animation is a loop but is set to reverse, it will reverse only to the starting point
                    // instead of redoing all the loops backwards.
                    backToStart = repeats;

                    backward(running);
                } else if (repeats)
                    pause();
                else {
                    stop();
                }
            }
        }

        @Override
        public void mouseClicked(MouseEvent e) {
            if (Event.onClick.equals(trigger) && e.getButton() == mouseButton)
                forward(false);
        }
    }
}

I hope my work can be put to good use. Edit: Syntax highlighting to make it easier to read. Edit 2: Fixed a bug where the icon would not repaint to the proper fraction (because of the delay between the call to invokeLater() and the actual call to the painting method). It should work properly now, I hope. Also, this was provoking the image to disappear last frame, not svgSalamander, however it does have a bug with fills. I'll update that comment with a different GIF to show it. Edit 3: Tweaked the ticking and stoping functions so now stop returns the animation to fraction 0 but the animation will naturally pause at fraction 1 if keepEndState = true. Also, apparently all SVG icons share a single universe, I imagine this is in order to save memory, and likely will go unnoticed if you have a couple animated icons here and there, but it wreaks havoc on the animations if you have several icons running at the same time, completely messing with times; so now SVGAnimator assigns a new universe to the icon on update. Note that for any themed icon that wasn't loaded (and by loaded I mean completely loaded/painted, since they're lazy) you'll have to call IconColorMapper.patchColors(icon) after initializing/updating icon in the animator to get the right colors. Or at least that's the easiest way I found.

Z3R0x24 avatar Sep 08 '21 13:09 Z3R0x24

Thank you very much for your efforts!

weisJ avatar Sep 08 '21 16:09 weisJ

I'm just doing my part to contribute to this amazing library.

Z3R0x24 avatar Sep 08 '21 20:09 Z3R0x24

I have implemented a new version of the Animator, which is more inline with your implementation. For now I probably won't include the SVGAnimator class myself due to the planned change of SVG implementation. However an implementation will make its way into darklaf eventually :^) Until then here is an updated version of your SVGAnimator class, which can be used with the current snapshot version:

import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.HashSet;
import java.util.function.Supplier;

import javax.swing.*;

import com.github.weisj.darklaf.properties.icons.DarkSVGIcon;
import com.kitfox.svg.SVGException;
import com.kitfox.svg.SVGUniverse;

public class SVGAnimator extends Animator {
    public enum Event {
        onClick,
        onHover
    }

    // Keep a single instance for easy removal on component detaching
    private final AnimationListener listener;

    private final Supplier<Icon> iconSupplier;
    private DarkSVGIcon darkSVGIcon;
    private volatile SVGUniverse universe;
    private final HashSet<JComponent> attachedTo;

    private Event trigger;
    private boolean reverseOnExit;
    private int mouseButton = MouseEvent.BUTTON1;

    private final float animationDuration;

    public SVGAnimator(DarkSVGIcon icon, float duration, float delay, RepeatMode repeatMode, Event trigger) {
        this(() -> icon, duration, delay, repeatMode, trigger);
    }

    // Set a null trigger in order to make it manual (via play(), forward() or backward()) only.
    public SVGAnimator(Supplier<Icon> iconSupplier, float durationInSeconds, float delayInSeconds,
            RepeatMode repeatMode, Event trigger) {
        super(Math.round(durationInSeconds * 1000), Math.round(delayInSeconds * 1000), DefaultInterpolator.LINEAR,
                repeatMode);

        this.iconSupplier = iconSupplier;
        attachedTo = new HashSet<>(1); // Most likely used for single components
        listener = new AnimationListener();
        animationDuration = durationInSeconds;
        this.trigger = trigger;

        updateIcon(false);
    }

    // These two functions only work if the Animator was given a supplier instead of a static icon
    // ======================
    public void updateIcon() {
        updateIcon(true);
    }

    /*
     * I included the option to not turn back to non-direct rendering in case the icon is used somewhere
     * else. I'm not completely sure, but I believe darklaf keeps the icons themselves cached, if that's
     * the case, other component using the animated icon will suddenly stop being animated. Anyway it
     * doesn't hurt.
     */

    /**
     * Retrieves the new icon from the supplier and updates the universe.
     *
     * @param updateRenderingMode If true, the old icon will revert to non-direct rendering mode,
     *        enabling the cache.
     */
    public void updateIcon(boolean updateRenderingMode) {
        Icon icon = iconSupplier.get();

        if (icon instanceof DarkSVGIcon) {
            updateIcon0((DarkSVGIcon) icon, updateRenderingMode);
        }
    }
    // ==================================================================================================================

    // These two will take an icon and update it directly
    // ===============================================================
    public void updateIcon(Icon icon) {
        updateIcon(icon, true);
    }

    /**
     * Changes the icon for a new one. Supplier is unaffected, so the previous icon can potentially be
     * recovered by calling {@code updateIcon()}
     *
     * @param icon New icon
     * @param updateRenderingMode If true, the old icon will revert to non-direct rendering mode,
     *        enabling the cache.
     */
    public void updateIcon(Icon icon, boolean updateRenderingMode) {
        if (icon instanceof DarkSVGIcon)
            updateIcon0((DarkSVGIcon) icon, updateRenderingMode);
    }
    // ==================================================================================================================

    private void updateIcon0(DarkSVGIcon icon, boolean updateRenderingMode) {
        if (!icon.equals(darkSVGIcon)) {
            if (updateRenderingMode && darkSVGIcon != null)
                darkSVGIcon.setDirectRenderingMode(false);

            darkSVGIcon = icon;
            darkSVGIcon.setDirectRenderingMode(true);

            universe = new SVGUniverse();
            darkSVGIcon.getSVGIcon().setSvgUniverse(universe);
        }
    }

    /**
     * Adds the component to the collection of components to be repainted each frame, also adds the
     * animation listener to the component. Essentially, the component becomes both a trigger and a
     * display (assuming the component paints the icon in some way) for the icon after calling this
     * method.
     *
     * @see SVGAnimator#detachFrom(JComponent)
     * @param c Component
     */
    public void attachTo(JComponent c) {
        synchronized (attachedTo) {
            attachedTo.add(c);
        }

        c.addMouseListener(listener);
    }

    /**
     * The opposite of {@code attachTo()}. Removes the component from the collection and the animation
     * listener from the component, which means the component stops triggering the icon and being
     * repainted. However, if the component still displays the icon, and other component triggers the
     * animation, it will still reflect the changes after repainting.
     *
     * @see SVGAnimator#attachTo(JComponent)
     * @param c Component
     */
    public void detachFrom(JComponent c) {
        synchronized (attachedTo) {
            attachedTo.remove(c);
        }

        c.removeMouseListener(listener);
    }

    /**
     * Sets the mouse button to trigger the animation when the {@code trigger} variable is set to
     * {@code Event.onClick}, it has no effect for hovering animations.
     *
     * @see Event
     * @see AnimationListener#mouseClicked(MouseEvent)
     * @param mouseButton Which mouse button will trigger the animation on clicking the component.
     */
    public void setMouseButton(int mouseButton) {
        this.mouseButton = mouseButton;
    }

    /**
     * If the {@code trigger} variable is set to {@code Event.onHover}, determines whether the animation
     * will play backwards on mouse exiting the component.
     *
     * @see Event
     * @see AnimationListener#mouseExited(MouseEvent)
     * @param reverseOnExit Animation reverses on mouse exit if true
     */
    public void setReverseOnExit(boolean reverseOnExit) {
        this.reverseOnExit = reverseOnExit;
        // Indicates that the fraction should be kept at 1 after finishing the animation, so it can be
        // reversed afterwards. No effect on repeating animations.
        setRepeatMode(RepeatMode.DO_NOT_REPEAT_FREEZE);
        // I should be able to use keepEndState as a reversing indicator too, but I'll keep the other
        // variable for
        // clarity
    }

    /**
     * Sets the trigger of the animation.
     *
     * @see Event
     * @param trigger A value from the {@code Event} enum
     */
    public void setTrigger(Event trigger) {
        this.trigger = trigger;
    }

    @Override
    protected void paintAnimationFrame(float fraction) {
        universe.setCurTime(fraction * animationDuration);
        try {
            universe.updateTime();

            synchronized (attachedTo) {
                for (JComponent c : attachedTo)
                    c.repaint();
            }
        } catch (SVGException e) {
            e.printStackTrace();
        }
    }

    private class AnimationListener extends MouseAdapter {
        @Override
        public void mouseEntered(MouseEvent e) {
            if (Event.onHover.equals(trigger)) {
                playForwards();
            }
        }

        @Override
        public void mouseExited(MouseEvent e) {
            if (Event.onHover.equals(trigger)) {
                if (reverseOnExit) {
                    // If the animation is a loop but is set to reverse, it will reverse only to the starting point
                    // instead of redoing all the loops backwards.
                    playBackwards(isRunning());
                } else if (repeatMode().isRepeating())
                    pause();
                else {
                    stop();
                }
            }
        }

        @Override
        public void mouseClicked(MouseEvent e) {
            if (Event.onClick.equals(trigger) && e.getButton() == mouseButton) playForwards(false);
        }
    }
}

weisJ avatar Sep 17 '21 23:09 weisJ

That's so nice, thank you! If you don't mind the question, do you think the new SVG implementation will support filters? I've been dying to use some of them with my icons.

Z3R0x24 avatar Sep 17 '21 23:09 Z3R0x24

I absolutely don’t mind the question. Support for filters in the new implementation is definitely planned: https://github.com/weisJ/jsvg

weisJ avatar Sep 18 '21 10:09 weisJ

That's wonderful. I'll totally check it out when I can.

Z3R0x24 avatar Sep 18 '21 11:09 Z3R0x24

Just to give an update for this issue. I am at the point where I can replace all current usage of svgSalamander in darklaf with jsvg. Though I still haven't gotten around implementing animation support, which will probably be an considerate amount work. If you want to accelerate the progress on this issue I would highly appreciate you contributing to jsvg. The same goes for any filters which you want to see implemented :)

weisJ avatar Oct 17 '21 17:10 weisJ

I'd love to contribute, but I'm not sure if I can be of much help. In any case I'll take a look at it later and see if there's anything I can help with.

Z3R0x24 avatar Oct 17 '21 20:10 Z3R0x24

Follow up to my previous comment. Sorry I wasn't able to help before, I've been pretty busy with studies and some other stuff. However, I just had one of those eureka moments and I think I came up with an idea for an animator that will keep all features of the one I posted here (easily reverse the animation, pause it, etc), while also implementing something very important that I skipped (pun absolutely intended): frame-skipping. Can't promise it will be done this week, 'cause I still have some important things left to do, but I think maybe I could get it done this weekend (if not, it'll have to wait another week, got exams).

The basic idea is that it will account for slower (or faster, if it happened, for whatever reason) executions and increase (or decrease) the fraction delta accordingly, so it will catch up. I'd just have to figure out a couple conditions and organize the code properly. Using this 'hybrid' approach makes it easy to reverse the animation by just changing the sign of the delta, just like before, while also avoiding all the hassle of dealing with times. :D

I'm not very familiarized with image rendering, so I doubt I can be of much help with actually drawing the SVGs, but I think at least I can help with the animator to speed up development a little bit. Anyway, let me know what you think and I'll get to it as soon as I can.

Z3R0x24 avatar Nov 17 '21 11:11 Z3R0x24