javaswing

Provide default component width, allow shrinking inside JScrollPane


Here's what I want.

  1. I want a component to have a default "preferred" width.
  2. (important) The component is fine with being shrunk to some degree, it's just it shouldn't be displayed in a shrunk state initially.
  3. I also want it to be inside a JScrollPane.

Can I square the three together?

The problem is any component's preferredSize effectively becomes its minimumSize if it's placed inside a JScrollPane, it never shrinks — but it is cropped. I can't meet the second requirement while also meeting the other two. As shown in the second screenshot, the component's cropped (not shrunk) once I shrink the container beyond the component's preferred width.

I think another way to put it is this: I expect the scroller to "kick in" (e.g. show the scrollbar, if it's "as needed") only once its components' minimum (not preferred) sizes can no longer be honored.

import javax.swing.BorderFactory;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;
import javax.swing.WindowConstants;
import java.awt.Color;
import java.awt.Dimension;

public class ScrollPaneDemo {

    public static void main(String[] args) {
        SwingUtilities.invokeLater(ScrollPaneDemo::launch);
    }

    private static void launch() {
        JFrame frame = new JFrame("ScrollPane Demo");
        frame.setContentPane(createScrollPane());
        frame.setLocationRelativeTo(null);
        frame.pack();
        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        frame.setVisible(true);
    }

    private static JScrollPane createScrollPane() {
        JScrollPane scrollPane = new JScrollPane(createScrollablePanel());
        scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
        return scrollPane;
    }

    private static JPanel createScrollablePanel() {
        JPanel panel = new JPanel();
        panel.setBorder(BorderFactory.createLineBorder(Color.RED, 3));
        panel.setPreferredSize(new Dimension(250, 150));
        return panel;
    }
}

container exceeds the component's preferred width

container is less than the component's preferred width, the component's cropped


Solution

  • I implemented VGR's idea. It works.

    Scenario #1: reference to JScrollPane available at instantiation

    private static JScrollPane createScrollPane() {
        JScrollPane scrollPane = new JScrollPane();
        scrollPane.setViewportView(createScrollablePanel(scrollPane));
        scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
        return scrollPane;
    }
    
    private static JPanel createScrollablePanel(JScrollPane scrollPane) {
        JPanel panel = new ScrollablePanel(scrollPane);
        panel.setBorder(BorderFactory.createLineBorder(Color.RED, 3));
        panel.setPreferredSize(new Dimension(350, 250)); // not honored
        panel.setMinimumSize(new Dimension(200, 250)); // honored
        return panel;
    }
    
    private static class ScrollablePanel extends JPanel implements Scrollable {
    
        private final JViewport viewport;
    
        private ScrollablePanel(JScrollPane scrollPane) {
            this(scrollPane.getViewport());
        }
    
        private ScrollablePanel(JViewport viewport) {
            this.viewport = viewport;
        }
    
        @Override
        public Dimension getPreferredScrollableViewportSize() {
            return getPreferredSize();
        }
    
        @Override
        public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
            return 5; // arbitrary
        }
    
        @Override
        public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) {
            return 10; // arbitrary
        }
    
        @Override
        public boolean getScrollableTracksViewportWidth() {
            return getMinimumSize().getWidth() <= viewport.getWidth();
        }
    
        @Override
        public boolean getScrollableTracksViewportHeight() {
            return getMinimumSize().getHeight() <= viewport.getHeight();
        }
    }
    

    the component's shown in its preferred size

    the component's shrunk beyond its preferred width as the viewport's shrunk

    the viewport's shrunk beyond the component's minimum width, the component's cropped

    Scenario #2: reference to JScrollPane not available at instantiation

        private static JPanel createScrollablePanel() {
            JPanel panel = new ScrollablePanel();
            panel.setBorder(BorderFactory.createLineBorder(Color.RED, 3));
            panel.setPreferredSize(new Dimension(250, 150)); // not honored
            panel.setMinimumSize(new Dimension(150, 150)); // honored
            return panel;
        }
    
        private static class ScrollablePanel extends JPanel implements Scrollable {
    
            private JScrollPane scrollPane;
    
            @Override
            public Dimension getPreferredScrollableViewportSize() {
                return getPreferredSize();
            }
    
            @Override
            public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) {
                if (scrollPaneNotFound()) return 5; // arbitrary
                JScrollBar scrollBar = scrollPane.getVerticalScrollBar();
                return scrollBar.getUnitIncrement();
            }
    
            private boolean scrollPaneNotFound() {
                return !scrollPaneFound();
            }
    
            private boolean scrollPaneFound() {
                if (scrollPane != null) return true;
                scrollPane = findScrollPane().orElse(null);
                return scrollPane != null;
            }
    
            public Optional<JScrollPane> findScrollPane() {
                JScrollPane scrollPane = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, this);
                return Optional.ofNullable(scrollPane);
            }
    
            @Override
            public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) {
                if (scrollPaneNotFound()) return 10; // arbitrary
                JScrollBar scrollBar = scrollPane.getVerticalScrollBar();
                return scrollBar.getBlockIncrement();
            }
    
            @Override
            public boolean getScrollableTracksViewportWidth() {
                if (scrollPaneNotFound()) return false;
                return getMinimumSize().getWidth() <= scrollPane.getViewport().getWidth();
            }
    
            @Override
            public boolean getScrollableTracksViewportHeight() {
                if (scrollPaneNotFound()) return false;
                return getMinimumSize().getHeight() <= scrollPane.getViewport().getHeight();
            }
        }
    

    Caveat

    It won't work if the component is not directly set as the scroller's view.

        private static JScrollPane createScrollPane() {
            JScrollPane scrollPane = new JScrollPane();
    
            JPanel panel = new JPanel(new BorderLayout()); // this will break everything
            panel.add(createScrollablePanel());
    
            scrollPane.setViewportView(panel);
            scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
            return scrollPane;
        }
    

    It's because ScrollPaneLayout doesn't search for Scrollables recursively:

    // javax.swing.ScrollPaneLayout#layoutContainer
            if (!isEmpty && view instanceof Scrollable) {
                sv = (Scrollable)view;
                viewTracksViewportWidth = sv.getScrollableTracksViewportWidth();
                viewTracksViewportHeight = sv.getScrollableTracksViewportHeight();
            }