ORIGINAL DRAFT

A lot of modern applications allow you to control windows along the edge of a main view, making them visible or invisible and resizing them interactively with a split bar. This month we’ll implement a component that lets you do the same thing either vertically, with an optional north and south window, or horizontally with an optional west and east window. In each case, you can frame a central window and our component can easily be nested to create more sophisticated interfaces.

JWindowPane works by using two nested JSplitPane instances, each of which can have one of their components hidden. Each JSplitPane operates normally but one of the panes is dedicated to a dismissable window, which may be either active or inactive. When inactive, the window is hidden and the split bar is placed on the edge with a size of zero. When the window is active, it is visible and displayed with a title bar that has a button used to dismiss the window interactively.

Figure 1: Two nested JWindowPane, the outside one
is VERTICAL and the inside one is HORIZONTAL. Each JWindowPane has two edge windows and a center component.

Figure 1: Two nested JWindowPane, the outside one is VERTICAL and the inside one is HORIZONTAL. Each JWindowPane has two edge windows and a center component.

Since there is no way to retrieve the hidden window component, we’ll make it easy to listen for change events in order to keep menu items, or other components, in synch with the current state. When a ChangeEvent is received, a ChangeListener can get the current state of the JWindowPane to determine whether the window is visible, using methods we’ll build into our design.

By convention, we’ll use the NORTH, SOUTH, EAST, WEST positions to designate a window component. When you create a JWindowPane, you can specify a VERTICAL or HORIZONTAL orientation. When the orientation is VERTICAL, methods referencing the north and south components can be used. When the orientation is HORIZONTAL, methods referencing the east and west components can be used. In addition to the NORTH/SOUTH or EAST/WEST components, JWindowPane also supports a component placed in the center. In our example, we’ve nested a VERTICAL JWindowPane inside a HORIZONTAL one to achieve the results you see in Figure 1.

There are only a few classes in this project. Figure 2 shows their relationship. The relationships are pretty linear. JWindowPaneTest uses JWindowPane, which in turn uses a WindowPane class, which includes a WindowPaneBar, which uses a RolloverButton with a TitleBarIcon. We’ll concentrate our focus on the WindowPane and JWindowPane classes, with a look at JWindowPaneTest to explain how you might use the component in context.

The RolloverButton merely extends JButton and controls the border by watching for mouse events. The constructors mimic the JButton constructors but calls a setup method I’ve added. The setup method sets the button to be transparent with no border by default. It also makes it unable to receive the focus and explicitly sets the margins to 2 all the way around to avoid problems in certain look-and-feel implementations. We handle the mouseEntered and mouseExited events by calling setBorderPainted on-the-fly. There is a flag that makes it possible to use rollover buttons in Swing’s Metal look-and-feel but this is not a feature that works in any other look-and-feel, so I’ve avoided using it.

Figure 2: JWindowPane and supporting classes. The
WindowPane class holds components that can be dismissed and reactivated inside a JWindowPane.

Figure 2: JWindowPane and supporting classes. The WindowPane class holds components that can be dismissed and reactivated inside a JWindowPane.

The WindowPaneBar sets up a JPanel with a JLabel on the left and a RolloverButton on the right. It uses the UIManager to get the JInternalFrame’s active title color choices for the current look-and-feel and provides a setTitle method so that the text can be set from another object. Most of it’s behavior centers on responding to an action event coming from the RolloverButton, which causes it to call the setActive method on the WindowPane reference provided in the constructor. The only other constructor argument is the text used for the title.

The TitleBarIcon is an implementation of the Icon interface that draws a scalable ‘X’. I decided to approach the icon this way for portability. Using an image is a good idea when the component size is predictable, but I wasn’t sure about the ideal size for this design and it’s easy to do the drawing on-the-fly with an Icon class. It would have been just as easy to reference an image using the ImageIcon class.

Let’s take a look at the two central classes (JWindowPane and WindowPane) and then we’ll look at how JWindowPane can be used in context with the JWindowPaneTest class. WindowPane is used by JWindowPane as a container for components that can be dismissed. Let’s look at that one first. You can check out the code for WindowPane in Listing 1.

WindowPane is a JPanel that uses a BorderLayout to put the WindowPaneBar in the NORTH position and a child component in the CENTER. The constructors expect a reference to the JSplitPane in which the WindowPane is to be placed. The first constructor variant assumes an empty title but the second one does most of the work, keeping a reference to the JSplitPane, setting up the layout and adding a WindowPaneBar with the specified title.

WindowPane provides three key methods. The setTitle method uses the setTitle method we exposed in the WindowPaneBar to set the text. The setComponent method places the component in the BorderLayout CENTER position. The setActive method is the most important. It determines the referenced JSplitPane’s orientation and whether it is in the Top or Left position. If so, it sets the divider location to the height or width of the component, depending on the orientation. Otherwise, it does some calculations first to figure out where the right or bottom divider position should be. The divider size is set normally if the active flag is true and to zero otherwise. Finally, the visibility is set, based on the active flag.

Armed with a mechanism for displaying and hiding windows, we can make better sense of the JWindowPane component in Listing 2. We store a few instance variables to track the orientation, as well as the edge WindowPane and JSplitPane instances. I’ve used the north/south convention for naming and mapped west/east on to those variable names where necessary, as you’ll see later in the code. We also manage an ArrayList of change listeners so we can notify other interested parties when WindowPane states change.

There’s are two constructors in JWindowPane, the first one does most of the work. Depending on the orientation, it sets up the JSplitPane instances with suitable WindowPane children. Once the children are properly seated, we add JWindowPane as a ComponentListener and clear the JSplitPane borders before adding things to JWindowPane as a central GridLayout component. The use of null borders in JSplitPane is purely esthetic but avoids problems when borders don’t match or when children add their own borders. In practice, it’s usually best to use JSplitPane instances without borders.

The second constructor provides a shorthand for calling the first constructor and then calling each of the common methods in order. It allows you to specify both the edge components and titles, as well as the center component in a simple call. You’ll notice that it does this by calling a number of methods to set these and that the north and south orientations are used, even if we’re dealing with west and east components. Event though we explicitly provide the east/west variants for each method, they are very similar and often map directly onto their north/south equivalents.

The first set of methods let you set the north/south, east/west title, followed by a set of methods that let you set components. These have the form set<edge>Title and set<edge>Component. The same pattern is used for the set<edge>Active and the is<edge>Active methods, all of which provide access to the current WindowPane state. There’s also a setCenterComponent method that lets you control the component in the center position.

There are three methods that address the divider location in different ways. The resetPreferredDividerLocations can be called externally to used the preferred size of the edge components as a guide for positioning. I’ve used this in the componentResized callback from the ComponentListener implementation to decide how to respond to resize events. You may not like this approach since it may obviate user-selected positions for the split bars, so you can change that behavior if you prefer the alternative.

The other two divider-handling methods are not for external use. The adjustPreferredDividerLocation is used by the set<edge>Component methods to use the preferred size of the child component as an initial position for JSplitPane dividers. It branches based on the north/south, east/west position of the component and uses the getPreferredDividerLocation to do the calculation. The math is simple enough but nicely abstracted this way so that it only has to be written once. Unfortunately, we depend on the current size of the JWindowPane to do these, so results may vary if the call is made before containers have had a chance to determine their visible size.

There is a short set of methods to implement the ComponentListener interface. We’ve mentioned the resize event. The other two we’re interested in are the hide and show events. When these are called on the child components, we call fireChangeEvent to notify listeners. The fireChangeEvent call walks through the list of listeners and calls stateChanged on each. The addChangeListener and removeChangeListener methods are provided to manage the ArrayList.

Let’s take a look at JWindowPaneTest (Listing 3), so that we can get a better idea about how JWindowPane can be used in context. The trick with JWindowPane is that the user must have a way of resurrecting a dismissed window. While we provide a way of dismissing the window (using the RolloverButton on WindowPaneBar), there is no interface element that restores the window. The standard way of doing this is menu-based, so we’ll provide an example, though you could use an alternate approache and JWindowPane intentionally avoids restricting such choices.

The main method sets up a JFrame, an empty JMenuBar and adds an instance of JWindowPaneTest to the JFrame. The JMenuBar is passed to the JWindowPaneTest constructor so that we can set up the menus. The JWindowPaneTest constructor sets up a File JMenu, with an Exit JMenuItem and a View JMenu with a JMenuItem for each of the edges we’ll manage. Each is added to it’s parent and the JWindowPaneTest is added to each JMenuItem as an ActionListener, so we can respond to menu events in the actionPerformed method.

After the menus are set up, we create two instances of JWindowPane, one inside the other, and add a few dummy components for demonstration purposes. I’ve used a createChild method that sets up JTextArea instances with suitable word wrapping, scrolling, spacing and size preferences.

The actionPerformed method branches based on the menu item that was triggered. We change the active state of the JWindowPane window, based on the state of the menu item. Since we also registered with the JWindowPane objects as a ChangeListener, we catch any changes in the state to a WindowPane and reflect them back to the menu items. There’s a slight amount of wasted effort when this happens, first because we can’t tell from the change event which of the windows have changed, so we change the state for both menus. When the menu triggeres the change, it’s a little redundant to change it’s state again, but there’s no harm in it and it makes the example code more readable.

JWindowPane addresses the needs of many Swing developers in that it provides an idiom that users have become accustomed to, without undue complexity. Adding dismissable components on any of the four edges of a window, with split pane-style resizing is easy and straight forward. Activating and deactivating these windows is a familiar activity in numerous modern programs. With this approach, you can add the same functionality to your user interfaces.

Listing 1

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

public class WindowPane extends JPanel
{
  protected JSplitPane splitPane;
  protected WindowPaneBar titleBar;

  public WindowPane(JSplitPane splitPane)
  {
    this(splitPane, "");
  }
  
  public WindowPane(JSplitPane splitPane, String title)
  {
    this.splitPane = splitPane;
    setLayout(new BorderLayout());
    add(BorderLayout.NORTH, titleBar =
      new WindowPaneBar(this, title));
  }
  
  public void setTitle(String title)
  {
    titleBar.setTitle(title);
  }
  
  public void setComponent(Component child)
  {
    add(BorderLayout.CENTER, child);
  }

  public void setActive(boolean active)
  {
    int divSize = UIManager.getInt("SplitPane.dividerSize");
    if (active)
    {
      Dimension size = getSize();
      int orientation = splitPane.getOrientation();
      if (orientation == JSplitPane.VERTICAL_SPLIT)
      {
        if (splitPane.getTopComponent() == this)
        {
          splitPane.setDividerLocation(size.height);
        }
        else
        {
          int max = splitPane.getSize().height;
          splitPane.setDividerLocation(
            max - size.height - divSize);
        }
      }
      else
      {
        if (splitPane.getLeftComponent() == this)
        {
          splitPane.setDividerLocation(size.width);
        }
        else
        {
          int max = splitPane.getSize().width;
          splitPane.setDividerLocation(
            max - size.width - divSize);
        }
      }
    }
    splitPane.setDividerSize(active ? divSize : 0);
    setVisible(active);
  }
}

Listing 2

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

public class JWindowPane extends JPanel
  implements SwingConstants, ComponentListener
{
  protected int orientation;
  protected JSplitPane northSplit, southSplit;
  protected WindowPane northWindow, southWindow;
  protected ArrayList changeListeners = new ArrayList();
    
  public JWindowPane(int orientation)
  {
    this.orientation = orientation;
    if (orientation == VERTICAL)
    {
      int dir = JSplitPane.VERTICAL_SPLIT;
      northSplit = new JSplitPane(dir);
      northWindow = new WindowPane(northSplit);
      northSplit.setTopComponent(northWindow);
      
      southSplit = new JSplitPane(dir);
      southWindow = new WindowPane(southSplit);
      southSplit.setBottomComponent(southWindow);
      southSplit.setTopComponent(northSplit);
    }
    else
    {
      int dir = JSplitPane.HORIZONTAL_SPLIT;
      northSplit = new JSplitPane(dir);
      northWindow = new WindowPane(northSplit);
      northSplit.setLeftComponent(northWindow);
      
      southSplit = new JSplitPane(dir);
      southWindow = new WindowPane(southSplit);
      southSplit.setRightComponent(southWindow);
      southSplit.setLeftComponent(northSplit);
    }
    addComponentListener(this);
    southWindow.addComponentListener(this);
    northWindow.addComponentListener(this);
    northSplit.setBorder(null);
    southSplit.setBorder(null);
    setLayout(new GridLayout());
    add(southSplit);
  }
  
  public JWindowPane(int orientation,
    String northTitle, Component northChild,
    String southTitle, Component southChild,
    Component center)
  {
    this(orientation);
    setNorthTitle(northTitle);
    setNorthComponent(northChild);
    setSouthTitle(southTitle);
    setSouthComponent(southChild);
    setCenterComponent(center);
  }
  
  public void setNorthTitle(String title)
  {
    northWindow.setTitle(title);
  }
  
  public void setSouthTitle(String title)
  {
    southWindow.setTitle(title);
  }
  
  public void setWestTitle(String title)
  {
    setNorthTitle(title);
  }
  
  public void setEastTitle(String title)
  {
    setSouthTitle(title);
  }
  
  public void setNorthComponent(Component child)
  {
    northWindow.setComponent(child);
    adjustPreferredDividerLocation(NORTH);
  }
  
  public void setSouthComponent(Component child)
  {
    southWindow.setComponent(child);
    adjustPreferredDividerLocation(SOUTH);
  }
  
  public void setWestComponent(Component child)
  {
    northWindow.setComponent(child);
    adjustPreferredDividerLocation(WEST);
  }
  
  public void setEastComponent(Component child)
  {
    southWindow.setComponent(child);
    adjustPreferredDividerLocation(EAST);
  }
  
  public void setCenterComponent(Component child)
  {
    if (orientation == VERTICAL)
    {
      northSplit.setBottomComponent(child);
    }
    else
    {
      northSplit.setRightComponent(child);
    }
  }
  
  public void resetPreferredDividerLocations()
  {
    if (orientation == VERTICAL)
    {
      adjustPreferredDividerLocation(SOUTH);
      adjustPreferredDividerLocation(NORTH);
    }
    else
    {
      adjustPreferredDividerLocation(EAST);
      adjustPreferredDividerLocation(WEST);
    }
  }
  
  protected void adjustPreferredDividerLocation(int edge)
  {
    int loc = getPreferredDividerLocation(edge);
    if (edge == NORTH || edge == WEST)
    {
      northSplit.setDividerLocation(loc);
    }
    else
    {
      southSplit.setDividerLocation(loc);
    }
  }
  
  protected int getPreferredDividerLocation(int edge)
  {
    int divSize = UIManager.getInt("SplitPane.dividerSize");
    Dimension total = southSplit.getSize();
    Dimension north = northWindow.getPreferredSize();
    Dimension south = southWindow.getPreferredSize();
    if (edge == NORTH)
    {
      return north.height;
    }
    if (edge == SOUTH)
    {
      return total.height - south.height - divSize;
    }
    if (edge == WEST)
    {
      return north.width;
    }
    if (edge == EAST)
    {
      return total.width - south.width - divSize;
    }
    return 0;
  }
  
  public void setNorthActive(boolean state)
  {
    northWindow.setActive(state);
  }
  
  public void setSouthActive(boolean state)
  {
    southWindow.setActive(state);
  }
  
  public void setWestActive(boolean state)
  {
    setNorthActive(state);
  }
  
  public void setEastActive(boolean state)
  {
    setSouthActive(state);
  }
  
  public boolean isNorthActive()
  {
    return northWindow.isVisible();
  }
  
  public boolean isSouthActive()
  {
    return southWindow.isVisible();
  }
  
  public boolean isWestActive()
  {
    return isNorthActive();
  }
  
  public boolean isEastActive()
  {
    return isSouthActive();
  }
  
  public void componentHidden(ComponentEvent event)
  {
    Object source = event.getSource();
    if (source != this)
    {
      fireChangeEvent();
    }
  }

  public void componentShown(ComponentEvent event)
  {
    Object source = event.getSource();
    if (source != this)
    {
      fireChangeEvent();
    }
  }
  
  public void componentMoved(ComponentEvent event) {}
  
  public void componentResized(ComponentEvent event)
  {
    Object source = event.getSource();
    if (source == this)
    {
      resetPreferredDividerLocations();
    }
  }

  public void addChangeListener(ChangeListener listener)
  {
    changeListeners.add(listener);
  }

  public void removeChangeListener(ChangeListener listener)
  {
    changeListeners.remove(listener);
  }
  
  protected void fireChangeEvent()
  {
    ChangeEvent event = new ChangeEvent(this);
    ArrayList list = (ArrayList)changeListeners.clone();
    for (int i = 0; i < list.size(); i++)
    {
      ChangeListener listener =
        (ChangeListener)list.get(i);
      listener.stateChanged(event);
    }
  }
}

Listing 3

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

public class JWindowPaneTest extends JPanel
  implements ActionListener, ChangeListener
{
  protected JMenuItem exitMenu;
  protected JWindowPane vertPane, horzPane;
  protected JCheckBoxMenuItem northMenu, southMenu;
  protected JCheckBoxMenuItem eastMenu, westMenu;
    
  public JWindowPaneTest(JMenuBar menuBar)
  {
    JMenu fileMenu = new JMenu("File");
    JMenu viewMenu = new JMenu("View");
    menuBar.add(fileMenu);
    menuBar.add(viewMenu);
    
    northMenu = new JCheckBoxMenuItem("North View", true);
    northMenu.addActionListener(this);
    viewMenu.add(northMenu);
    
    southMenu = new JCheckBoxMenuItem("South View", true);
    southMenu.addActionListener(this);
    viewMenu.add(southMenu);
    
    eastMenu = new JCheckBoxMenuItem("East View", true);
    eastMenu.addActionListener(this);
    viewMenu.add(eastMenu);
    
    westMenu = new JCheckBoxMenuItem("West View", true);
    westMenu.addActionListener(this);
    viewMenu.add(westMenu);
    
    exitMenu = new JMenuItem("Exit");
    exitMenu.addActionListener(this);
    fileMenu.add(exitMenu);
    
    vertPane = new JWindowPane(JWindowPane.VERTICAL,
      "North Pane", createChild("This is the North component."),
      "South Pane", createChild("This is the South component."),
      createChild("This is the Center component."));
      
    horzPane = new JWindowPane(JWindowPane.HORIZONTAL,
      "West Pane", createChild("This is the West component."),
      "East Pane", createChild("This is the East component."),
      vertPane);
    
    vertPane.addChangeListener(this);
    horzPane.addChangeListener(this);
    
    setLayout(new GridLayout());
    add(horzPane);
  }
  
  protected JComponent createChild(String text)
  {
    JTextArea textArea = new JTextArea(text);
    textArea.setLineWrap(true);
    textArea.setWrapStyleWord(true);
    JScrollPane scroll = new JScrollPane(textArea);
    scroll.setBorder(null);
    JPanel panel = new JPanel(new GridLayout());
    panel.setPreferredSize(new Dimension(100, 100));
    panel.setBackground(textArea.getBackground());
    panel.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
    panel.add(scroll);
    return panel;
  }
  
  public void actionPerformed(ActionEvent event)
  {
    Object source = event.getSource();
    if (source == northMenu)
    {
      vertPane.setNorthActive(northMenu.getState());
    }
    if (source == southMenu)
    {
      vertPane.setSouthActive(southMenu.getState());
    }
    if (source == eastMenu)
    {
      horzPane.setEastActive(eastMenu.getState());
    }
    if (source == westMenu)
    {
      horzPane.setWestActive(westMenu.getState());
    }
    if (source == exitMenu)
    {
      System.exit(0);
    }
  }
  
  public void stateChanged(ChangeEvent event)
  {
    Object source = event.getSource();
    if (source == vertPane)
    {
      northMenu.setState(vertPane.isNorthActive());
      southMenu.setState(vertPane.isSouthActive());
    }
    if (source == horzPane)
    {
      westMenu.setState(horzPane.isWestActive());
      eastMenu.setState(horzPane.isEastActive());
    }
  }

  public static void main(String[] args)
  {
    JFrame frame = new JFrame();
    JMenuBar menuBar = new JMenuBar();
    frame.getContentPane().setLayout(new GridLayout());
    frame.getContentPane().add(new JWindowPaneTest(menuBar));
    frame.setJMenuBar(menuBar);
    frame.setSize(600, 500);
    frame.show();
  }
}