ORIGINAL DRAFT

This month, we’re going to develop a simple component that lets you scroll information across the screen. While this is hardly an original idea, especially given the huge proliferation of ticker applets on the Internet, it is implemented in a way that promises more flexibility and reusability in your user interface. If you need to draw attention to help topics, show headings for real-time feeds, display message subjects, etc., you can use this component.

Figure 1: Each JTicker message scrolls to the left
at about 25 frames per second by default, though you can control the frequency and scrolling increment using
JTicker properties.

Figure 1: Each JTicker message scrolls to the left at about 25 frames per second by default, though you can control the frequency and scrolling increment using JTicker properties.

The first objective is to keep our design as open a possible. For that, we can use interfaces constructively, separating the model and the renderer elements, defining a contract between the JTicker component and supporting classes. In effect, our model is pretty much the same as a Swing ListModel, though we’ll extend it to support actions that are not part of the standard ListModel interface. The renderer interface is also simple, accommodating the delivery of virtually any kind of information in the scrolling window.

Figure 2: JTicker Classes. Two interfaces provide an
open architecture that lets you easily extend or replace message display and data storage.

Figure 2: JTicker Classes. Two interfaces provide an open architecture that lets you easily extend or replace message display and data storage.

Figure 2 shows the relationship between classes in JTicker. We provide default implementations for both the TickerModel and TickerRenderer, but this is where you can plug in your own implementations to extend the open architecture. The TickerAction class is merely an example use to demonstrate JTicker’s capabilities.

Action objects can be used to associate clickable information. What you do with the an action event is up to you. The TickerAction implementation prints the text for demonstration purposes. You can use any Object as an element in JTicker. When you use an Action object, however, JTicker can use the icon and text from the Action properties. The DefaultTickerRenderer will highlight Action entries and JTicker will trigger the action event when a user clicks on the scrolling entry. To make this a little more user-friendly, scrolling stops until the user release the mouse button.

Both the TickerModel and TickerRenderer interfaces are simple. TickerRenderer uses a few constants to identify the current state of the mouse, with respect to the current value.

public interface TickerRenderer
{
  public static final int STATE_NORMAL = 1;
  public static final int STATE_MOUSE = 2;
  public static final int STATE_CLICK = 3;

  public JComponent getTickerRendererComponent(
    JTicker ticker, Object value, int state);
}

The TickerModel interface extends the Swing ListModel interface. The reason we do this is interesting. We want to use the same strategy used by the standard Swing components, but we don’t want to make assumptions about the default model. To expose the ability to add or remove list elements, we need appropriate methods. These are available (under different names) in the DefaultListModel implementation but they are not explicitly part of the ListModel interface. Here are the methods we’ve added.

public interface TickerModel extends ListModel
{
  public Object get(int index);
  public int indexOf(Object item);
  public void add(Object item);
  public void remove(Object item);
}

The add and remove methods are functionally part of the DefaultListModel, named addElement and removeElement. I’ve wrapped these into more standard names based on the Collections interface. The same is true of the get and indexOf methods, which are already part of the DefaultListModel implementation. Our DefaultTickerModel implementation, in Listing 1, provides two methods that delegate the add and remove methods to addElement and removeElement methods in the DefaultListModel.

Listing 2 shows the code for DefaultTickerRenderer. We extend JLabel to provide both text and an icon by default. The getTickerRendererComponent does most of the work. We first set the foreground color, based on the current state. If the mouse is over the display for this element, the text is colored blue. If the mouse is over the element and pressed, the text is red. By default, the text is black.

The colors are accessible through set methods that let you define the normal, mouse over and mouse clicked states. There are three variables that define the colors and associated methods that follow the JavaBeans naming guidelines. The normalColor variable can be set by the setNormalColor method. The same pattern is applied to the mouseOverColor and mouseClickedColor variables.

If the type of value we’re dealing with is an instance of the Action interface, we get values for the properties NAME and SMALL_ICON, setting them using setText and setIcon, respectively. If the value is not an Action object, we use the toString method to set the current text and use the default Icon.

Listing 3 shows code for the JTicker class. The implementation is only slightly complicated by the use of a model and renderer. We support three listeners. A ListDataListener monitors the TickerModel for changes. The MouseListener and MouseMotionListener handle mouse events. We always listen for MouseEvents but register to receive the MouseMotionEvents when the mouse enters the component, unregistering the MouseMotionListener when it exists.

There are five properties in JTicker. Two of them are the model and renderer. The other two are the time interval between repaint events, in milliseconds, the increment in pixels and the gap between messages, also in pixels. We provide accessors for each in the form of JavaBeans get and set methods. The setInterval method reinitializes a Swing Timer. The setRenderer method recalculates the position array. The setModel method resets the ListDataListener to keep track of changes to the model, as well as recalculating the positions array.

The positions array is used to precalculate the offset position of each message. Starting from the left at zero, we mark the position of each message incrementally by calculating the preferred size for each rendered message. Having the positions array like this speeds up the process of positioning and calculating drawing rectangles for each message as it scrolls on the screen. It also helps us quickly determine whether the position for each message is within the window, speeding up drawing somewhat more by ignoring messages outside the drawing area.

The drawing is handled in the paintComponent method at the end of this listing. We make use of the Swing CellRendererPane to handle this rendering. You can find more information on how this works in the JavaDoc description for CellRendererPane. As you can see, it’s quite easy to use. The paintComponent method handles repainting the background, accounting for the border, and then calls the renderer on each visible message, using the positions array and the current offset value. We make two passes through the array to account for looping messages.

Skipping back to the beginning of the code, you’ll see three constructors and an init method that initializes the component. We use the accessor methods to optionally set the model and renderer because the setModel and setRenderer methods handle additional setup conditions. The init method can be extended in a subclass, so using this pattern is good practice when handling component constructors.

We provide a pair of methods for adding and removing objects to and from the list. We mentioned the listeners earlier. The ListDataListener methods recalculate the position array and reset the preferred size. The MouseListener methods do a little more work. The mousePressed and mouseReleased methods mark the current state of the mouse button with a mousePressed flag. When the mouse is pressed we stop the timer to put a hold on scrolling and when the mouse is released, we restart the timer. In both cases we repaint the screen to reflect the current state of affairs. Finally, when the mouse is pressed we call the fireActionEvent method.

The fireActionEvent method determines whether the clicked-on message is an Action. if so, we fire the actionPerformed method on that Action. Otherwise, we return immediately or skip over the processing. Both the mouseEntered and mouseExisted methods play a small role by registering and unregistering the MouseMotionListener, respectively. The mouseMoved method then marks the current mouse position and calls the highlight method, as does the mouseEntered method.

The highlight method figures out which message the mouse is over. We do this by applying the same basic pattern as the paintComponent method, walking though the positions array and checking for the current mouse anchor position. The anchor variable stores the last mouse position primarily for this purpose, a value set to null if the mouse is not currently over the component. The purpose of the highlight method is to set the mouseOver value to either the message currently under the mouse or a -1 value indicating no selection.

During the execution of the paintComponent method, we call the getState method which takes the current selection index, mouseOver, and the state of the mouse button, mousePressed, and returns an appropriate value for the renderer. If the message object is not an Action instance, no hilighting is necessary. Otherwise, we need to distinguish between a mouse over and click condition.

Only two methods remain. The actionPerformed method is triggered by the Timer at each clock tick. This is where we increment the current offset. In effect, the default value for increment is a negative value causing scrolling to move to the left, but you can use positive values here as well if you prefer scrolling to the right. The actionPerformed method also calls the highlight method to handle cases where the mouse is over the display area but has not moved.

The last method is calculatePreferredSize, which walks through each object in the model and calls the renderer to get an instance of the rendering component. Given the component instance, we can call getPreferredSize to find out what the preferred size of each entry might be. To keep things simple, our calculation simply looks for the largest dimensions required for a single entry, accounting for border inset values. In practice, the vertical dimensions are important during layout and the horizontal size is likely to be adjusted by the layout manager.

JTicker is a surprisingly useful component, especially given it’s simplicity. This design maximizes flexibility while ensuring basic functionality in default implementations of the model and renderer elements. You can easily extend JTicker or use it as-is to make users aware of information you want to highlight or to deal with streaming data, such as news stories, email or other data source. Use it in good faith.

Listing 1

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

public class DefaultTickerModel
  extends DefaultListModel
  implements TickerModel
{
  public void add(Object item)
  {
    addElement(item);
  }

  public void remove(Object item)
  {
    removeElement(item);
  }
}

Listing 2

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

public class DefaultTickerRenderer extends JLabel
  implements TickerRenderer
{
  public static final Icon defaultIcon =
    new ImageIcon("page.gif");
    
  protected Color normalColor = Color.black;
  protected Color mouseOverColor = Color.blue;
  protected Color mouseClickedColor = Color.red;

  public DefaultTickerRenderer() {}
  
  public void setNormalColor(Color color)
  {
    normalColor = color;
  }

  public void setMouseOverColor(Color color)
  {
    mouseOverColor = color;
  }

  public void setMouseClicked(Color color)
  {
    mouseClickedColor = color;
  }

  public JComponent getTickerRendererComponent(
    JTicker ticker, Object value, int state)
  {
    setForeground(normalColor);
    if (state == STATE_MOUSE)
    {
      setForeground(mouseOverColor);
    }
    if (state == STATE_CLICK)
    {
      setForeground(mouseClickedColor);
    }
    
    if (value instanceof Action)
    {
      Action action = (Action)value;
      Icon icon = (Icon)action.
        getValue(Action.SMALL_ICON);
      String name = (String)action.
        getValue(Action.NAME);
      setText(name);
      setIcon(icon);
    }
    else
    {
      setText(value.toString());
      setIcon(defaultIcon);
    }
    return this;
  }
}

Listing 3

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

public class JTicker extends JPanel
  implements ActionListener, ListDataListener,
    MouseListener, MouseMotionListener
{
  // Property instance variables
  protected TickerRenderer renderer;
  protected TickerModel model;
  protected int interval = 40;
  protected int increment = -6;
  protected int gap = 20;
  
  // Internal instance variables
  protected CellRendererPane renderPane =
    new CellRendererPane();
  protected boolean mousePressed;
  protected int mouseOver = -1;
  protected int[] positions;
  protected int offset = 0;
  protected Point anchor;
  protected Timer timer;
  
  public JTicker()
  {
    this(new DefaultTickerModel());
  }
  
  public JTicker(TickerModel model)
  {
    this(model, new DefaultTickerRenderer());
  }
  
  public JTicker(TickerModel model,
    TickerRenderer renderer)
  {
    setRenderer(renderer);
    setModel(model);
    init();
  }
  
  public void init()
  {
    calculatePositionArray();

    addMouseListener(this);
    
    setBackground(Color.white);
    setBorder(BorderFactory.
      createLineBorder(Color.black, 1));
    
    setPreferredSize(calculatePreferredSize());
    
    timer = new Timer(interval, this);
    timer.start();
  }

// ----------------------------------------------
// Model
// ----------------------------------------------
  
  public void addItem(Object item)
  {
    model.add(item);
  }
  
  public void removeItem(Object item)
  {
    model.remove(item);
  }
  
// ----------------------------------------------
// Accessors
// ----------------------------------------------
  
  public void setGap(int gap)
  {
    this.gap = gap;
  }
  
  public int getGap()
  {
    return gap;
  }
  
  public void setIncrement(int increment)
  {
    this.increment = increment;
  }
  
  public int getIncrement()
  {
    return increment;
  }
  
  public void setInterval(int interval)
  {
    this.interval = interval;
    timer.stop();
    timer = new Timer(interval, this);
    timer.start();
  }
  
  public int getInterval()
  {
    return interval;
  }
  
  public void setRenderer(TickerRenderer renderer)
  {
    this.renderer = renderer;
    calculatePositionArray();
  }
  
  public TickerRenderer getRenderer()
  {
    return renderer;
  }
  
  public void setModel(TickerModel model)
  {
    if (this.model != null)
    {
      this.model.removeListDataListener(this);
    }
    this.model = model;
    this.model.addListDataListener(this);
    calculatePositionArray();
  }
  
  public TickerModel getModel()
  {
    return model;
  }

// ----------------------------------------------
// ListDataListener
// ----------------------------------------------

  public void intervalAdded(ListDataEvent event)
  {
    calculatePositionArray();
    setPreferredSize(calculatePreferredSize());
  }
  
  public void intervalRemoved(ListDataEvent event)
  {
    calculatePositionArray();
    setPreferredSize(calculatePreferredSize());
  }
  
  public void contentsChanged(ListDataEvent event)
  {
    calculatePositionArray();
    setPreferredSize(calculatePreferredSize());
  }
  
// ----------------------------------------------
// MouseListener
// ----------------------------------------------

  public void mousePressed(MouseEvent event)
  {
    mousePressed = true;
    timer.stop();
    repaint();
    fireActionEvent();
  }
  
  public void mouseReleased(MouseEvent event)
  {
    mousePressed = false;
    timer.restart();
    repaint();
  }
  
  public void mouseClicked(MouseEvent event) {}
  
  public void mouseEntered(MouseEvent event)
  {
    addMouseMotionListener(this);
    anchor = event.getPoint();
    highlight(event.getX());
  }
  
  public void mouseExited(MouseEvent event)
  {
    removeMouseMotionListener(this);
    mouseOver = -1;
    anchor = null;
  }

// ----------------------------------------------
// MouseMotionListener
// ----------------------------------------------

  public void mouseMoved(MouseEvent event)
  {
    anchor = event.getPoint();
    highlight(event.getX());
  }
  
  public void mouseDragged(MouseEvent event) {}

// ----------------------------------------------
// ActionListener
// ----------------------------------------------

  public void actionPerformed(ActionEvent event)
  {
    offset += increment;
    int last = positions.length - 1;
    int min = -positions[last];
    if (offset < min) offset = 0;
    if (anchor != null) highlight(anchor.x);
    repaint();
  }
  
// ----------------------------------------------
// Support
// ----------------------------------------------
  
  protected void calculatePositionArray()
  {
    if (model == null) return;
    int pos = 0;
    int count = model.getSize();
    positions = new int[count + 1];
    positions[0] = 0;
    for (int i = 0; i < count; i++)
    {
      Object value = model.getElementAt(i);
      JComponent component = renderer.
        getTickerRendererComponent(this,
          value, TickerRenderer.STATE_NORMAL);
      Dimension size = component.getPreferredSize();
      pos += size.width + gap;
      positions[i + 1] = pos;
    }
  }
  
  protected Dimension calculatePreferredSize()
  {
    int width = 0;
    int height = 0;
    int count = model.getSize();
    for (int i = 0; i < count; i++)
    {
      Object value = model.get(i);
      JComponent component = renderer.
        getTickerRendererComponent(this,
          value, TickerRenderer.STATE_NORMAL);
      Dimension cell = component.getPreferredSize();
      width = Math.max(width, cell.width);
      height = Math.max(height, cell.height);
    }
    Insets insets = getInsets();
    return new Dimension(
      width + insets.left + insets.right,
      height + insets.top + insets.bottom);
  }
  
  public int getState(int index, Object value)
  {
    if (index == mouseOver &&
      value instanceof Action)
    {
      if (mousePressed)
      {
        return TickerRenderer.STATE_CLICK;
      }
      return TickerRenderer.STATE_MOUSE;
    }
    return TickerRenderer.STATE_NORMAL;
  }
  
  public void fireActionEvent()
  {
    if (mouseOver == -1) return;
    Object value = model.get(mouseOver);
    if (value instanceof Action)
    {
      Action action = (Action)value;
      if (action != null)
      {
        ActionEvent event = new ActionEvent(this,
          ActionEvent.ACTION_PERFORMED, "Action");
        action.actionPerformed(event);
      }
    }
  }

// ----------------------------------------------
// Display
// ----------------------------------------------

  public void highlight(int x)
  {
    mouseOver = -1;
    Insets insets = getInsets();
    int right = positions[positions.length - 1];
    int count = positions.length - 1;
    for (int i = 0; i < count * 2; i++)
    {
      int index = (i < count) ? i : i - count;
      int adjust = insets.left + offset;
      int head = positions[index] + adjust;
      int tail = positions[index + 1] + adjust;
      if (i >= count)
      {
        head += right;
        tail += right;
      }
      if (x >= head && x <= tail)
      {
        mouseOver = index;
        return;
      }
    }
  }
  
  public void paintComponent(Graphics g)
  {
    int w = getSize().width;
    int h = getSize().height;
    Insets insets = getInsets();
    g.setColor(getBackground());
    g.fillRect(insets.left, insets.top,
      w - (insets.left + insets.right),
      h - (insets.top + insets.bottom));
    int count = model.getSize();
    int right = positions[positions.length - 1];
    for (int i = 0; i < count * 2; i++)
    {
      int index = (i < count) ? i : i - count;
      int adjust = insets.left + offset;
      int head = positions[index] + adjust;
      int tail = positions[index + 1] + adjust;
      if (i >= count)
      {
        head += right;
        tail += right;
      }
      if (head < w && tail > 0)
      {
        Object value = model.getElementAt(index);
        int state = getState(index, value);
        JComponent component = renderer.
          getTickerRendererComponent(this, value, state);
        Dimension size = component.getPreferredSize();
        renderPane.paintComponent(g, component, this,
          head, insets.top, size.width, size.height);
      }
    }
  }
}