2004-09-03

A Cocoa-like search field for Java

If you've used a Mac, you'll have used NSSearchField, though you may not know it as such unless you're a Cocoa programmer. For those who can't guess, it's the little text field at the top of the iTunes window.

Having an always-visible search field to filter what you're looking at (often as-you-type) is a big part of what makes Mac OS applications feel like they're there to help, rather than get in your way (yes, Outlook, I'm talking about you, king of slow and awkward).

iTunes has one. Mail has one. Address Book has one. iCal has one.

You get the idea.

Even applications that don't offer a filterable view of some database use the same GUI component for their search. Safari, for example, has an NSSearchField next to the location bar that does a Google search. For those freaks who don't have Google as their home page, I guess.

Here are some of the most obvious features of NSSearchField:

  • Rounded corners to distinguish them from normal text fields.
  • Gray placeholder text (saying "Search" or "Google", say) when the field is empty and unfocused.
  • A cancel button when the field is non-empty.
  • Optional sending of notifications on each keystroke, rather than waiting for the user to hit Enter.
  • An optional menu of recent searches.
  • Optional automatic persistence of recent searches ("just add filename").


Some time ago, I wrote a JTextField subclass called SearchField that implemented the placeholder text. That was cunning, but easy: a FocusListener changed the foreground color and set the text to the placeholder string on losing the focus, and put the original string and color back when the focus returned. This is far simpler than trying to draw over the JTextArea by either subclassing or adding a Border. Sadly, those dead-ends were the first two ideas I had.

I had a go at the corners and cancel button, but gave up after getting nowhere. I'd planned on using BorderLayout to combine a JTextField and a JButton either side (one for the menu, one for the cancel button). Even pulling borders out of one component and giving them to another, I couldn't get this to look anything but stupid.

So for a while, I gave up and went away and did other things.

Then the other day I got sick of messing about with jar(1) and javap(1) on the command line whenever I wanted to poke about in a JAR file, and knocked up a quick front-end. And this was crying out for iTunes-like filtering on its list of JAR contents. (Filtering a JList is a story for another day.)

Anyway, this time I thought I'd see what I could do with a CompoundBorder on the JTextField. And it all just sort of came together. EmptyBorder is a good border to subclass, because it lets you say how much space you're going to need, but won't do any drawing. You can override paintBorder and do what you like. I conditionally draw a little gray circle with a white cross on it.

An anti-aliased circle, of course, because any other kind just doesn't look like a circle unless it's huge.

To put some behavior on my "button", I add a MouseInputAdapter (in two stages, because there's no addMouseInputListener, even though a custom component almost always needs both). The only tricky part there was remembering that mouseExited isn't invoked if a mouse button is down: you have to watch for the MouseEvent passed to mouseDragged going outside your component instead.

Getting notification on each keystroke is just a matter of adding a DocumentListener. This is made slightly more tricky by the placeholder text. [Update: I later switched to using a KeyListener so I could cancel if the user hit Esc. This implementation turns out to be nicer anyway, and slightly shorter too. As usual, the latest code is in the 'salma-hayek' nightly builds a few clicks away from my home page.]

Anyway, with the realization that I should get proper hit-testing sorted out for the "button", and that my experiences adding the recent searches menu may cause me to rethink the design so far, here's the current code:

package e.gui;

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

/**
* A text field for search/filter interfaces. The extra functionality includes
* a placeholder string (when the user hasn't yet typed anything), and a button
* to clear the currently-entered text.
*
* @author Elliott Hughes
*/

//
// TODO: add a menu of recent searches.
// TODO: make recent searches persistent.
// TODO: use rounded corners, at least on Mac OS X.
//

public class SearchField extends JTextField {
private static final Border CANCEL_BORDER = new CancelBorder();

private boolean sendsNotificationForEachKeystroke = false;
private boolean showingPlaceholderText = false;
private boolean armed = false;

public SearchField(String placeholderText) {
super(15);
addFocusListener(new PlaceholderText(placeholderText));
initBorder();
initKeyListener();
}

public SearchField() {
this("Search");
}

private void initBorder() {
setBorder(new CompoundBorder(getBorder(), CANCEL_BORDER));

MouseInputListener mouseInputListener = new CancelListener();
addMouseListener(mouseInputListener);
addMouseMotionListener(mouseInputListener);
}

private void initKeyListener() {
addKeyListener(new KeyAdapter() {
public void keyReleased(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
cancel();
} else if (sendsNotificationForEachKeystroke) {
maybeNotify();
}
}
});
}

private void cancel() {
setText("");
postActionEvent();
}

private void maybeNotify() {
if (showingPlaceholderText) {
return;
}
postActionEvent();
}

public void setSendsNotificationForEachKeystroke(boolean eachKeystroke) {
this.sendsNotificationForEachKeystroke = eachKeystroke;
}

/**
* Draws the cancel button as a gray circle with a white cross inside.
*/
static class CancelBorder extends EmptyBorder {
private static final Color GRAY = new Color(0.7f, 0.7f, 0.7f);

CancelBorder() {
super(0, 0, 0, 15);
}

public void paintBorder(Component c, Graphics oldGraphics, int x, int y, int width, int height) {
SearchField field = (SearchField) c;
if (field.showingPlaceholderText || field.getText().length() == 0) {
return;
}

Graphics2D g = (Graphics2D) oldGraphics;
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

final int circleL = 14;
final int circleX = x + width - circleL;
final int circleY = y + (height - 1 - circleL)/2;
g.setColor(field.armed ? Color.GRAY : GRAY);
g.fillOval(circleX, circleY, circleL, circleL);

final int lineL = circleL - 8;
final int lineX = circleX + 4;
final int lineY = circleY + 4;
g.setColor(Color.WHITE);
g.drawLine(lineX, lineY, lineX + lineL, lineY + lineL);
g.drawLine(lineX, lineY + lineL, lineX + lineL, lineY);
}
}

/**
* Handles a click on the cancel button by clearing the text and notifying
* any ActionListeners.
*/
class CancelListener extends MouseInputAdapter {
private boolean isOverButton(MouseEvent e) {
// If the button is down, we might be outside the component
// without having had mouseExited invoked.
if (contains(e.getPoint()) == false) {
return false;
}

// In lieu of proper hit-testing for the circle, check that
// the mouse is somewhere in the border.
Rectangle innerArea = SwingUtilities.calculateInnerArea(SearchField.this, null);
return (innerArea.contains(e.getPoint()) == false);
}

public void mouseDragged(MouseEvent e) {
arm(e);
}

public void mouseEntered(MouseEvent e) {
arm(e);
}

public void mouseExited(MouseEvent e) {
disarm();
}

public void mousePressed(MouseEvent e) {
arm(e);
}

public void mouseReleased(MouseEvent e) {
if (armed) {
cancel();
}
disarm();
}

private void arm(MouseEvent e) {
armed = (isOverButton(e) && SwingUtilities.isLeftMouseButton(e));
repaint();
}

private void disarm() {
armed = false;
repaint();
}
}

/**
* Replaces the entered text with a gray placeholder string when the
* search field doesn't have the focus. The entered text returns when
* we get the focus back.
*/
class PlaceholderText implements FocusListener {
private String placeholderText;
private String previousText = "";
private Color previousColor;

PlaceholderText(String placeholderText) {
this.placeholderText = placeholderText;
focusLost(null);
}

public void focusGained(FocusEvent e) {
setForeground(previousColor);
setText(previousText);
showingPlaceholderText = false;
}

public void focusLost(FocusEvent e) {
previousText = getText();
previousColor = getForeground();
if (previousText.length() == 0) {
showingPlaceholderText = true;
setForeground(Color.GRAY);
setText(placeholderText);
}
}
}
}