Tristate Checkbox for Swing

One modern GUI feature notably absent from Java Swing is the tristate checkbox, i.e. a checkbox that has a third or “null” state in addition to the checked and unchecked states. This is typically visualized as a square or dash in the checkbox, where the checked state would be a checkmark and the unchecked state an empty box.

A proper implementation would take a lot of work, as the existing Swing infrastructure based on JToggleButton simply does not support more than two states. You can find an experimental attempt in an old JavaSpecialists article, and that wall of code does not even include drawing a proper null state icon. (I did crib the trick of showing all installed Look & Feels from that article, for my test application shown below.)

However, I found a clever and frankly outrageous hack submitted by an anonymous user on Stack Overflow. Instead of designing a new checkbox class, it is possible to emulate tristate behavior by simply attaching a custom ActionListener to a regular JCheckBox! Building on that, here’s my complete TristateActionListener class, with Javadoc comments elided for brevity. Note how actionPerformed cycles through the three states, using a custom null state icon not only for display but also as a marker for the third state, returned by getState as a null Boolean.

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

public class TristateActionListener implements ActionListener {
    final protected Icon _icon;

    public TristateActionListener(Icon icon) {
        if (icon == null)
            throw new NullPointerException("icon");

        _icon = icon;
    }

    public static Icon createIcon(UIDefaults defaults) {
        if (defaults == null) defaults = UIManager.getDefaults();

        final Icon icon = defaults.getIcon("CheckBox.icon");
        final int width = icon.getIconWidth();
        final int height = icon.getIconHeight();
        final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);

        final Graphics g = image.getGraphics();
        icon.paintIcon(new JCheckBox(), g, 0, 0);
        final int dx = width / 4, dy = (int) (height / 2.2f);
        g.setColor(defaults.getColor("CheckBox.foreground"));
        g.fillRect(dx, dy, width - 2 * dx, height - 2 * dy);
        g.dispose();

        return new ImageIcon(image);
    }

    public static Boolean getState(JCheckBox checkBox){
        if (checkBox.getIcon() != null) return null;
        return checkBox.isSelected();
    }

    public void setState(JCheckBox checkBox, Boolean state) {
        if (state == null) {
            checkBox.setSelected(false);
            checkBox.setIcon(_icon);
        } else {
            checkBox.setSelected(state);
            checkBox.setIcon(null);
        }
    }

    public void actionPerformed(ActionEvent e) {
        final JCheckBox checkBox = (JCheckBox) e.getSource();
        if (!checkBox.isSelected())
            checkBox.setIcon(_icon);
        else if (checkBox.getIcon() != null) {
            checkBox.setSelected(false);
            checkBox.setIcon(null);
        }
    }
}

Compared to the Stack Overflow code, I fixed a bug in getState and added the setState method, as well as the drawing of the null state icon, based on the supplied or current Look & Feel. The result is not aesthetically perfect but good enough for my purposes. Here is a screenshot of the test application that shows the null state for all installed Look & Feels on my Windows 10 system.

Spinner Demo

You can download the complete source code for TristateActionListener and the TristateTest application, both with extensive Javadoc comments, as TristateCheckBox.zip. This package also contains the corresponding pre-created Java class files (targeting JDK 8) and Javadoc pages, all organized as a project for IntelliJ Idea 2022.1.2 with JDK 10.0.2.

However elegant, this hack is not without its shortcomings. They are detailed in the Javadoc comments, but aside from various visual inconsistencies the most important is that TristateActionListener itself changes the state of the attached JCheckBox. This means that any additional ActionListener that wants to react to a state change must delay that action using SwingUtilities.invokeLater, or else it would see an outdated state. Also, since the custom icon is used both as image and marker of the null state, you cannot use any custom icons of your own.

Finally, the test application revealed a weird internal JDK bug on my system (Windows 10 with JDK 10.0.2). Using multiple Look & Feels in the same application evidently can get some of them confused. The standard JCheckBox icons of Nimbus – not my custom one! – appear with the wrong background color (a shade of green). When using Nimbus as the only Look & Feel, the bug disappears.

Update 2022-06-12: Version 1.1 adds the setState method which I had forgotten in the initial release, and three buttons in the test application to verify that it works.

2 thoughts on “Tristate Checkbox for Swing”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.