петък, 27 юли 2007 г.

Creating a custom UI delegate for JTabbedPane

This article was found on someone's blog, and I copied it here to preserve it in case that blog goes down. I find it very informative and thorough. At the end of the article, there is a link to the original post.

In my opinion one of the most underappreciated (and perhaps underused) features of Swing is the ability to create custom UI delegates for existing controls. It seems to me that most of the time new delegates are only created as part of developing a new complete look and feel, however I think they could be better leveraged to help add polish to existing applications.

For example, if you look at an application like Adobe Photoshop, in order to save space in their palettes, they use a small tab control. The same goes for most of the Microsoft Office products. They each contain customized tab controls that better integrate into the confinements of their respective user interfaces. Functionally, these behave like a standard tab controls, however the look and feel of these are different.

Over the years, I’ve seen many custom Swing controls implemented where the developers have re-implemented all that logic that already exists in an existing control with the only purpose of creating a version of that control that looks different. In fact, I know that I am guilty of doing that as well. Doesn’t it seem stupid to have to reimplement the logic for a button, a tab control, a checkbox or whatever control you are trying to create if your only goal is to change the way it looks?

Luckily for us Swing developers, there is a way just to address this exact problem. :-)

I thought a good (and simple) example of how to implement a custom UI delegate would be to create an implementation for JTabbedPane that makes it look like the tabs used in the palletes of Adobe Photoshop:

An example of how the tabs look in an Adobe Photoshop palette

These tabs are simple enough, that we can implement this with little effort, and it will be (hopefully) a good example of how to create your own UI delegate.

Step 1: Create a new delegate class

If you’ve ever dug into the implementation of the different look and feels of Swing, you see that all the look and feels extend a basic look and feel implementation (in the javax.swing.plaf.basic package). These “basic” implementations (generally) break down the drawing of the respective controls into smaller units to make it easier to create a new delegates.

Since we want to create a new delegate for JTabbedPane, our new class needs to extend the BasicTabbedPaneUI class:

import javax.swing.plaf.basic.BasicTabbedPaneUI;

public class PSTabbedPaneUI extends BasicTabbedPaneUI
{

}
Believe it or not, this is actually enough to start using this “custom” delegate in a real application. In the next step, we will create a test application that we can use to see the transformation as we implement our look and feel.

Step 2: Create a small application to test our tabs



In order to test our new delegate, we need to create a small application that will use it. In order to get a good idea of how the delegate behaves, we will create a panel with three tabs in it. Tab 1 will contain a standard JPanel; Tab 2 will contain a JPanel with a black, 2 pixel border; and Tab 3 will contain a JButton. The reason we will do this, is that we can see the changes to the border of the content area of the tab, as well as the insets of the content area.


Here is the code that we will use for our sample application:

public class TestPSTabbedPaneUI
{
public static void main(String[] args)
{
try
{
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
}
catch (Exception exc)
{
// Do nothing...
}

JFrame vFrame = new JFrame();
vFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
vFrame.setSize(200, 200);
JTabbedPane vTab = new JTabbedPane();
vTab.setUI(new PSTabbedPaneUI());

vTab.add(\"One\", new JPanel());

JPanel vPanel2 = new JPanel();
vPanel2.setBorder(BorderFactory.createLineBorder(Color.BLACK,2));
vTab.add(\"Two\", vPanel2);

vTab.add(\"Three\", new JButton(\"three\"));

vFrame.getContentPane().add(vTab);
vFrame.setTitle(\"Tabs Example\");
vFrame.show();
}
}
If we would run this right now, we would see the following:

As you can see, the application runs and is usable, but the tabs just look like standard (older) Windows style tabs. We can now begin with the fun stuff, actually changing the way the tabs are presented.

Step 3: Customize the way the tabs are drawn

In order to change the way the tabs are drawn, we need to override the paintTabBorder method. If you look closely at the the tabs in Photoshop, you will see that in addition to having a border, there is a “beveled” look to the selected tab. It has a white line inside the left and top edge of the, and with a darker gray line inside of the right hand (angled) side. We will implement this beveled look in the paintTabBorder method as well:

 protected void paintTabBorder(Graphics g, int tabPlacement, int tabIndex, int x, int y, int w, int h, boolean isSelected)
{
g.setColor(Color.BLACK);
g.drawLine(x, y, x, y + h);
g.drawLine(x, y, x + w - (h / 2), y);
g.drawLine(x + w - (h / 2), y, x + w + (h / 2), y + h);

if (isSelected)
{
g.setColor(Color.WHITE);
g.drawLine(x + 1, y + 1, x + 1, y + h);
g.drawLine(x + 1, y + 1, x + w - (h / 2), y + 1);

g.setColor(shadow);
g.drawLine(x + w - (h / 2), y + 1, x + w + (h / 2)-1, y + h);
}
}
If we would run this right now, we would see the following:

It’s still pretty ugly, but as you can see, by changing overriding one method, we already have made a drastic change to the way it looks.

Step 4: Customize the way the tabs are painted

If you look at the selected tab in the screenshots from step 3, you can see that you can see the edge of the border of the adjacent tabs. The next thing we will want to do is clean that up. In order to do that, we can extend the paintTabBackground method. In this method, we will simply create a polygon which is the shape of the tab and fill it with the background color of the tab pane:

 protected void paintTabBackground(Graphics g, int tabPlacement, int tabIndex, int x, int y, int w, int h, boolean isSelected)
{
Polygon shape = new Polygon();

shape.addPoint(x, y + h);
shape.addPoint(x, y);
shape.addPoint(x + w - (h / 2), y);

if (isSelected || (tabIndex == (rects.length - 1)))
{
shape.addPoint(x + w + (h / 2), y + h);
}
else
{
shape.addPoint(x + w, y + (h / 2));
shape.addPoint(x + w, y + h);
}

g.setColor(tabPane.getBackground());
g.fillPolygon(shape);
}
If we would run this right now, we would see the following:

With that simple change, the tabs themselves look a lot cleaner and a lot more polished, however there is still alot we need to do finish things off.

Step 5: Supress the painting of the focus indicator

If you look at the Photoshop screenshot, there is no focus indicator for the tabs. Additionally, if you look at the screenshots from Step 4, you will notice that that the focus indicators are rectangular, while the buttons are not. In order to simulate the way the Photoshop tabs are implemented, we are going to supress the painting of the focus indicator. In order to do this, we just need to override the paintFocusIndicator method and have it do nothing:

 protected void paintFocusIndicator(Graphics g, int tabPlacement, Rectangle[] rects, int tabIndex, Rectangle iconRect, Rectangle textRect, boolean isSelected)
{
// Do nothing
}

If we run our sample application now, we would see the following:

Without the focus indicator painted, it looks alot more like the original control we are copying.

Step 6: Change the insets of the tab bar

If look at the screenshot of the Photoshop tabs, you will notice that there is a 4 pixel gap between the left edge of the first tab (”Layers”) and the left hand side of the container. In our current version, the left hand side of the tab is directly agains the left edge of the container.

Additionally, if you look at our current version, you will see that the text for the tabs are inset a few pixels farther from the top edge of the tab than in the original Photoshop tab.

In order to fix this, we need to override the installDefaults method and change the default values for the insets. To fix problem number 1, we will need to modify the tabAreaInsets field, and to fix problem number 2, we will need to modify the selectedTabPadInsets field and the tabInsets field:

 protected void installDefaults()
{
super.installDefaults();
tabAreaInsets.left = 4;
selectedTabPadInsets = new Insets(0, 0, 0, 0);
tabInsets = selectedTabPadInsets;
}
If we run our sample application now, we would see the following:

While we fixed the problem with the insets, the changes make our tabs look alot worse than before. In order to make them look better, we need a way to specify their size.

Step 7: Specify the size of the tabs

In order to specify the size of the tabs we need to override the calculateTabHeight method and the calculateTabWidth method. We can also use the calculateTabHeight method to enforce the fact that a tab will always be a height that is divisible by two. This will make sure that the angled line on the right hand side of the tabs always looks good (unlike the screenshots from the previous step).

 protected int calculateTabHeight(int tabPlacement, int tabIndex, int fontHeight)
{
int vHeight = fontHeight;
if (vHeight % 2 > 0)
{
vHeight += 1;
}
return vHeight;
}

protected int calculateTabWidth(int tabPlacement, int tabIndex, FontMetrics metrics)
{
return super.calculateTabWidth(tabPlacement, tabIndex, metrics) + metrics.getHeight();
}
If we run our sample application now, we would see the following:

These changes now give our tabs the right proportions as compared to the real tabs in Photoshop. We can now turn our attention to the text of the tabs.

Step 8: Change the way the text is drawn

In Photoshop, the selected tab always has it’s text drawn in bold. Additionally, if you look at the text of all of the tabs in Photoshop, you will see they are all drawn at the same y location. In our tabs, the text for the selected tab is always drawn 2 pixels higher than the rest. In order to fix these problems, we will need to override a few methods.

First of all, we need to create the bold font to use to draw the selected text. We can create that font in the installDefaults method where we specified the custom insets:

 protected void installDefaults()
{
super.installDefaults();
tabAreaInsets.left = 4;
selectedTabPadInsets = new Insets(0, 0, 0, 0);
tabInsets = selectedTabPadInsets;

boldFont = tabPane.getFont().deriveFont(Font.BOLD);
boldFontMetrics = tabPane.getFontMetrics(boldFont);
}

Note: In a “real” delegate, you would additionally want to listen for the font property of the tabPane to change so that you could update the cached bold font. For our simple example, we’ll overlook that small detail to simplify things.

Next, in order to prevent the text of the selected tab to be drawn at a different y location than the unselected tabs, we need to override the getTabLabelShiftY method:

 protected int getTabLabelShiftY(int tabPlacement, int tabIndex, boolean isSelected)
{
return 0;
}

Finally, in order to paint the text in a bold font for the selected text, we need to override the paintText method:

 protected void paintText(Graphics g, int tabPlacement, Font font, FontMetrics metrics, int tabIndex, String title, Rectangle textRect, boolean isSelected)
{
if (isSelected)
{
int vDifference = (int)(boldFontMetrics.getStringBounds(title,g).getWidth()) - textRect.width;
textRect.x -= (vDifference / 2);
super.paintText(g, tabPlacement, boldFont, boldFontMetrics, tabIndex, title, textRect, isSelected);
}
else
{
super.paintText(g, tabPlacement, font, metrics, tabIndex, title, textRect, isSelected);
}
}
If we run our sample application now, we would see the following:

Step 9: Paint the background behind the tabs

If you look at the background behind (and to the right) of the tabs in Photoshop, it is painted a little bit darker than the color of the tabs. In order to implement this, we need to override the paintTabArea method.

Before we do that however, we need to decide what color to paint the background. In order to make this delegate work fairly well with any look and feel, we will simply create a darker version of the background color by calling the darker()<> method on the background color. We can add the creation of this color to the installDefaults method like we’ve done before:

 protected void installDefaults()
{
super.installDefaults();
tabAreaInsets.left = 4;
selectedTabPadInsets = new Insets(0, 0, 0, 0);
tabInsets = selectedTabPadInsets;

Color background = tabPane.getBackground();
fillColor = background.darker();

boldFont = tabPane.getFont().deriveFont(Font.BOLD);
boldFontMetrics = tabPane.getFontMetrics(boldFont);
}
Now that we have that out of the way, we can implement the paintTabArea method:
 protected void paintTabArea(Graphics g, int tabPlacement, int selectedIndex)
{
int tw = tabPane.getBounds().width;

g.setColor(fillColor);
g.fillRect(0, 0, tw, rects[0].height + 3);

super.paintTabArea(g, tabPlacement, selectedIndex);
}
If we run our sample application now, we would see the following:

The color is a little darker than in the original, however by implementing it this way, the delegate should be reusable between any look and feel (and any background color). You’ll also notice that we have the same issue with the font. The font does match up exactly, but by using the default font for the look and feel, we make the delegate more portable.

Step 10: Change the way the top border of the content area is drawn

If you at the screenshot from the previous step where tab “One” is selected, you will notice that there are a few problems with how the top line of the content area is painted. First of all, the Photoshop tabs have a black line there as opposed to a while line. Secondly, there is a bevel effect in the real version, and finally in our (current) version, the lines don’t even match up to the border of the tabs (on the right hand side of the tab where the angle comes down).

In order to fix this, we will need to override the paintContentBorderTopEdge method:

 protected void paintContentBorderTopEdge(Graphics g, int tabPlacement, int selectedIndex, int x, int y, int w, int h)
{
Rectangle selectedRect = selectedIndex < width =" selectedRect.width">
If we run our sample application now, we would see the following:

With those changes, we are now done with the tab area itself. Now, we just need to clean up the borders (actually remove them) of the content area.

Step 11: Removing the borders in the content area

In order to remove the borders of the content area, we just need to override the methods paintContentBorderRightEdge, paintContentBorderLeftEdge and paintContentBorderBottomEdge and have them do nothing:

 protected void paintContentBorderRightEdge(Graphics g, int tabPlacement, int selectedIndex, int x, int y, int w, int h)
{
// Do nothing
}

protected void paintContentBorderLeftEdge(Graphics g, int tabPlacement, int selectedIndex, int x, int y, int w, int h)
{
// Do nothing
}

protected void paintContentBorderBottomEdge(Graphics g, int tabPlacement, int selectedIndex, int x, int y, int w, int h)
{
// Do nothing
}
If we run our sample application now, we would see the following:

If you look at the screenshot where “One” is selected, you might think we are done with our delegate. However, if you look at the screenshot where tab “Two” is selected, you will notice that the content is still inset a little bit from the edges. In the orignal Photoshop tab, there is no inset, so we still need to remove that before we can be finished.

Step 12: Removing the content area insets

Implementing this turns out to be quite simple as well. All we need to do is override the getContentBorderInsets method:
 protected Insets getContentBorderInsets(int tabPlacement)
{
return new Insets(2, 0, 0, 0);
}

The reason the insets have a pixel height of 2 for the top is that we need to inset for the black line and the white “bevel” line below it.

If we run our sample application now, we would see the following:

If you look at the screenshot where the tab “Two” is selected, you can now see that content area extends to the borders exactly like the Photoshop example.

If you want to play with the example yourself, you can download the source code for the delegate here, and you can download the source code for the test application here.

2 коментара:

Анонимен каза...

Thank god, I searched all over the net for this! Bad links everywhere, thank you so much :)
K

Анонимен каза...

Aw, this was a really nice post. In idea I would like to put in writing like this additionally - taking time and actual effort to make a very good article… but what can I say… I procrastinate alot and by no means seem to get something done.