Sexy Swing App – iTunes Table Header

May 11, 2008

iTunes has a unique, clean, clearly identifiable table header. Its built from few simple gradients and border colors.

It’s easy to replicate the iTunes table header in Swing by creating a TableCellRenderer that extends EmphasizedLabel (demonstrated in the Part 1 of this blog series). This renderer can then be installed as the default renderer for the table’s JTableHeader.

In order to get the selected column state, driven by user mouse events, we add a mouse listener to the table’s header component in the renderer’s constructor. This lets us render all five different states:

  1. Unselected, focused
  2. Unselected, pressed, focused
  3. Selected, focused
  4. Selected, pressed, focused
  5. Unfocused

Here’s the code for the custom table header renderer:

public class ITunesTableHeaderRenderer extends EmphasizedLabel
        implements TableCellRenderer{

    private JTable fTable;
    private int fSelectedColumn = -1;
    private int fPressedColumn = -1;
    private Color fTopColor = new Color(0xdbdbdb);
    private Color fBottomColor = new Color(0xbbbbbb);

    public static final Color ITUNES_TABLE_HEADER_LEFT_UNSELECTED_BORDER_COLOR =
            new Color(0xd9d9d9);
    public static final Color ITUNES_TABLE_HEADER_LEFT_PRESSED_UNSELECTED_BORDER_COLOR =
            new Color(0xc0c0c0);
    public static final Color ITUNES_TABLE_HEADER_LEFT_SELECTED_BORDER_COLOR =
            new Color(0xabbbce);
    public static final Color ITUNES_TABLE_HEADER_RIGHT_UNSELECTED_BORDER_COLOR =
            new Color(0x9c9c9c);
    public static final Color ITUNES_TABLE_HEADER_RIGHT_SELECTED_BORDER_COLOR =
            new Color(0x8a97a6);
    public static final Color ITUNES_TABLE_HEADER_BOTTOM_BORDER_COLOR =
            new Color(0x555555);
    public static final Color ITUNES_TABLE_HEADER_UNSELECTED_TOP_COLOR =
            new Color(0xdbdbdb);
    public static final Color ITUNES_TABLE_HEADER_UNSELECTED_BOTTOM_COLOR =
            new Color(0xbbbbbb);
    public static final Color ITUNES_TABLE_HEADER_SELECTED_TOP_COLOR =
            new Color(0xc2cfdd);
    public static final Color ITUNES_TABLE_HEADER_SELECTED_BOTTOM_COLOR =
            new Color(0x7d93b2);
    public static final Color ITUNES_TABLE_HEADER_PRESSED_UNSELECTED_TOP_COLOR =
            new Color(0xc4c4c4);
    public static final Color ITUNES_TABLE_HEADER_PRESSED_UNSELECTED_BOTTOM_COLOR =
            new Color(0x959595);
    public static final Color ITUNES_TABLE_HEADER_PRESSED_SELECTED_TOP_COLOR =
            new Color(0x96b7cb);
    public static final Color ITUNES_TABLE_HEADER_PRESSED_SELECTED_BOTTOM_COLOR =
            new Color(0x536b90);

    ITunesTableHeaderRenderer() {
        setOpaque(false);
        setFont(UIManager.getFont("Table.font").deriveFont(Font.BOLD, 11.0f));
    }

    ITunesTableHeaderRenderer(JTable table) {
        this();
        table.getTableHeader().addMouseListener(new HeaderClickHandler());
        fTable = table;
    }

    public Component getTableCellRendererComponent(
            JTable table, Object value, boolean isSelected, boolean hasFocus,
            int row, int column) {

        // determine if the window has focus.
        Window window = SwingUtilities.getWindowAncestor(fTable);
        boolean windowHasFocus = window != null && window.isFocused();

        int modelColumn = fTable.convertColumnIndexToModel(column);

        setText(value.toString());
        Border leftSpacerBorder = BorderFactory.createCompoundBorder(
                BorderFactory.createMatteBorder(0,1,0,0,
                        getLeftBorderColor(modelColumn, windowHasFocus)),
                BorderFactory.createEmptyBorder(1,4,0,4));

        Border bottomRightBorder = BorderFactory.createCompoundBorder(
                BorderFactory.createMatteBorder(0,0,1,0,
                       ITUNES_TABLE_HEADER_BOTTOM_BORDER_COLOR),
                            BorderFactory.createMatteBorder(0,0,0,1,
                        getRightBorderColor(modelColumn, windowHasFocus)));

        setBorder(BorderFactory.createCompoundBorder(
                bottomRightBorder, leftSpacerBorder));

        fTopColor = getTopColor(modelColumn, windowHasFocus);
        fBottomColor = getBottomColor(modelColumn, windowHasFocus);

        return this;
    }

    /**
     * Gets the color to use as the top gradient color. This color takes into account window
     * focus as well as the selection state of the column.
     */
    private Color getTopColor(int column, boolean windowHasFocus) {
        Color retVal;

        if (!windowHasFocus) {
            retVal = ITUNES_TABLE_HEADER_UNSELECTED_TOP_COLOR;
        } else if (column == fSelectedColumn && column == fPressedColumn) {
            retVal = ITUNES_TABLE_HEADER_PRESSED_SELECTED_TOP_COLOR;
        } else if (column == fSelectedColumn) {
            retVal = ITUNES_TABLE_HEADER_SELECTED_TOP_COLOR;
        } else if (column == fPressedColumn) {
            retVal = ITUNES_TABLE_HEADER_PRESSED_UNSELECTED_TOP_COLOR;
        } else {
            retVal = ITUNES_TABLE_HEADER_UNSELECTED_TOP_COLOR;
        }

        return retVal;
    }

    /**
     * Gets the color to use as the bottom gradient color. This color takes into account window
     * focus as well as the selection state of the column.
     */
    private Color getBottomColor(int column, boolean windowHasFocus) {
        Color retVal;

        if (!windowHasFocus) {
            retVal = ITUNES_TABLE_HEADER_UNSELECTED_BOTTOM_COLOR;
        } else if (column == fSelectedColumn && column == fPressedColumn) {
            retVal = ITUNES_TABLE_HEADER_PRESSED_SELECTED_BOTTOM_COLOR;
        } else if (column == fSelectedColumn) {
            retVal = ITUNES_TABLE_HEADER_SELECTED_BOTTOM_COLOR;
        } else if (column == fPressedColumn) {
            retVal = ITUNES_TABLE_HEADER_PRESSED_UNSELECTED_BOTTOM_COLOR;
        } else {
            retVal = ITUNES_TABLE_HEADER_UNSELECTED_BOTTOM_COLOR;
        }

        return retVal;
    }

    private Color getLeftBorderColor(int column, boolean windowHasFocus) {
        Color retVal;

        if (!windowHasFocus) {
            retVal = ITUNES_TABLE_HEADER_LEFT_UNSELECTED_BORDER_COLOR;
        } else if (column == fSelectedColumn && column == fPressedColumn) {
            retVal = ITUNES_TABLE_HEADER_LEFT_SELECTED_BORDER_COLOR;
        } else if (column == fSelectedColumn) {
            retVal = ITUNES_TABLE_HEADER_LEFT_SELECTED_BORDER_COLOR;
        } else if (column == fPressedColumn) {
            retVal = ITUNES_TABLE_HEADER_LEFT_PRESSED_UNSELECTED_BORDER_COLOR;
        } else {
            retVal = ITUNES_TABLE_HEADER_LEFT_UNSELECTED_BORDER_COLOR;
        }

        return retVal;
    }

    private Color getRightBorderColor(int column, boolean windowHasFocus) {
        return windowHasFocus && column == fSelectedColumn
                ? ITUNES_TABLE_HEADER_RIGHT_SELECTED_BORDER_COLOR
                : ITUNES_TABLE_HEADER_RIGHT_UNSELECTED_BORDER_COLOR;
    }

    @Override
    protected void paintComponent(Graphics g) {
        Graphics2D graphics = (Graphics2D) g.create();

        Paint paint = new GradientPaint(
                0, 0, fTopColor, 0, getHeight(), fBottomColor);

        graphics.setPaint(paint);
        graphics.fillRect(0,0,getWidth(),getHeight());

        graphics.dispose();

        super.paintComponent(g);
    }

    private class HeaderClickHandler extends MouseAdapter {

        private boolean mouseEventIsPerformingPopupTrigger = false;
        
        public void mouseClicked(MouseEvent mouseEvent) {
            // if the MouseEvent is popping up a context menu, do not sort
            if (mouseEventIsPerformingPopupTrigger) return;

            // if the cursor indicates we're resizing columns, do not sort
            if (fTable.getTableHeader().getCursor() == Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)) {
                return;
            }

            final TableColumnModel columnModel = fTable.getColumnModel();
            int viewColumn = columnModel.getColumnIndexAtX(mouseEvent.getX());
            fSelectedColumn = fTable.convertColumnIndexToModel(viewColumn);

            fTable.getTableHeader().repaint();
        }

        public void mousePressed(MouseEvent mouseEvent) {
            this.mouseEventIsPerformingPopupTrigger = mouseEvent.isPopupTrigger();

            if (fTable.getTableHeader().getCursor() != Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)) {
                final TableColumnModel columnModel = fTable.getColumnModel();
                int viewColumn = columnModel.getColumnIndexAtX(mouseEvent.getX());
                fPressedColumn = fTable.convertColumnIndexToModel(viewColumn);

                fTable.getTableHeader().repaint();
            }
        }

        @Override
        public void mouseReleased(MouseEvent e) {
            fPressedColumn = -1;
            fTable.getTableHeader().repaint();
        }
    }
}

To install this renderer, simply set the default renderer on the JTable‘s JTableHeader like this:

table.getTableHeader().setDefaultRenderer(new ITunesTableHeaderRenderer(this));

There’s one last bit of subtlety to handle (and subtly is the name of the game!), namely the upper right hand corner of the enclosing JScrollPane. By default, that corner will be blank white, which sticks out like a sore thumb. To paint that area with the appropriate gradient, you can do either of the following:

  • Call jscrollPane.setCorner(JScrollPane.UPPER_RIGHT_CORNER, new ITunesTableHeaderRenderer())
  • Override the configureEnclosingScrollPane method of JTable, should you be extending that class (this is the technique I used):
        @Override
        protected void configureEnclosingScrollPane() {
            super.configureEnclosingScrollPane();
            Container p = getParent();
    
            if (p instanceof JViewport) {
                Container gp = p.getParent();
                if (gp instanceof JScrollPane) {
                    JScrollPane scrollPane = (JScrollPane)gp;
                    ITunesTableHeaderRenderer renderer = new ITunesTableHeaderRenderer();
                    renderer.setBorder(BorderFactory.createMatteBorder(0,0,1,0,
                           ITUNES_TABLE_HEADER_BOTTOM_BORDER_COLOR));
                    scrollPane.setCorner(JScrollPane.UPPER_RIGHT_CORNER, renderer);
                }
            }
        }
    

The latter technique is a little more self encapsulated, though not quite as clean.

Advertisements

19 Responses to “Sexy Swing App – iTunes Table Header”

  1. Felix Says:

    Cool. I like the hole Sexy Swing App (most works on Tiger, too).

  2. Ken Says:

    We need more Sexy Swing Apps!

  3. Felix Says:

    I noticed something:
    The table header is not automatically repainted when the window looses the focus — the selected column is still highlighted.
    I’ll handle it with a window listener.

  4. Ken Says:

    Interesting – on Leopard using Java 1.5.0_13 it seems to repaint for me. I was under the impression that a full repaint of the hierarchy occurs when the focus state of a window changes.

    What OS are you using?

  5. Ken Says:

    Your right Felix…in a stand-alone app, the table header (and in particular the selected header) doesn’t repaint when focus is lost. For some reason, in my demo app I didn’t see that behavior. I’ll look into it.

  6. Felix Says:

    Great!

  7. Felix Says:

    I fixed it for me now:
    Add a window listener to the parent window and repaint the table header when the window is focused/unfocused (you need to get the window later, not in the constructor; best I worked out is in the getTableCellRendererComponent-method).
    This works fine for me.

    By the way: On Tiger, this also includes Terminal apps.

  8. Ken Says:

    Hi Felix,

    I also added the fix – I still haven’t figured out why it *was* working in my demo app.

    I also looked into the JTable code, to see how it was changing the selection color based on focus. It uses a plain focus listener, which works when the component in question bases its rendering only on *it’s own* focus state. In our case, we needed to base the rendering on the parent window’s focus state.

    As you mention, it isn’t possible to get the parent frame at construction time, so we have to be a little more creative. I created a factory that hands out a Window that repaints itself on focus events – the rest takes care of itself.

    -Ken

  9. Felix Says:

    Hi,

    maybe your solution is better than mine — the table header is the first painted component when the window shows (I’ve got quite a complex layout, so painting takes at least a quarter second on my ppc) because it gets its own rendering process (that’s what I think is the reason). Now everyone sees the great table header :-) before they see the rest.

    Felix

  10. rbs Says:

    THis is pretty nifty, but you need to alter the mouseClicked and mousePressed methods in HeaderClickHandler to test if column selection is allowed in the table. Otherwise is highlights a clicked column heading even when column selection is turned off.

    As is, you have in mouseClicked…

    final TableColumnModel columnModel = fTable.getColumnModel();
    int viewColumn = columnModel.getColumnIndexAtX(mouseEvent.getX());
    fSelectedColumn = fTable.convertColumnIndexToModel(viewColumn);

    That would be better as…

    final TableColumnModel columnModel = fTable.getColumnModel();
    if (columnModel.getColumnSelectionAllowed ( ))
    {
    int viewColumn = columnModel.getColumnIndexAtX(mouseEvent.getX());
    fSelectedColumn = fTable.convertColumnIndexToModel(viewColumn);
    }

  11. Ken Says:

    Hmmm…I don’t know if you’d want to prevent sorting if columns weren’t allowed to be selected. Column selection relates to selecting all the cells in a column, where as clicking the header indicates that a sort operation should be performed. These seem like two distinct things to me.

  12. rbs Says:

    Just to be clear… In my case I have a particular table where sorting is not allowed, nor is column selection. Consequently, I do not want _anything_ to happen if the user clicks on a column heading. And that includes changing the color of the header. The stock default header renderer behaves the way I want, and adding the if-then clauses that i suggest causes ITunesTableHeaderRenderer to do so also.

    As for why that’s the case, it’s actually a tree-table with just a few columns. Sorting is automatic, and row selection is preferred.

  13. ishkafel Says:

    Hi i read you post at java-dev mailing list, and i tried to use your header renderer, and do some changes, but i don’t know for some reason the sort arrow never drawn even when i just copy paste your code… does it suppose to happen or just in my case?
    Can anybody help me with sorter arrow drawing?

    thanks…

  14. Ken Says:

    I should have pointed out that the table sorting and the sorting arrows were provided by Glazed Lists. If your interested in using Glazed Lists sorting capabilities, check out this demo code.

    Sorry about the confusion.

  15. ishkafel Says:

    thanks…
    but I finally able to figure out how to draw sorter arrow with your table header, for some reason my table just need ordinary sorting ability and it’s quite easier to just use jdk6 auto sorter ability…

    thanks… nice project and awesome Swing tips… can’t wait for your new tips…

  16. Ken Says:

    Glad you got it working. I’ll talk to the Glazed Lists guys about maybe integrating with the JDK 6 sort header rendering (not that that would have helped you).

  17. ijabz Says:

    This table header rendering code is great , its something Ive wanted to achieve for quite some time. I actually use Swingx and JXTables so I’ve made modification to your code so that it works with a JXTable with in built sorting on Java 1.5.

    Screenshot: http://www.jthink.net/jaikoz/jsp/code/jaikozusingmacwidgets.jpg
    Code: http://www.jthink.net/jaikoz/jsp/code/JaikozColumnHeaderRenderer.java

  18. Ken Says:

    Looks great Paul! I’ll take a look at your changes and see if I can incorporate them back in. Also note that I’m currently working on iTunes style scroll bars which may be of interest to you.

    -Ken

  19. ijabz Says:

    The key changes are in getTableCellRendererComponent, and I simplified the HeaderClickHandler so that it just identified mouse presses and didnt deal with the sorting.

    One additional thing I did do is change your Painter code to implement Swingx painters instead then I could use the same Painter to paint the background of a JXLabel used as the header for my browser genre/artist/album list. So unlike others comments on the list I would actually prefer the dependencies on swingx to be kept so your code can be eaily used in situations that might not have ocurred to you, but I can see why the dependency has been removed.


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: