ORIGINAL DRAFT

Text editors commonly offer the ability to split the view you are editing so that you can scroll through different parts of the document individually in the same viewing area. This month, we’re going to implement a component that adds this capability to a text editor, though it can also be used with other components. Figure 1 shows what this looks like when the split panes are resized. Initially, the top and left edges offer a few unused pixels, with a single view in the middle. You can drag these edges, or the intersection of the split bars, and resize these views any time you like.

Figure 1: JSplitView in action. The four views
show the same plain text model, which is shared across multiple instance of JExitArea, each navigable 
independently.

Figure 1: JSplitView in action. The four views show the same plain text model, which is shared across multiple instance of JExitArea, each navigable independently.

The classes in our project are shown in Figure 2. We’ll develop a new layout manager, SplitViewLayout, which extends a base AbstractLayout manager that regular readers will have seen before. We’ll use a SplitPanel to hold the views and a SplitBar object for both vertical and horizontal resizing. The SplitViewFactory is an interface that supports multiple views on a single object. This is done by sharing the Swing model and can be applied to other components using this interface. We’ll provide a concrete implementation in the form of a TextAreaFactory.

Figure 2: JSplitView Classes. JSplitView uses a 
SplitViewFactory to create multiple views, like the default TextAreaFactory, though this is fully customizable.

Figure 2: JSplitView Classes. JSplitView uses a SplitViewFactory to create multiple views, like the default TextAreaFactory, though this is fully customizable.

To keep this article concise, we won’t look at all of the classes. The AbstractLayout, for example, is little more than a convenience base class for common LayoutManager implementations. The SplitBar is a simple transparent JPanel that draws a checkered four-pixel pattern using TexturePaint. Transparency can be turned on and off to offer some visual feedback depending on whether it’s being dragged or not.

We won’t look at the TextAreaFactory class, which is a straight forward implementation of the SplitViewFactory interface. The interface looks like this:

public interface SplitViewFactory
{
  public JComponent createComponentView(Object obj);
  public Object getModel(JComponent comp);
  public void setModel(JComponent comp, Object model);
}

The createComponentView method is called four times by the JSplitView class, once to create each view. On the first returned view, the getModel method will be called (with the first component as it’s argument) and the setModel method will be called on each subsequent view so that they all share the first model. you can still be creative beyond the expected usage for this interface, returning different kinds of views rather than the same type of component, or ignoring the setModel call if you want the views to be for different models. What’s important is that the interface provides the necessary flexibility you need to accomplish a variety of intentions without forcing you to do things a particular way.

Let’s take a look at the three central classes - SplitViewLayout, SplitPanel and JSplitView itself.

Listing 1 shows the SplitViewLayout manager. The main purpose of this layout manager is to layout the four quadrants of the view, with a vertical and horizontal separation specified by a rectangle we call the apex. The apex defines an x and y value and the rectangle’s width and height values define the thickness of the separators. By default, we use a thickness of 4 pixels in each dimension. Not surprisingly, we define constants for each quadrant (NW, NE, SW, SE) and the two components that will be used as vertical and horizontal separators (VERT, HORZ). We also define instance variables to hold each of these components.

You’ll notice that the constructor doesn’t support any options. Most layout managers offer the ability to control the vertical and horizontal gap between components but we do this through the apex rectangle, which can be set separately using the setApex method.

The AbstractLayout class implements everything you need in a layout manager, except for the minimumLayoutSize, preferredLayoutSize and layoutContainer methods, which is left for subclasses to implement. In this case we override the addLayoutComponent method as well. We do this because we want finer control over the components as individual instance variables. This is the way BorderLayout handles the 5 components it supports. If an inappropriate constraint value is provided, we also want to respond by pointing this out using an IllegalArgumentException.

The minimumLayoutSize and preferredLayoutSize methods are fairly straight forward. We collect the minimum or preferred dimensions for each object, default to zero dimensions if none is present, and use these to calculate the outside width and height. The layoutContainer method actually sets the bounds for each component. You’ll notice that we check the isValidateRoot method on each component to respond to cases where the contained component is a JScrollPane and then force a layout and revalidation on the component if this method returns true. This is critical to getting proper behavior out of a JScrollPane, which blocks the layout traversal through the rest of the hierarchy if we don’t do this.

Listing 2 shows SplitPanel, which uses the SplitViewLayout and two SplitBar instances to manage the resizable views. SplitPanel also takes responsibility for handling cursor changes and mouse behavior. We implement both the MouseListener and MouseMotionListener interfaces. Aside from setting up a few instance variables, we also declare references to the standard cursors we’ll be using. The constructor sets the SplitViewLayout manager and adds two SplitBar instances to support vertical and horizontal adjustments, before registering to receive both kinds of mouse events.

We’ll use a couple of convenience methods, containsX and containsY, to test for inclusion in a vertical and horizontal rectangle, respectively. This makes it easier to figure out where the mouse cursor is respective to the vertical and horizontal bar positions by using the apex rectangle. The rest of the class handles the mouse events.

When we handle mouseEntered and mouseExited events, we merely reset the normal cursor to avoid cases where contained components assume this cursor to be set. Since this is normally the case, numerous child components don’t take any action to reset the cursor to an appropriate form, so we want to avoid visual glitches by handling those cases proactively.

When the mouse is pressed, we test for vertical and horizontal containment and flip the transparency to false on appropriate SplitBar instances. When we do this, we force a repaint and set a local variable to keep track of which bars we are moving. Three combinations are possible - we are moving either the horizontal or the vertical bar separately or we are moving both from their intersection point.

When the mouse is released we want to clean things up. We reset the transparency on each SplitBar instance as well as their final position, based on the apex rectangle that we moved around. We call the layout method on the SplitViewLayout manager and force a layout by calling the doLayout method.

The mouseMoved method is primarily implemented to handle cursor changes. We use the predefined N_RESIZE_CURSOR or W_RESIZE_CURSOR if we are over one of the vertical or horizontal SplitBar instances. If we are over their intersection, we use the predefined MOVE_CURSOR which shows arrows moving in all four cardinal directions. Otherwise, we use the default cursor.

Finally, the mouseDragged method handles the quadrant resizing function. Most of the code handles determining where the cursor is and sets appropriate values for the apex rectangle. From the apex, we set suitable positions for the vertical and horizontal bars. You’ll notice that we do not do any layout work, other than moving one or both of the bars, as appropriate, although we do call the repaint method to make sure our changes are visible. The mouseReleased method, you’ll recall, sets the layout manager apex value and makes the call to doLayout to trigger resizing the other child components.

JSplitView ties everything together (Listing 3). In and of itself, it does very little work, delegating most of the work to SplitPanel. Instead we provide two constructors and focus on applying the SplitFactory interface appropriately. The first constructor assumes we are using the TextAreaFactory and handles a single text argument. The second constructor is more generic and supports any SplitViewFactory instance, along with a text value. This isn’t as generic as it could be given the assumption that a text value is useful, but you’re free to extend or modify this class however you see fit.

JSplitView provides users with a subtle feature, which is discoverable. The cursor change is typically sufficient to encourage the kind of experimentation that leads a user to discovering the feature’s availability. This is a common enough editor feature that basic functionality can be inferred from experience in most cases. It’s especially notable that this is one of those functions that stays tucked away until needed, yet enhances a user interface in meaningful ways in those cases where the user may need it. With JSplitView, this feature is available whenever you need it.

Listing 1

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

public class SplitViewLayout
  extends AbstractLayout
{
  public static final String NW = "NW";
  public static final String NE = "NE";
  public static final String SW = "SW";
  public static final String SE = "SE";
  public static final String VERT = "VERT";
  public static final String HORZ = "HORZ";
  
  protected Rectangle apex = new Rectangle(0, 0, 4, 4);
  protected JComponent nw, ne, sw, se;
  protected Component vert, horz;
  
  public SplitViewLayout() {}
  
  public void setApex(Rectangle apex)
  {
    this.apex = apex;
  }
  
  public void addLayoutComponent(Component c, Object constraints)
  {
    JComponent comp = (JComponent)c;
    synchronized (comp.getTreeLock())
    {
      if (constraints.equals(NW)) nw = comp;
      else if (constraints.equals(NE)) ne = comp;
      else if (constraints.equals(SW)) sw = comp;
      else if (constraints.equals(SE)) se = comp;
      else if (constraints.equals(VERT)) vert = comp;
      else if (constraints.equals(HORZ)) horz = comp;
      else throw new IllegalArgumentException(
        "Unrecognized constrain value: " +
          constraints.toString());
    }
  }
  
  public Dimension minimumLayoutSize(Container c)
  {
    Dimension nwSize = nw != null ?
      nw.getMinimumSize() : new Dimension(0, 0);
    Dimension neSize = ne != null ?
      ne.getMinimumSize() : new Dimension(0, 0);
    Dimension swSize = sw != null ?
      sw.getMinimumSize() : new Dimension(0, 0);
    Dimension seSize = se != null ?
      se.getMinimumSize() : new Dimension(0, 0);
    Dimension vertSize = vert != null ?
      vert.getMinimumSize() : new Dimension(0, 0);
    Dimension horzSize = horz!= null ?
      horz.getMinimumSize() : new Dimension(0, 0);
    int w = horzSize.width +
      Math.max(nwSize.width, swSize.width) +
      Math.max(neSize.width, seSize.width);
    int h = vertSize.height +
      Math.max(nwSize.height, neSize.height) +
      Math.max(swSize.height, seSize.height);
    return new Dimension(w, h);
  }
  
  public Dimension preferredLayoutSize(Container c)
  {
    Dimension nwSize = nw != null ?
      nw.getPreferredSize() : new Dimension(0, 0);
    Dimension neSize = ne != null ?
      ne.getPreferredSize() : new Dimension(0, 0);
    Dimension swSize = sw != null ?
      sw.getPreferredSize() : new Dimension(0, 0);
    Dimension seSize = se != null ?
      se.getPreferredSize() : new Dimension(0, 0);
    Dimension vertSize = vert != null ?
      vert.getPreferredSize() : new Dimension(0, 0);
    Dimension horzSize = horz!= null ?
      horz.getPreferredSize() : new Dimension(0, 0);
    int w = horzSize.width +
      Math.max(nwSize.width, swSize.width) +
      Math.max(neSize.width, seSize.width);
    int h = vertSize.height +
      Math.max(nwSize.height, neSize.height) +
      Math.max(swSize.height, seSize.height);
    return new Dimension(w, h);
  }
  
  public void layoutContainer(Container c)
  {
    int w = c.getSize().width;
    int h = c.getSize().height;
    int r = apex.x + apex.width;
    int b = apex.y + apex.height;
    if (nw != null)
    {
      nw.setBounds(0, 0, apex.x, apex.y);
      if (nw.isValidateRoot())
      {
        nw.doLayout();
        nw.revalidate();
      }
    }
    if (ne != null)
    {
      ne.setBounds(r, 0, w - r, apex.y);
      if (ne.isValidateRoot())
      {
        ne.doLayout();
        ne.revalidate();
      }
    }
    if (sw != null)
    {
      sw.setBounds(0, b, apex.x, h - b);
      if (sw.isValidateRoot())
      {
        sw.doLayout();
        sw.revalidate();
      }
    }
    if (se != null)
    {
      se.setBounds(r, b, w - r, h - b);
      if (se.isValidateRoot())
      {
        se.doLayout();
        se.revalidate();
      }
    }
  }
}

Listing 2

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

public class SplitPanel extends JPanel
  implements MouseListener, MouseMotionListener
{
  protected Rectangle apex;
  protected SplitViewLayout layout;
  protected SplitBar vert, horz;
  protected int xAnchor = -1;
  protected int yAnchor = -1;
  
  protected Cursor normalCursor =
    Cursor.getPredefinedCursor(
      Cursor.DEFAULT_CURSOR);
  protected Cursor verticalCursor =
    Cursor.getPredefinedCursor(
      Cursor.N_RESIZE_CURSOR);
  protected Cursor horizontalCursor =
    Cursor.getPredefinedCursor(
      Cursor.W_RESIZE_CURSOR);
  protected Cursor resizeCursor =
    Cursor.getPredefinedCursor(
      Cursor.MOVE_CURSOR);

  public SplitPanel()
  {
    setOpaque(false);
    apex = new Rectangle(0, 0, 4, 4);
    setLayout(layout = new SplitViewLayout());
    add(vert = new SplitBar(), SplitViewLayout.VERT);
    add(horz = new SplitBar(), SplitViewLayout.HORZ);
    addMouseMotionListener(this);
    addMouseListener(this);
  }

  protected boolean containsX(Rectangle rect, int x)
  {
    return x >= rect.x && x <= rect.x + rect.width;
  }
  
  protected boolean containsY(Rectangle rect, int y)
  {
    return y >= rect.y && y <= rect.y + rect.height;
  }
  
  public void mouseEntered(MouseEvent event)
  {
    setCursor(normalCursor);
  }
  
  public void mouseExited(MouseEvent event)
  {
    setCursor(normalCursor);
  }
  
  public void mouseClicked(MouseEvent event) {}
  
  public void mousePressed(MouseEvent event)
  {
    int x = event.getX();
    int y = event.getY();
    if (containsX(apex, x))
    {
      vert.setTransparency(false);
      vert.repaint();
      xAnchor = x;
    }
    if (containsY(apex, y))
    {
      horz.setTransparency(false);
      horz.repaint();
      yAnchor = y;
    }
  }
  
  public void mouseReleased(MouseEvent event)
  {
    xAnchor = -1;
    yAnchor = -1;
    int w = getSize().width;
    int h = getSize().height;
    horz.setTransparency(true);
    horz.setBounds(0, apex.y, w, apex.height);
    vert.setTransparency(true);
    vert.setBounds(apex.x, 0, apex.width, h);
    layout.setApex(apex);
    doLayout();
  }
  
  public void mouseMoved(MouseEvent event)
  {
    int x = event.getX();
    int y = event.getY();
    
    boolean horz = containsX(apex, x);
    boolean vert = containsY(apex, y);
    if (vert && horz)
    {
      setCursor(resizeCursor);
    }
    else if (horz)
    {
      setCursor(horizontalCursor);
    }
    else if (vert)
    {
      setCursor(verticalCursor);
    }
    else
    {
      setCursor(normalCursor);
    }
  }
  
  public void mouseDragged(MouseEvent event)
  {
    int w = getSize().width;
    int h = getSize().height;
    int x = event.getX();
    int y = event.getY();
    if (xAnchor == -1) x = apex.x;
    if (yAnchor == -1) y = apex.y;
    if (x < 0) x = 0;
    if (y < 0) y = 0;
    int ww = w - apex.width;
    int hh = h - apex.height;
    if (x > ww) x = ww;
    if (y > hh) y = hh;
    apex = new Rectangle(x, y, 4, 4);
    
    horz.setBounds(0, apex.y, w, apex.height);
    vert.setBounds(apex.x, 0, apex.width, h);
    repaint();
  }
}

Listing 3

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

public class JSplitView extends JPanel
{
  public JSplitView(String text)
  {
    this(new TextAreaFactory(), text);
  }
  
  public JSplitView(SplitViewFactory factory, Object obj)
  {
    setLayout(new GridLayout());
    JComponent nw = factory.createComponentView(obj);
    JComponent ne = factory.createComponentView(null);
    JComponent sw = factory.createComponentView(null);
    JComponent se = factory.createComponentView(null);
    
    Object model = factory.getModel(nw);
    factory.setModel(ne, model);
    factory.setModel(sw, model);
    factory.setModel(se, model);
    
    SplitPanel panel = new SplitPanel();
    panel.add(SplitViewLayout.NW, new JScrollPane(nw));
    panel.add(SplitViewLayout.NE, new JScrollPane(ne));
    panel.add(SplitViewLayout.SW, new JScrollPane(sw));
    panel.add(SplitViewLayout.SE, new JScrollPane(se));
    add(panel);
  }
}