ORIGINAL DRAFT

Numerous applications deal with ranged information, including project management, music composition software, schedulers and more. Ranges can be modeled using Swing’s BoundedRangeModel, which is already used in scroll bars and sliders. Unfortunately, none of the existing Swing components support dynamically adjusting the extent of a range. This month, we’ll develop a JRange component that allows users to view and edit bounded ranges.

Figure 1: JRange at work. Each range beginning
and end point can be moved with the mouse, and the bar can be dragged to move the whole thing around.

Figure 1: JRange at work. Each range beginning and end point can be moved with the mouse, and the bar can be dragged to move the whole thing around.

To maximize flexibility, we’ll support two rendering options, one for the range itself and another for the editing markers at each end, allowing you to customize the component’s visual display. I’ve provided both a default renderer and editor, which you can see in Figure 1, and a Gantt chart renderer and editor to demonstrate a typical application. Figure 2 shows the Gantt chart renderer and editor in both TASK and GROUP modes. The TASK option is shown as a solid green bar. The GROUP option is shown as a half-thickness black bar with diamond-like editor elements.

Figure 2: JRange using the 
GanttRangeRenderer and GanttRangeEditor options.

Figure 2: JRange using the GanttRangeRenderer and GanttRangeEditor options.

There are several classes in this project and we are, as always, somewhat limited by space. While we won’t be able to explore each class in detail, you can find them all online at www.javapro.com. Figure 3 shows the relationship between classes in the project. We’ll use two interfaces, the RangeRenderer and RangeEditor, to uncouple the view from the rest of our component. JRange uses the Swing BounderRangeModel to represent the data.

Figure 3: JRange and related classes. We use the
RangeEditor and RangeRenderer interfaces to maximize flexibility and offer two concrete implementations 
to show how they can be used.

Figure 3: JRange and related classes. We use the RangeEditor and RangeRenderer interfaces to maximize flexibility and offer two concrete implementations to show how they can be used.

The RangeRenderer interface provides a flexible mechanism that allows us to substitute the view that displays the actual range of values in the BoundedRangeModel. The interface is straight forward:

public interface RangeRenderer
{
  public JComponent
    getRangeRendererComponent(
      JRange range,
      BoundedRangeModel model,
      boolean hasFocus);
}

A RangeRenderer draws both the background of the JRange view as well as the bounded range itself, providing sufficient control over the view to ensure any visual component can be rendered. We pass in references to the JRange component, the bounded range model and a flag indicating whether we have the focus or not.

The RangeEditor interface defines a contract that editors are expected to implement. The interface looks like this:

public interface RangeEditor
{
  public int getWidth();
  public JComponent
    getRangeEditorComponent(
      JRange range,
      BoundedRangeModel model,
      boolean hasFocus);
}

A RangeEditor draws the two ends of the bounded range when the range is editable. It is possible to disable editing with JRange, in which case the RangeEditor will not be called. It is also possible that the RangeEditor draws nothing, such as when the GanttRangeEditor is in TASK mode, displaying a solid bar that does not require and visual cues for editing. The getWidth method is provided as a mechanism for determining how wide the selection area is expected to be. This method is used by JRange to decide when the resize cursor should be display and whether dragging results in resizing or movement.

One of the classes show in Figure 3 is BoundedRangeUtil. I’ll mention it here but we won’t have a change to take a closer look. This class provides a set of static methods that make it easier to calculate and set values in a BoundedRangeModel. You’ll notice it’s used heavily by each of the rendering and editing classes, as well as the JRange component itself.

We’ll skip the default renderer and editor implementations. They represent a form of eye candy that makes the display interesting, but it’s likely you’ll want to create your own renderer and editor implementations for various application-specific reasons. We’ll focus on the GanttRangeRenderer, GanttRangeEditor and JRange classes. If you download the code, you’ll also find a JRangeTest class that you can use to reproduce the screen shots in Figures 1 and 2.

Because Gantt charts involve individual tasks as well as ranges that present collections of tasks, our renderer and editor designs accept an integer value that determines which type of display to present, as a constructor argument. Static final values are declared in each class, though they could be centralized into a RangeConstants interface.

The GanttRangeRenderer class, presented in Listing 1, stores each of the arguments from the RangeRenderer interface method as instance variables and does most of the work in the paintComponent method. We first clear the background using the JRange to fetch the background color. The code uses a pair of BoundedRangeUtil methods for scaling the position and extent values to fit the drawing area.

With these values in hand, all we have to do is draw a rectangle of appropriate size in a suitable color. If the type value is TASK, the bar is draw from the start position for the specified extent from top to bottom. If the type is GROUP, the bar is draw only in the upper half of the display. A TASK is draw in green unless we have the focus, and a GROUP is draw using the foreground color from the JRange component. In both cases, the color blue is used if the component has focus.

The code for GanttRangeEditor is presented in Listing 2. Like the GanttRangeRenderer class, the type is provided as an argument in the constructor and the values from the main method in the RangeEditor interface are stored in a set of instance variables. The getWidth method returns a wider area for selection if we are working with the GROUP type. Once again, most of the work is handled by the paintComponent method.

Because the same polygon is drawn at both ends of the range, I’ve provided a makePolygon method that makes it easier to construct the Shape from basic arguments. The makePolygon method is called twice in paintComponent. The polygons are drawn using the JRange foreground color, unless we have the focus, in which case they are drawn in blue. The TASK type draws nothing, though the getWidth method ensures that it is still resizable in edit mode. Be aware that a width settings of 0 will result in a non-editable range.

The JRange class in Listing 3 is the main component class. It implements a large number of interfaces, including ChangeListener to listen for changes in the BoundedRangeModel, MouseListener and MouseMotionListener to handle mouse events, KeyListener to handle keyboard events and the FocusListener interface to ensure changes in focus are reflected in the visual appearance of the component.

There are a number of static and instance variables declared up front. The cursors we use are all declared as static references to make them easier to access. We use value and anchor variables to handle dragging relative to a position where the mouse was first pressed. The draw, left and right variables indicate states that flag whether we are resizing from left or right or dragging the range. The editable and hasFocus values are used to determine whether we are in edit mode and whether the component has the focus or not, respectively.

The last four variables store the BoundedRangeModel, RangeRenderer and RangeEditor and a reference to a CellRendererPane to do the actual drawing. The constructor expects a value, extent, minimum and maximum value. The value and extent are the position and length of the range and must be within the minimum and maximum range. In the case of extent, the position added to the extent must be less than the maximum value. Aside from setting up a background color, preferred size and a suitable BoundedRangeModel, we call setEditable with a default value of true.

The isEditable and setEditable methods handle the editable flag/variable. If editing is activated, we register mouse, keyboard and focus listeners. The same listeners are removed if editing is turned off. We provide accessors for the renderer and editor, both to get and set them, as well as model accessors. The setModel method also registers the ChangeListener, removing any previous listener if necessary.

The paintComponent method does the actual drawing, relying on the CellRendererPane to delegate drawing to the renderer and, if editable, the editor component. In both cases, we call the interface to get a suitable component and scale it to cover the whole display area. This makes it possible to render any view we like. Keep in mind if you design your own editor, that it gets drawn over the renderer display and should be kept transparent unless you intentionally want to wipe clean the rendered drawing area.

The next four methods handle changes to the model and focus traversal. If the model changes, all we have to do is repaint the display to correctly reflect any changes. The focus handling, involves something similar in that we want to repaint the display after setting the hasFocus flag to reflect the current state. We also implement the isFocusTraversable method to tell Swing that the component is capable of handling focus events.You’ll notice that we use the editable flag to determine whether it’s worth gaining the focus. There’s little point in having the focus if the user can’t make any changes.

Keyboard handling is fairly straight forward. The keyPressed event determines whether we adjust the model. If the left or right keys are used, the size (or extent to be more specific) of the range is adjusted either incrementing or decrementing, respectively. If the control key is pressed, and the left or right keys are used, we move the range in the specified direction instead.

The mouse listener interfaces handle both cursor settings and adjustments when JRange is editable. If the mouse is clicked, we request the focus and determine whether we are adjusting the left, right or moving the range. The mouseDragged event does the actual adjusting. The mouseExited, mouseReleased and mouseMoved events change the state of the cursor.

JRange provides a mechanism for users to view and modify boundaries within a range of values. The boundaries can easily be changed, and the whole range can be moved within the range of possible values. This is a common requirement, especially in project management software. With loose coupling between a model, view and editor, the component can easily be customized with minimal effort. I hope this technique serves you well.

Listing 1

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

public class GanttRangeRenderer extends JPanel
  implements RangeRenderer
{
  public static final int TASK = 1;
  public static final int GROUP = 2;
  
  protected int type;
  protected JRange range;
  protected BoundedRangeModel model;
  protected boolean hasFocus;
  
  public GanttRangeRenderer(int type)
  {
    this.type = type;
  }
  
  public JComponent getRangeRendererComponent(
    JRange range, BoundedRangeModel model,
    boolean hasFocus)
  {
    this.range = range;
    this.model = model;
    this.hasFocus = hasFocus;
    return this;
  }
  
  protected void paintComponent(Graphics g)
  {
    int w = getSize().width;
    int h = getSize().height;
    g.setColor(range.getBackground());
    g.fillRect(0, 0, w, h);
    
    int pos = BoundedRangeUtil.getScaledValuePos(model, w);
    int len = BoundedRangeUtil.getScaledExtentLen(model, w);
    if (type == GROUP)
    {
      int m = h / 2;
      g.setColor(hasFocus ? Color.blue : range.getForeground());
      g.fillRect(pos, 0, len, m + 1);
    }
    else
    {
      g.setColor(hasFocus ? Color.blue : Color.green);
      g.fillRect(pos, 0, len, h);
    }
  }
}

Listing 2

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

public class GanttRangeEditor extends JPanel
  implements RangeEditor
{
  public static final int TASK = 1;
  public static final int GROUP = 2;
  
  protected int type;
  protected JRange range;
  protected BoundedRangeModel model;
  protected boolean hasFocus;
  
  public GanttRangeEditor(int type)
  {
    this.type = type;
  }
  
  public int getWidth()
  {
    return type == GROUP ? 5 : 3;
  }
  
  public JComponent getRangeEditorComponent(
    JRange range, BoundedRangeModel model,
    boolean hasFocus)
  {
    this.range = range;
    this.model = model;
    this.hasFocus = hasFocus;
    return this;
  }
  
  protected void paintComponent(Graphics g)
  {
    if (type == GROUP)
    {
      int w = getSize().width;
      int h = getSize().height;
      int val = BoundedRangeUtil.getScaledValuePos(model, w);
      int pos = BoundedRangeUtil.getScaledExtentPos(model, w);
      Polygon poly1 = makePolygon(val, h);
      Polygon poly2 = makePolygon(pos - 1, h);
      g.setColor(hasFocus ? Color.blue : range.getForeground());
      g.fillPolygon(poly1);
      g.fillPolygon(poly2);
    }
  }

  protected Polygon makePolygon(int x, int h)
  {
    int m = h / 2;
    Polygon poly = new Polygon();
    poly.addPoint(x - m, 0);
    poly.addPoint(x + m, 0);
    poly.addPoint(x + m, m);
    poly.addPoint(x, h);
    poly.addPoint(x - m, m);
    return poly;
  }
}

Listing 3

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

public class JRange extends JPanel
  implements ChangeListener, KeyListener,
    MouseListener, MouseMotionListener,
    FocusListener
{
  public static final Cursor DEFAULT_CURSOR =
    Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR);
  public static final Cursor ADJUST_CURSOR =
    Cursor.getPredefinedCursor(Cursor.W_RESIZE_CURSOR);
  public static final Cursor DRAG_CURSOR =
    Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);

  protected int value, anchor;
  protected boolean drag = false;
  protected boolean left = false;
  protected boolean right = false;
  protected boolean editable = true;
  protected boolean hasFocus = false;
  
  protected BoundedRangeModel model;
  protected RangeRenderer renderer = new DefaultRangeRenderer();
  protected RangeEditor editor = new DefaultRangeEditor();
  protected CellRendererPane rendererPane = 
    new CellRendererPane();
  
  public JRange(int val, int len, int min, int max)
  {
    setBackground(Color.white);
    setPreferredSize(new Dimension(400, 16));
    setModel(new DefaultBoundedRangeModel(val, len, min, max));
    setEditable(true);
  }
  
  protected boolean isEditable()
  {
    return editable;
  }
  
  public void setEditable(boolean editable)
  {
    this.editable = editable;
    if (editable)
    {
      addFocusListener(this);
      addMouseMotionListener(this);
      addMouseListener(this);
      addKeyListener(this);
    }
    else
    {
      removeFocusListener(this);
      removeMouseMotionListener(this);
      removeMouseListener(this);
      removeKeyListener(this);
    }
  }
  
  public RangeRenderer getRenderer()
  {
    return renderer;
  }
  
  public void setRenderer(RangeRenderer renderer)
  {
    this.renderer = renderer;
  }
  
  public RangeEditor getEditor()
  {
    return editor;
  }
  
  public void setEditor(RangeEditor editor)
  {
    this.editor = editor;
  }
  
  public void setModel(BoundedRangeModel model)
  {
    if (this.model != null)
    {
      this.model.removeChangeListener(this);
    }
    this.model = model;
    model.addChangeListener(this);
  }
  
  public BoundedRangeModel getModel()
  {
    return model;
  }
  
  public void paintComponent(Graphics g)
  {
    Insets insets = getInsets();
    int x = insets.left;
    int y = insets.top;
    int w = getSize().width;
    int h = getSize().height;
    w -= (insets.left + insets.right);
    h -= (insets.top + insets.bottom);
    JComponent rendererComponent =
      renderer.getRangeRendererComponent(this, model, hasFocus);
    rendererPane.paintComponent(g, rendererComponent, this, x, y, w, h);
    if (editable)
    {
      JComponent editorComponent =
        editor.getRangeEditorComponent(this, model, hasFocus);
      rendererPane.paintComponent(g, editorComponent, this, x, y, w, h);
    }
  }

  public void stateChanged(ChangeEvent event)
  {
    repaint();
  }
  
  public boolean isFocusTraversable()
  {
    return editable;
  }
  
  public void focusGained(FocusEvent event)
  {
    hasFocus = true;
    repaint();
  }
  
  public void focusLost(FocusEvent event)
  {
    hasFocus = false;
    repaint();
  }
  
  public void keyTyped(KeyEvent event) {}
  public void keyReleased(KeyEvent event) {}
  public void keyPressed(KeyEvent event)
  {
    int code = event.getKeyCode();
    if (event.isControlDown())
    {
      int val = model.getValue();
      if (code == KeyEvent.VK_RIGHT)
      {
        BoundedRangeUtil.setValue(model, val + 1);
      }
      if (code == KeyEvent.VK_LEFT)
      {
        BoundedRangeUtil.setValue(model, val - 1);
      }
    }
    else
    {
      int len = model.getExtent();
      if (code == KeyEvent.VK_RIGHT)
      {
        BoundedRangeUtil.setExtent(model, len + 1);
      }
      if (code == KeyEvent.VK_LEFT)
      {
        BoundedRangeUtil.setExtent(model, len - 1);
      }
    }
  }
  
  public void mouseEntered(MouseEvent event) {}
  
  public void mouseExited(MouseEvent event) {}
  {
    setCursor(DEFAULT_CURSOR);
  }
  
  public void mousePressed(MouseEvent event)
  {
    requestFocus();
    Insets insets = getInsets();
    int w = getSize().width;
    w -= (insets.left + insets.right);
    int lft = BoundedRangeUtil.getScaledValuePos(model, w);
    int rgt = BoundedRangeUtil.getScaledExtentPos(model, w);
    int x = event.getX() - insets.left;
    int ww = (editor == null) ? 0 : editor.getWidth();
    if (x > lft - ww && x < lft + ww)
    {
      left = true;
    }
    if (x > rgt - ww && x < rgt + ww)
    {
      right = true;
    }
    if (x > lft + ww && x < rgt - ww)
    {
      drag = true;
      anchor = event.getX() - insets.left;
      value = model.getValue();
    }
  }
  
  public void mouseReleased(MouseEvent event)
  {
    drag = false;
    left = false;
    right = false;
    setCursor(DEFAULT_CURSOR);
  }
  
  public void mouseClicked(MouseEvent event) {}
  
  public void mouseMoved(MouseEvent event)
  {
    Insets insets = getInsets();
    int w = getSize().width;
    w -= (insets.left + insets.right);
    int lft = BoundedRangeUtil.getScaledValuePos(model, w);
    int rgt = BoundedRangeUtil.getScaledExtentPos(model, w);
    int x = event.getX() - insets.left;
    int ww = (editor == null) ? 0 : editor.getWidth();
    if ((x > lft - ww && x < lft + ww) ||
        (x > rgt - ww && x < rgt + ww))
    {
      setCursor(ADJUST_CURSOR);
    }
    else if (x > lft + ww && x < rgt - ww)
    {
      setCursor(DRAG_CURSOR);
    }
    else
    {
      setCursor(DEFAULT_CURSOR);
    }
  }
  
  public void mouseDragged(MouseEvent event)
  {
    Insets insets = getInsets();
    int w = getSize().width;
    w -= (insets.left + insets.right);
    int val = model.getValue();
    int len = model.getExtent();
    double range = BoundedRangeUtil.getRange(model);
    double unit = (double)w / range;
    int x = event.getX() - insets.left;
    int pos = (int)((double)x / unit);
    if (drag)
    {
      int diff = (int)((double)(x - anchor) / unit);
      BoundedRangeUtil.setValue(model, value + diff);
    }
    if (left)
    {
      BoundedRangeUtil.setValue(model, pos);
      pos = model.getValue(); // Clipped
      BoundedRangeUtil.setExtent(model, len + (val - pos));
    }
    if (right)
    {
      BoundedRangeUtil.setExtent(model, pos - val);
    }
  }
}