user5327287
user5327287

Reputation: 410

JButton hover animation in Java Swing

I working on GUI window with Swing and I want to create a navegetion menu. I want to add an animation to the navegetion menu's buttons when hovering on them with the mouse.

Somthing like this:

enter image description here

I can't do it with mouse listener because it just change between colors without animation like so

enter image description here

With rolloving it require gifs and different screens sizes will require different gif sizes.

Is there a way to do it some how throw the code?

Upvotes: 2

Views: 4895

Answers (2)

MadProgrammer
MadProgrammer

Reputation: 347334

First things first, you're actually asking a very difficult and complicated question. Animation done right is not easy and requires a lot state management.

For example, what happens if the user moves the mouse out of the menu item BEFORE it's finished animating? Do you animate from the current state? Do you animate from what was to be the finished state? Do you animate across the full time range or only the remaining time range? How would you control and manage it all!?

You're also asking about animating across color ranges. Color blending is actually not as easy as it might sound. Sure you could argue you're just animating the alpha value, but blending the colors gives you a much greater opportunity to provide a different animation affect, like blending from a transparent green to an opaque blue, cool.

Animation is also not normally linear, there is a lot of theory that goes into animating something well, including anticipation, squash and stench, staging, blah, blah, blah - honestly, there are better people then me to take you through that. The point is, good animation is complicated.

My first suggestion is to go an use a good animation framework, like TimingFramework which does all the heavy lifting for you.

On the off chance you can't do that, then you're in for a fun ride.

The first, well actually, easy thing, is to find a good color blending algorithm. I spent a lot of time exploring different algorithms and trying a bunch of different things before settling on something like...

protected Color blend(Color color1, Color color2, double ratio) {
    float r = (float) ratio;
    float ir = (float) 1.0 - r;

    float red = color1.getRed() * r + color2.getRed() * ir;
    float green = color1.getGreen() * r + color2.getGreen() * ir;
    float blue = color1.getBlue() * r + color2.getBlue() * ir;
    float alpha = color1.getAlpha() * r + color2.getAlpha() * ir;

    red = Math.min(255f, Math.max(0f, red));
    green = Math.min(255f, Math.max(0f, green));
    blue = Math.min(255f, Math.max(0f, blue));
    alpha = Math.min(255f, Math.max(0f, alpha));

    Color color = null;
    try {
        color = new Color((int) red, (int) green, (int) blue, (int) alpha);
    } catch (IllegalArgumentException exp) {
        exp.printStackTrace();
    }
    return color;
}

The reason I use this approach is I can easily move a color towards black or white, which many other approaches couldn't achieve (just moved the color to high or low ranges). This also focuses on blending two colors together based on a ratio of between 0 and 1 (so at 0.5, there balanced blend between the two).

Animation Engine...

Note: Swing is single threaded AND not thread safe, you need to take that into consideration when developing a solution. Okay, the hard part...

It's easy to do a linear animation, animation from the start of one point until you reach the end of another point. The problem with this is, they don't scale well and don't generally produce a good natural feeling animation.

Instead, what you want is a concept of an animation running over a period of time. From this you can calculate the progression through the given period and calculate the value to be applied.

This approach scales well, you can change the time and not care about anything else, it will take care of itself. It also works well on systems whose performance may not be up to a killer frame rate, as they can drop frames and mostly "fake" it

One key concept to keep in mind, animation is the "illusion" of change over time.

Range

Range is a simple generic representation of a starting and ending point, then given a normalised progression (0-1) can calculate a reasonable representation between those points.

public abstract class Range<T> {

    private T from;
    private T to;

    public Range(T from, T to) {
        this.from = from;
        this.to = to;
    }

    public T getFrom() {
        return from;
    }

    public T getTo() {
        return to;
    }

    @Override
    public String toString() {
        return "From " + getFrom() + " to " + getTo();
    }

    public abstract T valueAt(double progress);

}

The stupid thing is, I do this a lot, so, here is a helper class to encapsulate the core concept.

Animator

This is a "central" engine which drives all the animations. It repeatedly ticks at about every 5 milliseconds and drives the Animatable objects, which actually calculate the time through the animation.

public enum Animator {

    INSTANCE;

    private Timer timer;

    private List<Animatable> properies;

    private Animator() {
        properies = new ArrayList<>(5);
        timer = new Timer(5, new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                Iterator<Animatable> it = properies.iterator();
                while (it.hasNext()) {
                    Animatable ap = it.next();
                    if (ap.tick()) {
                        it.remove();
                    }
                }
                if (properies.isEmpty()) {
                    timer.stop();
                }
            }
        });
    }

    public void add(Animatable ap) {
        properies.add(ap);
        timer.start();
    }

    public void remove(Animatable ap) {
        properies.remove(ap);
        if (properies.isEmpty()) {
            timer.stop();
        }
    }

}

Animatable

This is "something" which can be animated. It has a concept of a Range and a Duration over which it should be animated (it also supports easement, but I will talk about that later)

public interface Animatable<T> {
    public Range<T> getRange();
    public T getValue();
    public boolean tick();
    public void setDuration(Duration duration);
    public Duration getDuration();
    public Easement getEasement();
}

AbstractAnimatable

A abstract implementation of Animatable. This does all the cool, awesome, calculations on each tick to determine how far through the given animation cycle it is and generates notifications to registered listeners about the change in state, so they can actually do something about it...

public abstract class AbstractAnimatable<T> implements Animatable<T> {

    private Range<T> range;
    private LocalDateTime startTime;
    private Duration duration = Duration.ofSeconds(5);
    private T value;

    private AnimatableListener<T> listener;

    private Easement easement;

    public AbstractAnimatable(Range<T> range, AnimatableListener<T> listener) {
        this.range = range;
        this.value = range.getFrom();

        this.listener = listener;
    }

    public AbstractAnimatable(Range<T> range, Easement easement, AnimatableListener<T> listener) {
        this(range, listener);
        this.easement = easement;
    }

    public void setEasement(Easement easement) {
        this.easement = easement;
    }

    @Override
    public Easement getEasement() {
        return easement;
    }

    public void setDuration(Duration duration) {
        this.duration = duration;
    }

    public Duration getDuration() {
        return duration;
    }

    public Range<T> getRange() {
        return range;
    }

    @Override
    public T getValue() {
        return value;
    }

    public double getCurrentProgress(double rawProgress) {
        Easement easement = getEasement();
        double progress = Math.min(1.0, Math.max(0.0, getRawProgress()));
        if (easement != null) {
            progress = easement.interpolate(progress);
        }

        return progress;
    }

    public double getRawProgress() {
        if (startTime == null) {
            startTime = LocalDateTime.now();
        }
        Duration duration = getDuration();
        Duration runningTime = Duration.between(startTime, LocalDateTime.now());
        double progress = (runningTime.toMillis() / (double) duration.toMillis());
        return progress;
    }

    @Override
    public boolean tick() {
        double rawProgress = getRawProgress();
        double progress = getCurrentProgress(rawProgress);

        if (rawProgress >= 1.0) {
            progress = 1.0;
        }

        value = getRange().valueAt(progress);
        listener.stateChanged(this);

        return progress >= 1.0;
    }

}

AnimatableListener

A listener/observer to changes to a AbstractAnimatable state. This way, the AbstractAnimatable can tell interested parties that the state has been updated and what that state currently is.

public interface AnimatableListener<T> {

    public void stateChanged(Animatable<T> animator);
}

Easement

Okay, so I mentioned "animation theory". Basically what this is a implementation of a "spline interpolation" designed to provide common animation concepts, slow in, slow out, etc. What this does is changes the "progress" value through the animation so that the "speed" of the animation "appears" to change over the duration of the animation ... fancy, pancy for making it "look nice"

public enum Easement {

    SLOWINSLOWOUT(1d, 0d, 0d, 1d),
    FASTINSLOWOUT(0d, 0d, 1d, 1d),
    SLOWINFASTOUT(0d, 1d, 0d, 0d),
    SLOWIN(1d, 0d, 1d, 1d),
    SLOWOUT(0d, 0d, 0d, 1d);

    private final double points[];

    private final List<PointUnit> normalisedCurve;

    private Easement(double x1, double y1, double x2, double y2) {
        points = new double[]{x1, y1, x2, y2};

        final List<Double> baseLengths = new ArrayList<>();
        double prevX = 0;
        double prevY = 0;
        double cumulativeLength = 0;
        for (double t = 0; t <= 1; t += 0.01) {
            Point2D xy = getXY(t);
            double length = cumulativeLength
                            + Math.sqrt((xy.getX() - prevX) * (xy.getX() - prevX)
                                            + (xy.getY() - prevY) * (xy.getY() - prevY));

            baseLengths.add(length);
            cumulativeLength = length;
            prevX = xy.getX();
            prevY = xy.getY();
        }

        normalisedCurve = new ArrayList<>(baseLengths.size());
        int index = 0;
        for (double t = 0; t <= 1; t += 0.01) {
            double length = baseLengths.get(index++);
            double normalLength = length / cumulativeLength;
            normalisedCurve.add(new PointUnit(t, normalLength));
        }
    }

    public double interpolate(double fraction) {
        int low = 1;
        int high = normalisedCurve.size() - 1;
        int mid = 0;
        while (low <= high) {
            mid = (low + high) / 2;

            if (fraction > normalisedCurve.get(mid).getPoint()) {
                low = mid + 1;
            } else if (mid > 0 && fraction < normalisedCurve.get(mid - 1).getPoint()) {
                high = mid - 1;
            } else {
                break;
            }
        }
        /*
     * The answer lies between the "mid" item and its predecessor.
         */
        final PointUnit prevItem = normalisedCurve.get(mid - 1);
        final double prevFraction = prevItem.getPoint();
        final double prevT = prevItem.getDistance();

        final PointUnit item = normalisedCurve.get(mid);
        final double proportion = (fraction - prevFraction) / (item.getPoint() - prevFraction);
        final double interpolatedT = prevT + (proportion * (item.getDistance() - prevT));
        return getY(interpolatedT);
    }

    protected Point2D getXY(double t) {
        final double invT = 1 - t;
        final double b1 = 3 * t * invT * invT;
        final double b2 = 3 * t * t * invT;
        final double b3 = t * t * t;
        final Point2D xy = new Point2D.Double((b1 * points[0]) + (b2 * points[2]) + b3, (b1 * points[1]) + (b2 * points[3]) + b3);
        return xy;
    }

    protected double getY(double t) {
        final double invT = 1 - t;
        final double b1 = 3 * t * invT * invT;
        final double b2 = 3 * t * t * invT;
        final double b3 = t * t * t;
        return (b1 * points[2]) + (b2 * points[3]) + b3;
    }

    protected class PointUnit {

        private final double distance;
        private final double point;

        public PointUnit(double distance, double point) {
            this.distance = distance;
            this.point = point;
        }

        public double getDistance() {
            return distance;
        }

        public double getPoint() {
            return point;
        }

    }

}

So what?!

Okay, about now, you're probably scratching your head, wishing you hadn't asked the question ;)

How does all this help. The point is, all the above is re-usable, so you dump into a library somewhere and don't care about :/

What you do is then implement the required features and apply...

ColorRange

This takes the colorBlend algorithm from before and wraps into a Range concept...

public class ColorRange extends Range<Color> {
    
    public ColorRange(Color from, Color to) {
        super(from, to);
    }

    @Override
    public Color valueAt(double progress) {
        Color blend = blend(getTo(), getFrom(), progress);
        return blend;
    }

    protected Color blend(Color color1, Color color2, double ratio) {
        float r = (float) ratio;
        float ir = (float) 1.0 - r;

        float red = color1.getRed() * r + color2.getRed() * ir;
        float green = color1.getGreen() * r + color2.getGreen() * ir;
        float blue = color1.getBlue() * r + color2.getBlue() * ir;
        float alpha = color1.getAlpha() * r + color2.getAlpha() * ir;

        red = Math.min(255f, Math.max(0f, red));
        green = Math.min(255f, Math.max(0f, green));
        blue = Math.min(255f, Math.max(0f, blue));
        alpha = Math.min(255f, Math.max(0f, alpha));

        Color color = null;
        try {
            color = new Color((int) red, (int) green, (int) blue, (int) alpha);
        } catch (IllegalArgumentException exp) {
            exp.printStackTrace();
        }
        return color;
    }

}

This allows us to calculate the required color based on the progression through the animation

ColorAnimatable

We then make a "animatable color" concept...

public class ColorAnimatable extends AbstractAnimatable<Color> {

    public ColorAnimatable(ColorRange animationRange, Duration duration, AnimatableListener<Color> listener) {
        super(animationRange, listener);
        setDuration(duration);
    }

}

This means that we can establish a basic concept of a animation between two colors of a specified period of time and be notified when the animation state changes - it's nicely decoupled

Implementation...

And "finally" we're ready to actually make something out of it...

public class MenuItem extends JPanel {

    private Duration animationTime = Duration.ofSeconds(5);
    private JLabel label;

    private ColorAnimatable transitionAnimatable;

    private Color unfocusedColor = new Color(0, 0, 255, 0);
    private Color focusedColor = new Color(0, 0, 255, 255);

    public MenuItem() {
        setOpaque(false);
        setBorder(new EmptyBorder(8, 8, 8, 8));
        setLayout(new GridBagLayout());

        setBackground(unfocusedColor);

        GridBagConstraints gbc = new GridBagConstraints();
        gbc.weightx = 1;
        gbc.fill = GridBagConstraints.BOTH;

        label = new JLabel();
        label.setForeground(Color.WHITE);
        label.setHorizontalAlignment(JLabel.LEADING);
        add(label, gbc);

        label.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseEntered(MouseEvent e) {
                double progress = stopAnimation();
                transitionAnimatable = new ColorAnimatable(
                                new ColorRange(getBackground(), focusedColor), 
                                preferredAnimationTime(progress), 
                                new AnimatableListener<Color>() {
                    @Override
                    public void stateChanged(Animatable<Color> animator) {
                        setBackground(animator.getValue());
                    }
                });
                transitionAnimatable.setEasement(Easement.SLOWOUT);
                Animator.INSTANCE.add(transitionAnimatable);
            }

            @Override
            public void mouseExited(MouseEvent e) {
                double progress = stopAnimation();
                transitionAnimatable = new ColorAnimatable(
                                new ColorRange(getBackground(), unfocusedColor), 
                                preferredAnimationTime(progress), 
                                new AnimatableListener<Color>() {
                    @Override
                    public void stateChanged(Animatable<Color> animator) {
                        setBackground(animator.getValue());
                    }
                });
                transitionAnimatable.setEasement(Easement.SLOWOUT);
                Animator.INSTANCE.add(transitionAnimatable);
            }

        });
    }

    public MenuItem(String text) {
        this();
        setText(text);
    }

    protected Duration preferredAnimationTime(double currentProgress) {
        if (currentProgress > 0.0 && currentProgress < 1.0) {
            double remainingProgress = 1.0 - currentProgress;
            double runningTime = animationTime.toMillis() * remainingProgress;
            return Duration.ofMillis((long)runningTime);
        } 
        
        return animationTime;
    }

    protected double stopAnimation() {
        if (transitionAnimatable != null) {
            Animator.INSTANCE.remove(transitionAnimatable);
            return transitionAnimatable.getRawProgress();
        }
        return 0.0;
    }

    public void setText(String text) {
        label.setText(text);
    }

    public String getText() {
        return label.getText();
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        // Because we're faking it
        g.setColor(getBackground());
        g.fillRect(0, 0, getWidth(), getHeight());
    }

}

This looks complicated, but it's not to hard. Basically it defines a focused and unfocused color, sets up a MouseListener to monitor the mouseEntered and mouseExited events and setups the ColorAnimatable based on the required transition.

One thing this does do, which might not be obvious, is it will take into account unfinished animation. So if the user moves out of the item before the focus animation has completed, it will use the remaining time and the current color as starting point for the unfocus transition.

Because Swing components are either fully transparent or fully opaque, we need to "fake" the alpha support. We do this by making the component fully transparent and then painting the background ourselves

Runnable example...

Menu

import java.awt.Color;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.Point2D;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.Timer;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.border.EmptyBorder;

public class Test {

    public static void main(String[] args) {
        new Test();
    }

    public Test() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                    ex.printStackTrace();
                }

                JFrame frame = new JFrame("Testing");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        public TestPane() {
            setBorder(new EmptyBorder(8, 8, 8, 8));
            setLayout(new GridBagLayout());
            GridBagConstraints gbc = new GridBagConstraints();
            gbc.weightx = 1;
            gbc.fill = GridBagConstraints.HORIZONTAL;
            gbc.gridwidth = GridBagConstraints.REMAINDER;

            add(new MenuItem("Backup"), gbc);
            add(new MenuItem("Screenshots"), gbc);
            add(new MenuItem("Settings"), gbc);

            setBackground(Color.BLACK);
        }

    }

    // Sorry, I'm over the character limit, you will need to copy
    // all the other classes yourself
}

You could also have a look at:

You should also have a look at Concurrency in Swing to gain a better understanding into the underlying concept of how the engine itself works and why it was done this way

Upvotes: 10

VGR
VGR

Reputation: 44414

This is a much simpler implementation than what MadProgrammer has suggested. It makes some naive assumptions, such as linear color interpolation. It also does not attempt alpha blending of the animation colors. And it does not support animation in response to keyboard navigation.

The inner Fader class does animation in one direction, using a javax.swing.Timer. Each JMenuItem is assigned two Fader objects, one for transitioning to the highlight color, and another to transition back to the regular background color.

It is also necessary to override the UI delegate object, since most look-and-feels want to enforce their own colors regardless of a JMenuItem’s background, either all the time or when the menu item has keyboard focus.

import java.util.Objects;

import java.awt.Color;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

import javax.swing.AbstractButton;
import javax.swing.BorderFactory;
import javax.swing.JLabel;
import javax.swing.JFrame;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.Timer;
import javax.swing.plaf.basic.BasicMenuItemUI;

public class FadingMenuTest {
    private static final int animDurationMillis = 500;
    private static final int frameCount = 8;

    private static class Fader {
        private final Component component;

        private final Color normalBackground;
        private final float[] normalRGBA;
        private final float[] targetRGBA;
        private final float[] rgba = new float[4];

        private final Timer timer = new Timer(animDurationMillis / frameCount,
            e -> updateBackground());

        private int frameNumber;

        Fader(Component component,
              Color targetBackground,
              Color normalBackground) {

            this.component = Objects.requireNonNull(component);
            this.normalBackground = normalBackground;
            this.normalRGBA = normalBackground.getComponents(null);
            this.targetRGBA = targetBackground.getComponents(null);
        }

        private void updateBackground() {
            if (++frameNumber > frameCount) {
                timer.stop();
                return;
            }

            for (int i = rgba.length - 1; i >= 0; i--) {
                float normal = normalRGBA[i];
                float target = targetRGBA[i];
                rgba[i] =
                    normal + (target - normal) * frameNumber / frameCount;
            }

            component.setBackground(
                new Color(rgba[0], rgba[1], rgba[2], rgba[3]));
        }

        void start() {
            frameNumber = 0;
            timer.restart();
            component.setBackground(normalBackground);
        }

        void stop() {
            timer.stop();
            component.setBackground(normalBackground);
        }
    }

    static void addFadeOnHover(Component component,
                               Color targetBackground,
                               Color normalBackground) {

        Fader entryFader =
            new Fader(component, targetBackground, normalBackground);
        Fader exitFader =
            new Fader(component, normalBackground, targetBackground);

        component.addMouseListener(new MouseAdapter() {
            private static final long serialVersionUID = 1;

            @Override
            public void mouseEntered(MouseEvent event) {
                exitFader.stop();
                entryFader.start();
            }

            @Override
            public void mouseExited(MouseEvent event) {
                entryFader.stop();
                exitFader.start();
            }
        });
    }

    static void show() {
        Color foreground = Color.WHITE;
        Color background = new Color(0, 0, 128);
        Color highlight = Color.BLUE;

        JPopupMenu menu = new JPopupMenu();
        menu.setBackground(background);

        menu.add(new JMenuItem(String.format("%c Backup", 0x1f5ce)));
        menu.add(new JMenuItem(String.format("%c Screenshots", 0x1f5b5)));
        menu.add(new JMenuItem("\u26ed Settings"));

        // Most look-and-feels will override any colors we set, especially
        // when a menu item is selected.
        BasicMenuItemUI ui = new BasicMenuItemUI() {
            {
                selectionForeground = foreground;
                selectionBackground = background;
            }

            @Override
            public void paintBackground(Graphics g,
                                        JMenuItem item,
                                        Color background) {
                super.paintBackground(g, item, item.getBackground());
            }
        };

        for (Component item : menu.getComponents()) {
            AbstractButton b = (AbstractButton) item;
            b.setContentAreaFilled(false);
            b.setOpaque(true);
            b.setUI(ui);

            item.setForeground(foreground);
            item.setBackground(background);
            addFadeOnHover(item, highlight, background);
        }

        JLabel label = new JLabel("Right click here");
        label.setBorder(BorderFactory.createEmptyBorder(200, 200, 200, 200));
        label.setComponentPopupMenu(menu);

        JFrame frame = new JFrame("Fading Menu Test");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().add(label);
        frame.pack();
        frame.setLocationByPlatform(true);
        frame.setVisible(true);
    }

    public static void main(String[] args) {
        EventQueue.invokeLater(() -> show());
    }
}

Upvotes: 1

Related Questions