ORIGINAL DRAFT

For a while now, I’ve wanted to provide a solution that makes better use of the real estate around a scrollable area, to the top, bottom, left or right of the scroll bars in a JScrollPane. What makes this a thorny issue is that Swing doesn’t provide any easy way to do it, since any component placed in those areas must be a JScrollBar implementation. Using an alternate plugable-look-and-feel implementation defeats one of the tenets I’ve held for this column, to build on top of Swing rather than forcing development of new classes for each platform. After a few false starts, I’ve come up with the following code, which I think accomplishes that goal.

Our implementation is a collection of elements which are typically used in a JScrollPane. We’ll extend JScrollBar and support the ability to add components at each end, typically in a JScrollPane. Our implementation adds optional buttons that let you jump to the minimum and maximum values directly. We’ll also implement a simple JTabPane solution which conveniently fits into the bottom part of a JScrollerPane, allowing you to switch dynamically between multiple views.

Figure 1: JScrollerPane in action. The extended scroll bars
let you jump to the ends with a button press and the simple tab controls let you jump between panels.

Figure 1: JScrollerPane in action. The extended scroll bars let you jump to the ends with a button press and the simple tab controls let you jump between panels.

Figure 1 shows the result of our efforts with a JLabel containing an image in the viewable area. The frame contains a JScrollerPane, which uses two extended JScrollBar implementations called JScrollerBar. A JScrollerBar can have additional components at each end of the scroll bar. In our example, you can see a JTabBar in the bottom left area. The JTabBar is associated with a JTabPane which is presented in the viewing area, showing the selected image.

Figure 2: JScrollerPane and JTabPane 
class relationships.

Figure 2: JScrollerPane and JTabPane class relationships.

As you can see in Figure 2, there are two supporting classes for each the JScrollerPane and JTabPane classes. JScrollerPane relies on the JScrollerBar and ScrollerButton implementations. JTabPane requires the use of JTabBar and the TabLayout classes. These classes are designed to be as compatible as possible with their counterparts. JScrollerPane is completely backward-compatible with JScrollPane, so you can substituted it transparently. JTabPane is a less comprehensive substitute for JTabbedPane, which doesn’t support icons or some of the other sophisticated features. This article is primarily about JScrollerPane, so consider the JTabPane a nice afterthought that might require a little more work to be a complete solution.

As always space constraints preclude listing all the code in print, so you’ll find all these classes online at www.java-pro.com. We’ll cover JScrollerBar, JTabPane and JTabBar directly. ScrollerButton merely extends JButton and sets a number of options to remove the margins, ability to gain the focus, or default condition. It relies on a set of icons I’ve included in the icons subdirectory. TabLayout extends CardLayout and adds a method that lets you show a specified view by index value rather than by name. JScrollerPane extends JScrollPane and provides a set of constructors that add a few variants to let you specify the vertical and horizontal scroll bars at construction time.

Listing 1 shows the code for JScrollerBar. Most of the constructors exist for backward compatibility, so that you can use this component wherever a standard JScrollBar might be used. Because, JScrollerBar extends JScrollBar, it looks just as it should when used with a JScrollPane. Several constructors offer the ability to set a before and after JComponent. This is where JScrollerBar starts to differ, since you can add components at either end. In effect, this is possible because we use a BorderLayout to set up the scroll bar in the CENTER, leaving the ends available for the new components.

Another difference is our support for an EXTENDED option, thus the declaration of a couple of useful constants. When the EXTENDED flag is use, we add the ability to jump to the minimum and maximum positions directly. Using this feature doesn’t preclude the ability to add other components to the ends, so the main constructor is necessarily a bit more comprehensive than it might be. Using the EXTENDED option adds a pair of buttons to the scroll bar.

The output looks best in the Windows look-and-feel but can easily be adjusted for other platforms without having to implement any new look-and-feel classes. The icons, for example, are held in the icons subdirectory. The scroll bar arrows used by the Metal look-and-feel are a single pixel larger than they are in the Windows look-and-feel, for example. Switching to another look-and-feel may require more suitable icons for extended buttons in JScrollerBar. Fortunately, you can change these easily or even use alternate components in the JScrollerBar.

There’s a borderPanel in method the JScrollerBar class that serves to pad one of the button edges to make it consistent with the standard scroll bars under the Windows and Metal look-and-feel that I cannot guarantee will look ideal under other platforms. Fortunately, JScrollerBar provides enough external control to make this easy to address if you see any problem.

The rest of the code for JScrollerBar is there to provide a JScrollBar facet. Although we need to subclass JScrollBar to qualify as a drop-in replacement, we’re actually adding another JScrollBar instance to the CENTER of our panel. Confusing as this may be, it turns out the be the simplest solution I was able to find. It does means, however, that we need to pass all our processing to the contained JScrollBar rather than the parent, so each of the standard methods are implement, delegating requests to the contained JScrollBar.

The code for JTabPane is shown in Listing 2. This code exists primarily to support switching between viewing areas using the JTabBar control. You can create an instance and drop components into it in the order you want to display them. By default, the add method can be used to add components to JTabPane and the toString method will be used to determine what the tab label should be. Alternately, you can use the add method with the tab name as a constraint argument but the recommended usage is to call addTab to add a named tab to JTabPane.

It would have been nice to make this as functional a the JTabbedPane component, but this implementation exists primarily to demonstrate the JScrollerPane and JScrollerBar, so it’s necessarily limited. Let’s take a quick look at the JTabBar code in Listing 3.

The labels are stored in an ArrayList instance and displayed by a call to the toString method. That means you can store any object you like in this list, though the most common choice is likely to be a String list. The paintComponent method does the real work, delegating functionality to a set of internal methods. We draw the tabs from the outside-in, in two loops, to make sure the selected tab remains on top. The label bounds are precalculated with a call to getTextBounds, which returns the bounding rectangle for each tab. These rectangles overlap each other by the specified gap value, which you specify in the constructor.

The drawTab method actually paints each tab, drawing the background and outline of each tab and the text in the foreground. The background and outline are painted with a GeneralPath. You’d think the same path could be used for both, but the outline is an extra pixel to the right if you do this, so you’ll see two paths created, one drawn with fill, the other with the draw method. We make obvious use of the Java 2D API, so this code requires the Java 2 platform to operate.

To make the getPrefferedSize method functional, we call a method called calculateSize which uses the gap size and the getTextBounds method to determine the total display area required for the tabs that were defined. The only other significant method is the mousePressed functionality which determines where the mouse was clicked and repaints the tabs with the proper one selected and in the foreground.

This is not a particularly sophisticated JTabBar implementation, so you may have to enhance it if you plan to use it in your software. An implementation that scrolls left and right would be useful in limited space, and other orientations may be desirable. Either way, this solution is applicable to a small set of tabs and works well with the JSrollerBar, serving it’s purpose well enough.

These classes demonstrate an important solution to the problem of adding components around to the right left, top or bottom of a JScrollBar in a JScrollPane. While we’ve used JTabPane and JTabBar as a viable demonstration, you’re not limited by these examples. You might provide pullout menus, scaling buttons, splitter solutions or other variations for your user. User interface real estate should be used to effectively communicate user options. Shortcuts are especially useful and provide discoverable efficiencies for the end-user. I hope you’ve gained few new options from this article.

Listing 1

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import javax.swing.plaf.*;
import javax.swing.event.*;
import javax.accessibility.*;

public class JScrollerBar extends JScrollBar
  implements ActionListener
{
  public static final boolean NORMAL = false;
  public static final boolean EXTENDED = true;

  protected JScrollBar scrollBar;
  protected ScrollerButton minButton, maxButton;

  public JScrollerBar()
  {
    this(VERTICAL, 0, 10, 0, 100, null, null, NORMAL);
  }

  public JScrollerBar(int orientation)
  {
    this(orientation, 0, 10, 0, 100, null, null, NORMAL);
  }

  public JScrollerBar(
    JComponent before, JComponent after)
  {
    this(VERTICAL, 0, 10, 0, 100, before, after, NORMAL);
  }

  public JScrollerBar(int orientation,
    JComponent before, JComponent after)
  {
    this(orientation, 0, 10, 0, 100, before, after, NORMAL);
  }

  public JScrollerBar(int orientation,
    JComponent before, JComponent after, boolean extended)
  {
    this(orientation, 0, 10, 0, 100, before, after, extended);
  }

  public JScrollerBar(int orientation,
    int value, int extent, int min, int max)
  {
    this(orientation, value, extent, min, max, null, null, NORMAL);
  }
  
  public JScrollerBar(int orientation,
    int value, int extent, int min, int max,
    JComponent before, JComponent after)
  {
    this(orientation, value, extent, min, max, before, after, NORMAL);
  }
  
  public JScrollerBar(int orientation,
    int value, int extent, int min, int max,
    JComponent before, JComponent after,
    boolean extended)
  {
    JPanel panel = new JPanel(new BorderLayout());
    panel.add(BorderLayout.CENTER, scrollBar =
      new JScrollBar(orientation, value, extent, min, max));
    if (extended)
    {
      minButton = new ScrollerButton(orientation == VERTICAL ? 
        ScrollerButton.TOP : ScrollerButton.LEFT);
      maxButton = new ScrollerButton(orientation == VERTICAL ?
        ScrollerButton.BOTTOM : ScrollerButton.RIGHT);
    
      minButton.addActionListener(this);
      maxButton.addActionListener(this);

      panel.add(orientation == VERTICAL ?
        BorderLayout.NORTH : BorderLayout.WEST, 
        borderPanel(orientation, minButton));
      panel.add(orientation == VERTICAL ?
        BorderLayout.SOUTH : BorderLayout.EAST,
        borderPanel(orientation, maxButton));
    }
    
    setLayout(new BorderLayout(4, 4));
    add(BorderLayout.CENTER, panel);
    if (before != null) add(orientation == VERTICAL ?
        BorderLayout.NORTH : BorderLayout.WEST,
        borderPanel(orientation, before));
    if (after != null) add(orientation == VERTICAL ?
      BorderLayout.SOUTH : BorderLayout.EAST,
        borderPanel(orientation, after));
  }

  public void actionPerformed(ActionEvent event)
  {
    Object source = event.getSource();
    if (source == minButton)
    {
      setValue(getMinimum());
    }
    if (source == maxButton)
    {
      setValue(getMaximum());
    }
  }

  protected JComponent borderPanel(int orientation, JComponent child)
  {
    if (!(child instanceof ScrollerButton)) return child;
    boolean vertical = orientation == VERTICAL;
    JPanel panel = new JPanel(new GridLayout());
    panel.setBorder(BorderFactory.createEmptyBorder(
      vertical ? 0 : 1, vertical ? 1 : 0, 0, 0));
    panel.add(child);
    return panel;
  }

  public void addAdjustmentListener(AdjustmentListener listener)
  {
    scrollBar.addAdjustmentListener(listener);
  }

  public AccessibleContext getAccessibleContext()
  {
    return scrollBar.getAccessibleContext();
  }
  
  public int getBlockIncrement()
  {
    return scrollBar.getBlockIncrement();
  }
  
  public int getBlockIncrement(int direction)
  {
    return scrollBar.getBlockIncrement(direction);
  }
  
  public int getMaximum()
  {
    return scrollBar.getMaximum();
  }
  
  public Dimension getMaximumSize() 
  {
    return scrollBar.getMaximumSize();
  }
  
  public int getMinimum() 
  {
    return scrollBar.getMinimum();
  }
  
  public Dimension getMinimumSize() 
  {
    return scrollBar.getMinimumSize();
  }
  
  public BoundedRangeModel getModel() 
  {
    return scrollBar.getModel();
  }
  
  public int getOrientation() 
  {
    return scrollBar.getOrientation();
  }
  
  public ScrollBarUI getUI() 
  {
    return scrollBar.getUI();
  }
  
  public String getUIClassID() 
  {
    return scrollBar.getUIClassID();
  }
  
  public int getUnitIncrement() 
  {
    return scrollBar.getUnitIncrement();
  }
  
  public int getUnitIncrement(int direction) 
  {
    return scrollBar.getUnitIncrement(direction);
  }
  
  public int getValue() 
  {
    return scrollBar.getValue();
  }
  
  public boolean getValueIsAdjusting() 
  {
    return scrollBar.getValueIsAdjusting();
  }
  
  public int getVisibleAmount() 
  {
    return scrollBar.getVisibleAmount();
  }
  
  public void removeAdjustmentListener(AdjustmentListener listener) 
  {
    scrollBar.removeAdjustmentListener(listener);
  }
  
  public void setBlockIncrement(int blockIncrement) 
  {
    scrollBar.setBlockIncrement(blockIncrement);
  }
  
  public void setEnabled(boolean b) 
  {
    scrollBar.setEnabled(b);
  }
  
  public void setMaximum(int maximum) 
  {
    scrollBar.setMaximum(maximum);
  }
  
  public void setMinimum(int minimum) 
  {
    scrollBar.setMinimum(minimum);
  }
  
  public void setModel(BoundedRangeModel newModel) 
  {
    scrollBar.setModel(newModel);
  }
  
  public void setOrientation(int orientation) 
  {
    scrollBar.setOrientation(orientation);
  }
  
  public void setUnitIncrement(int unitIncrement) 
  {
    scrollBar.setUnitIncrement(unitIncrement);
  }
  
  public void setValue(int value) 
  {
    scrollBar.setValue(value);
  }
  
  public void setValueIsAdjusting(boolean b) 
  {
    scrollBar.setValueIsAdjusting(b);
  }
  
  public void setValues(int newValue, int newExtent, int newMin, int newMax) 
  {
    scrollBar.setValues(newValue, newExtent, newMin, newMax);
  }
  
  public void setVisibleAmount(int extent) 
  {
    scrollBar.setVisibleAmount(extent);
  }
  
  public void updateUI()
  {
    if (scrollBar != null)
      scrollBar.updateUI();
  }
}

Listing 2

import java.awt.*;
import javax.swing.*;
import javax.swing.event.*;

public class JTabPane extends JPanel
  implements ChangeListener
{
  protected JTabBar tab;
  protected TabLayout layout;
  protected SingleSelectionModel model =
    new DefaultSingleSelectionModel();

  public JTabPane()
  {
    tab = new JTabBar();
    setLayout(layout = new TabLayout());
    model.addChangeListener(this);
    tab.setModel(model);
  }
  
  public JTabBar getTabBar()
  {
    return tab;
  }
  
  public Component add(Component component)
  {
    add(component, component.toString());
    return component;
  }
  
  public void addTab(String title, Component component)
  {
    add(component, title);
    tab.addTab(title);
  }
  
  public void stateChanged(ChangeEvent event)
  {
    int selected = model.getSelectedIndex();
    int count = getComponentCount();
    if (selected <= count)
      layout.show(this, selected);
  }
}

Listing 3

import java.awt.*;
import java.awt.event.*;
import java.awt.font.*;
import java.awt.geom.*;
import java.util.*;
import javax.swing.*;
import javax.swing.event.*;

public class JTabBar extends JPanel
  implements SwingConstants, MouseListener
{
  protected ArrayList labels;
  protected int gap;
  protected Dimension size;
  protected int selected = 0;
  protected Rectangle[] list;
  protected SingleSelectionModel model;

  public JTabBar()
  {
    this(new ArrayList(), 8);
  }
  
  public JTabBar(ArrayList labels)
  {
    this(labels, 8);
  }
  
  public JTabBar(ArrayList labels, int gap)
  {
    setOpaque(true);
    this.labels = labels;
    this.gap = gap;
    addMouseListener(this);
  }
  
  public void setModel(SingleSelectionModel model)
  {
    this.model = model;
  }
  
  public void addTab(String text)
  {
    labels.add(text);
  }
  
  protected Rectangle getTextBounds(Graphics2D g, String text, int x)
  {
    Font font = getFont();
    FontRenderContext context = g.getFontRenderContext();
    TextLayout layout = new TextLayout(text, font, context);
    Rectangle2D rect = layout.getBounds();
    return new Rectangle((int)rect.getX() + x, (int)rect.getY(),
      (int)rect.getWidth(), (int)rect.getHeight());
  }
  
  public Dimension calculateSize(Graphics2D g, int gap)
  {
    int width = 0;
    int height = 16;
    int count = labels.size();
    for (int i = 0; i < count; i++)
    {
      Rectangle rect = getTextBounds(g, labels.get(i).toString(), 0);
      width += rect.width + gap;
      if (rect.height > height)
        height = rect.height;
    }
    width += gap + 1;
    return new Dimension(width, height);
  }
  
  public Dimension getPreferredSize()
  {
    if (size == null)
      size = calculateSize((Graphics2D)getGraphics(), gap);
    return size;
  }
  
  public void paintComponent(Graphics gc)
  {
    Graphics2D g = (Graphics2D)gc;
    size = calculateSize(g, gap);
    int width = getSize().width;
    int height = getSize().height;
    
    g.setColor(getBackground());
    g.fillRect(0, 0, width, height);
    
    int x = gap;
    list = new Rectangle[labels.size()];
    for (int i = 0; i < labels.size(); i++)
    {
      list[i] = getTextBounds(g, labels.get(i).toString(), x);
      x += list[i].width + gap;
    }
    for (int i = labels.size() - 1; i > selected; i--)
    {
      drawTab(g, list[i], i);
    }
    for (int i = 0; i <= selected; i++)
    {
      drawTab(g, list[i], i);
    }
  }
  
  protected void drawTab(Graphics2D g, Rectangle rect, int index)
  {
    int height = getSize().height - 2;
    GeneralPath fill = new GeneralPath();
    fill.moveTo(rect.x - gap, 0);
    fill.lineTo(rect.x, height);
    fill.lineTo(rect.x + rect.width + 1, height);
    fill.lineTo(rect.x + rect.width + gap + 1, 0);
    
    g.setColor(index == selected ? Color.white : getBackground());
    g.fill(fill);
    
    GeneralPath draw = new GeneralPath();
    draw.moveTo(rect.x - gap, 0);
    draw.lineTo(rect.x, height);
    draw.lineTo(rect.x + rect.width, height);
    draw.lineTo(rect.x + rect.width + gap, 0);
    
    g.setColor(Color.black);
    g.draw(draw);
    g.drawString(labels.get(index).toString(), rect.x,
      height - ((height - rect.height) / 2) - 1);
  }
  
  public void mousePressed(MouseEvent event)
  {
    if (list == null) repaint();
    int x = event.getX();
    int tolerance = gap / 2;
    for (int i = 0; i < list.length; i++)
    {
      int xx = list[i].x;
      if (x >= xx - tolerance &&
        x <= xx + list[i].width + tolerance)
      {
        selected = i;
        model.setSelectedIndex(i);
        repaint();
        return;
      }
    }
  }
  
  public void mouseReleased(MouseEvent event) {}
  public void mouseClicked(MouseEvent event) {}
  public void mouseEntered(MouseEvent event) {}
  public void mouseExited(MouseEvent event) {}
}