2
\$\begingroup\$

By default, mouse wheel scrolling in Java Swing behaves differently than in web browsers. In Swing, when you have an inner scrollable component and an outer scrollable component, the mouse wheel events are only ever dispatched to the inner component. This means that when you are in a text area inside a larger panel, you cannot use the mouse wheel to scroll the panel. You first need to move the mouse cursor out of the text area.

I tested Google Chrome, Firefox, Internet Explorer and Edge, and they almost behave the same. I prefer their behavior over the default Swing behavior, therefore I implemented it.

  • The basic idea is that every top-level window has its "active scrolling component". This component receives all scroll events until it gets inactive by timeout (1 second).
  • To find a new "active component", the one at the mouse cursor is taken. If that component is not scrollable into the direction given by the event, its parent is taken, and so on.
  • The Microsoft browsers behave as if the timeout were 0 seconds, Chrome and Firefox have a timeout of 1 second.
  • When the mouse is moved while scrolling (really an edge case I think), my code happens to do the same as the browsers: scrolling continues with the component below the mouse cursor. I didn't plan for that, but it seems to be sensible.

Here is the code:

package de.roland_illig.playground.scroll;

import java.awt.Component;
import java.awt.event.MouseWheelEvent;
import java.awt.event.MouseWheelListener;
import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;

public final class WheelScrolling {

    /**
     * Passes mouse wheel events to the parent component if this component
     * cannot scroll further in the given direction.
     * <p>
     * This is the behavior of most web browsers and similar programs
     * that need to handle nested scrollable components.
     */
    public static void install(JScrollPane pane) {
        pane.addMouseWheelListener(new Listener(pane));
    }

    private static class Listener implements MouseWheelListener {

        private final JScrollPane pane;
        private boolean inHandler; // To avoid StackOverflowError in nested calls

        Listener(JScrollPane pane) {
            this.pane = pane;
            pane.setWheelScrollingEnabled(false);
        }

        public void mouseWheelMoved(MouseWheelEvent e) {
            if (!inHandler) {
                inHandler = true;
                try {
                    handleMoved(e);
                } finally {
                    inHandler = false;
                }
            }
        }

        private void handleMoved(MouseWheelEvent e) {
            JScrollPane curr = currentPane(e);
            if (curr == null || curr == pane || e.isControlDown() || e.isAltDown()) {
                dispatchDefault(pane, e);
            } else {
                dispatchDefault(curr, (MouseWheelEvent)
                        SwingUtilities.convertMouseEvent(pane, e, curr));
            }
        }

        private static void dispatchDefault(JScrollPane comp, MouseWheelEvent e) {
            if (comp.isWheelScrollingEnabled()) {
                comp.dispatchEvent(e);
            } else {
                comp.setWheelScrollingEnabled(true);
                comp.dispatchEvent(e);
                comp.setWheelScrollingEnabled(false);
            }
        }

        private JScrollPane currentPane(MouseWheelEvent e) {
            Current current = current(pane);
            if (current == null) {
                return null;
            }

            long validUntil = current.validUntil;
            current.validUntil = e.getWhen() + 1000;

            if (e.getWhen() < validUntil) {
                return current.pane;
            }

            for (Component comp = pane; comp != null; comp = comp.getParent()) {
                if (comp instanceof JScrollPane) {
                    JScrollPane otherPane = (JScrollPane) comp;
                    if (canScrollFurther(otherPane, e)) {
                        current.pane = otherPane;
                        return current.pane;
                    }
                }
            }

            current.pane = null;
            return null;
        }

        private static boolean canScrollFurther(JScrollPane pane, MouseWheelEvent e) {

            // See BasicScrollPaneUI
            JScrollBar bar = pane.getVerticalScrollBar();
            if (bar == null || !bar.isVisible() || e.isShiftDown()) {
                bar = pane.getHorizontalScrollBar();
                if (bar == null || !bar.isVisible()) {
                    return false;
                }
            }

            if (e.getWheelRotation() < 0) {
                return bar.getValue() != 0;
            } else {
                int limit = bar.getMaximum() - bar.getVisibleAmount();
                return bar.getValue() != limit;
            }
        }

        private static Current current(Component component) {
            if (component.getParent() == null) {
                return null;
            }

            Component top = component;
            while (top.getParent() != null) {
                top = top.getParent();
            }

            for (MouseWheelListener listener : top.getMouseWheelListeners()) {
                if (listener instanceof Current) {
                    return (Current) listener;
                }
            }

            Current current = new Current();
            top.addMouseWheelListener(current);
            return current;
        }
    }

    /**
     * The "currently active scroll pane" needs to remembered once
     * per top-level window.
     * <p>
     * Since a Component does not provide a storage for arbitrary data,
     * this data is stored in a no-op listener.
     */
    private static class Current implements MouseWheelListener {
        private JScrollPane pane;
        private long validUntil;

        @Override
        public void mouseWheelMoved(MouseWheelEvent e) {
            // Do nothing.
        }
    }
}

And here is a demo application using the above mouse wheel behavior:

package de.roland_illig.playground.scroll;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Rectangle;
import java.util.Random;
import javax.swing.BoxLayout;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.Scrollable;
import javax.swing.SwingConstants;
import javax.swing.WindowConstants;

public final class WheelScrollingDemo {

    public static void main(String[] args) {
        EventQueue.invokeLater(WheelScrollingDemo::main);
    }

    private static void main() {
        Random rnd = new Random(0);

        JPanel panel = new ScrollablePanel(new JTextArea());
        panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
        for (int i = 0; i < 10; i++) {
            panel.add(newInnerPanel(rnd.nextInt(50)));
        }

        JScrollPane pane = new JScrollPane(panel);

        JFrame frame = new JFrame("Mouse Wheel Scrolling Demo");
        frame.setPreferredSize(new Dimension(500, 500)); // Just a bad guess.
        frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        frame.getContentPane().add(pane, BorderLayout.CENTER);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    private static JPanel newInnerPanel(int rows) {
        JLabel head = new JLabel("Heading");
        head.setHorizontalTextPosition(SwingConstants.CENTER);

        JTextArea body = new JTextArea();
        body.setColumns(20);
        body.setRows(rows);

        JScrollPane pane = new JScrollPane(body);
        WheelScrolling.install(pane);

        JPanel panel = new ScrollablePanel(body);
        panel.setLayout(new BorderLayout());
        panel.setBackground(Color.LIGHT_GRAY);
        panel.setPreferredSize(new Dimension(400, 200)); // Just a bad guess.
        panel.add(head, BorderLayout.PAGE_START);
        panel.add(pane, BorderLayout.CENTER);

        return panel;
    }

    /**
     * Without this class, the mouse wheel scrolling speed differs
     * between the JTextArea and the JPanel.
     */
    private static final class ScrollablePanel extends JPanel implements Scrollable {

        private static final long serialVersionUID = 20181212;

        private final Scrollable ref;

        /**
         * @param ref the component providing the block and unit increments
         *            for scrolling
         */
        private ScrollablePanel(Scrollable ref) {
            this.ref = ref;
        }

        @Override
        public Dimension getPreferredScrollableViewportSize() {
            return getPreferredSize();
        }

        @Override
        public int getScrollableUnitIncrement(
                Rectangle visibleRect, int orientation, int direction) {
            return ref.getScrollableUnitIncrement(visibleRect, orientation, direction);
        }

        @Override
        public int getScrollableBlockIncrement(
                Rectangle visibleRect, int orientation, int direction) {
            return ref.getScrollableBlockIncrement(visibleRect, orientation, direction);
        }

        @Override
        public boolean getScrollableTracksViewportWidth() {
            return false;
        }

        @Override
        public boolean getScrollableTracksViewportHeight() {
            return false;
        }
    }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.