Search code examples
javaswingjscrollpanescalinglayout-manager

How can I support scaling a view displayed in a JScrollPane to avoid displaying JScrollBars up to a minimum size?


The behavior I want to implement is to display a view at 1:1 scale by default. If its container is made larger (by the user dynamically resizing the parent JFrame) the view should be scaled to fit the larger area; if made smaller, the view should be scaled to fit the smaller area - up to a limit. When smaller than a minimum size, scroll bars should appear with a viewport to support navigating around the view which is displayed at its minimum scale.

I have a poorly-functioning implementation now, using a JScrollPane and a ComponentListener to determine when a resize has occurred. Based on the new size a new scale factor is set for painting the view to fit (up to the minimum scale), the preferredSize of the view is set and revalidate() is called. The problem is that this results in "jittery" display; when sizing past the points where a new scale is applied to avoid displaying scrollbars, the scrollbars appear, then disappear. I believe this is because my approach is reactive, not predictive. That is, the LayoutManager is only looking at the view's preferredSize and performing layout based on that. I'm listening for the resize of the JScrollPane and when it is too small only then changing the preferredSize of the view and then causing the container to be laid out again with the call to revalidate(). At least that's how I understand it.

I've been reviewing the source for JScrollPane, JViewport and their respecive LayoutManagers. At this point I"m considering subclassing one or more to have a better (predictive, resulting in smoother sizing) implementation. This seems like behavior that others must have implemented before. Is there another way to use existing Containers/LayoutManagers/methods to do this without subclassing and risking unintended behavior across different LnF or platforms?

Edit: I've hacked a prototype subclass of ScrollPaneLayout. It checks for the view's minimum size before adding scrollbars. This is working (in that the scrollbars do not appear until the viewport is smaller than the view's minimum size, instead of the original behavior which displayed scrollbars when the viewport was smaller than the view's preferred size) but when the scrollbars are displayed the view thinks it is still the preferred size, not the minimum size. I may have to hack at the ViewportLayout class as well, but this is quickly becoming something that I doubt will be robust.

Edit:

I went back to basics and tried the suggestions again, and have been succcessful. My attempts to overload the default behavior of ScrollPaneLayout and ViewportLayout, and the interactions between them was not the right direction. After my first attempt I was convinced that there was no way to avoid the flickering and instability of my "reactive" approach to fixing incorrect sizing after the LayoutManagers had done their thing. Fortunately, there is a way to make this work without subclassing either LayoutManager - and as stated, it's by implementing the Scrollable interface (which I had done correctly before, but had not made other changes to make it work). The trick is indeed to implement getScrollableTracksViewport() such that it returns true/false depending on the viewport size. What I hadn't done was update the view's preferredSize in addition to the other calculuations I was doing. This was a critical step. (Note that I'm also relying on listening to ComponentListener.componentResized() notifications to trigger the calculations needed to properly set the return values) The functioning code follows, thatnks for the help.

@SuppressWarnings("serial")
class ScalingScreenPanel extends JScrollPane {

private static final Dimension              PREFERRED_SIZE = new Dimension(800,200);

private static ScalingScreen                sScreen;

public ScalingScreenPanel() {
    setPreferredSize(PREFERRED_SIZE);
    getViewport().addComponentListener(new ComponentAdapter() {
        public void componentResized(ComponentEvent event) {
            sScreen.calculateSizes(event.getComponent().getSize());
        }
    });     
    setViewportView(sScreen=new ScalingScreen());       
}
public void setShow(Show pShow) {
    sScreen.setShow(pShow);
}
} // class PreviewScreenPanel

@SuppressWarnings("serial")
public abstract class ScalingScreen extends JPanel implements Scrollable {

private static final Dimension              PREFERRED_SIZE = new Dimension(1000,100);
private static final JLabel                 EMPTY_PANEL = new JLabel("Empty",SwingConstants.CENTER);

private static final int                    DEFAULT_SIZE = 7;
private static final int                    MINIMUM_SIZE = 5;
private static final int                    SPACING = 3;
private static final int                    MINIMUM_PITCH = MINIMUM_SIZE+SPACING; // Do not modify directly
private static final int                    DEFAULT_PITCH = DEFAULT_SIZE+SPACING; // Do not modify directly

protected int                               cSize;
protected int                               cPitch;
protected Dimension                         cOrigin;
private Dimension                           cMinimumScreenSize;
private Dimension                           cDefaultScreenSize;

protected Dimension                         cCurrentScreenSize;
private boolean                             cEnableVerticalScrollbar = false, cEnableHorizontalScrollbar = false;

protected Dimension                         cGridSize = null;

ScalingScreen() {
    cOrigin = new Dimension(0,0);
    add(EMPTY_PANEL);
}
public void setShow(Show pShow) {
    remove(EMPTY_PANEL);
    cGridSize = new Dimension(pShow.dimension());
    cMinimumScreenSize = new Dimension(cGridSize.width*MINIMUM_PITCH+SPACING,cGridSize.height*MINIMUM_PITCH+SPACING);
    cDefaultScreenSize = new Dimension(cGridSize.width*DEFAULT_PITCH+SPACING,cGridSize.height*DEFAULT_PITCH+SPACING);
    setMinimumSize(cMinimumScreenSize);
    setPreferredSize(cDefaultScreenSize);
    calculateSizes(getSize());
                repaint();
}
public void calculateSizes(Dimension pViewportSize) {
    if (cGridSize==null) return;
    cPitch = Math.max(MINIMUM_PITCH,Math.min((pViewportSize.width-SPACING)/cGridSize.width,(pViewportSize.height-SPACING)/cGridSize.height));
    cSize = cPitch - SPACING;
    cOrigin = new Dimension((pViewportSize.width-(cPitch*cGridSize.width))/2,(pViewportSize.height-(cPitch*cGridSize.height))/2);
    cCurrentScreenSize = new Dimension(Math.max(pViewportSize.width,cMinimumScreenSize.width),Math.max(pViewportSize.height,cMinimumScreenSize.height));
    Dimension preferredSize = new Dimension();
    if (pViewportSize.width<cMinimumScreenSize.width) {
        cOrigin.width = 0;
        cEnableHorizontalScrollbar = true;
        preferredSize.width = cMinimumScreenSize.width;
    } else {
        cOrigin.width = (pViewportSize.width-(cPitch*cGridSize.width))/2;
        cEnableHorizontalScrollbar = false;
        preferredSize.width = cDefaultScreenSize.width;         
    }
    if (pViewportSize.height<cMinimumScreenSize.height) {
        cOrigin.height = 0;
        cEnableVerticalScrollbar = true;
        preferredSize.height = cMinimumScreenSize.height;
    } else {
        cOrigin.height = (pViewportSize.height-(cPitch*cGridSize.height))/2;
        cEnableVerticalScrollbar = false;
        preferredSize.height = cDefaultScreenSize.height;
    }
    setPreferredSize(preferredSize);
                repaint();
}

// Methods to implement abstract Scrollable interface
@Override
public Dimension getPreferredScrollableViewportSize() {
    return getPreferredSize();
}
@Override
public boolean getScrollableTracksViewportHeight() {
    return !cEnableVerticalScrollbar;
}
@Override
public boolean getScrollableTracksViewportWidth() {
    return !cEnableHorizontalScrollbar;
}
@Override
public int getScrollableBlockIncrement(Rectangle pVisibleRect, int pOrientation, int pDirection) {
    switch (pOrientation) {
    case SwingConstants.VERTICAL:
        return pVisibleRect.height/2;
    case SwingConstants.HORIZONTAL:
        return pVisibleRect.width/2;
    default:
        return 0;
    }       
}
@Override
public int getScrollableUnitIncrement(Rectangle pVisibleRect,
        int pOrientation, int pDirection) {
    switch (pOrientation) {
    case SwingConstants.VERTICAL:
        return 1;
    case SwingConstants.HORIZONTAL:
        return 1;
    default:
        return 0;
    }
}
      @Override
      public void paintComponent(Graphcs g) {
          // custom drawing stuff
      }
} // class ScalingScreen

Solution

  • The basic approach is to let your custom component implement Scrollable and code the getTracksViewportWidth/-Height as needed.

    Edit

    implement them to return true (thus disabling scrollbars) until I reach my minimum scale, then return false?

    exactly, that was the idea - but didn't work out as I expected: at the "turning point" when reaching the min the image is scaled to its preferred, even with an self-adjusting getPrefScrollable (which doesn't seem to have any effect, it's called once at the start)

    Edit 2

    With the help of the OP:

    update the view's preferredSize

    finally got it: (my initial attempt had it upside down ;-) keep the prefScrollable to the "real" pref and let pref return either minimum or pref, depending on whether or not the scrollbar should be visible.

    public static class JImagePanel extends JPanel implements Scrollable {
    
        private BufferedImage image;
    
        public JImagePanel(BufferedImage image) {
            this.image = image;
        }
    
        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            BufferedImage scaled = //new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB);
                    GraphicsUtilities.createCompatibleImage(getWidth(), getHeight());
            Graphics2D g2 = scaled.createGraphics();
            g2.drawImage(image, 0, 0, getWidth(), getHeight(), null);
            g.drawImage(scaled, 0, 0, this);
            g2.dispose();
        }
    
        /**
         * This method is used for laying out this container
         * inside the Viewport: let it return the "real" pref
         * or min, depending on whether or not the scrollbars
         * are showing.  
         */
        @Override
        public Dimension getPreferredSize() {
            Dimension size = getImageSize();
            if (!getScrollableTracksViewportWidth()) {
                size.width = getMinimumSize().width;
            }
            if (!getScrollableTracksViewportHeight()) {
                size.height = getMinimumSize().height;
            }
            return size;
        }
    
        @Override
        public Dimension getMinimumSize() {
            Dimension min = getImageSize();
            min.height /= 2;
            min.width /= 2;
            return min;
        }
    
        /**
         * This is used for laying out the scrollPane. Keep 
         * it fixed to "real" pref size.
         */
        @Override
        public Dimension getPreferredScrollableViewportSize() {
            return getImageSize();
        }
    
        /**
         * The unscaled image size (aka: "real" pref)
         */
        protected Dimension getImageSize() {
            return new Dimension(image.getWidth(), image.getHeight());
        }
    
        @Override
        public boolean getScrollableTracksViewportWidth() {
            return getParent() instanceof JViewport
                    && getParent().getWidth() >= getMinimumSize().width;
        }
    
        @Override
        public boolean getScrollableTracksViewportWidth() {
            return getParent() instanceof JViewport
                    && getParent().getWidth() >= getMinimumSize().width;
        }
    
        @Override
        public boolean getScrollableTracksViewportHeight() {
            return getParent() instanceof JViewport
                  && getParent().getHeight() >= getMinimumSize().height;        }
    
    
        @Override
        public int getScrollableUnitIncrement(Rectangle visibleRect,
                int orientation, int direction) {
            return 10;
        }
    
        @Override
        public int getScrollableBlockIncrement(Rectangle visibleRect,
                int orientation, int direction) {
            return 100;
        }
    
    }