Friday, March 4, 2011

Towards a Wrapping JComboBox

Okay, so I'm going to go ahead and publicly document a terrible decision I made a while ago. I decided that in order to make the user interface I wanted, I needed a WrappingComboBox. I wanted a JComboBox-like interface that would show a collection of options of widely varying lengths but that would not need to expand to occupy the space of the largest item but that could takeup multiple lines of text. I basically wanted this:


I wanted to choose from a set of options, but not require the entire selected option be visible when selected.

This seemingly simple task led me on an increasingly frustrating and desparate struggle to create something acceptable, ultimately including digging into the OpenJDK source code to see what's going on. The end product does what I want, mostly, but it also prompted the following comment on the Java forum here:

"I've seen guys abuse Swing Components before, making them do things they shouldn't do... but I think you got em all beat!"


Okay, so here it is:




import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Insets;
import java.awt.LayoutManager;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.InputMethodEvent;
import java.awt.event.InputMethodListener;
import java.util.Vector;

import javax.swing.BoxLayout;
import javax.swing.ComboBoxEditor;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JViewport;
import javax.swing.ListCellRenderer;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingUtilities;
import javax.swing.event.EventListenerList;
import javax.swing.plaf.basic.BasicComboBoxUI;
import javax.swing.plaf.basic.BasicComboPopup;
import javax.swing.plaf.basic.ComboPopup;
import javax.swing.text.View;

/**
* @author jfolson
*
*/
public class WrappingComboBox extends JComboBox {

WrappingComboBox() {
super();
this.setUI(new WrappingComboBoxUI());
}

WrappingComboBox(Vector<?> items) {
super(items);
this.setUI(new WrappingComboBoxUI());
}

public static class WrappingComboBoxUI extends BasicComboBoxUI {
WrappingHelper wrappingHelper;

public WrappingComboBoxUI() {
super();

}

public WrappingHelper getWrappingHelper() {
if (wrappingHelper == null)
wrappingHelper = new WrappingHelper();
return wrappingHelper;
}

/* Since my Editor, Renderer and Popup are not UIResources, I have to overload these methods
* or else installUI would call BasicComboBoxUI.createXXX anyway and replace mine
* */
@Override
protected ComboBoxEditor createEditor() {
return new WrappingComboBoxEditor();
}

/* See note for createEditor()
* */
@Override
protected ListCellRenderer createRenderer() {
return new WrappingList.WrappingListCellRenderer();
}

/* See note for createEditor()
* */
@Override
protected ComboPopup createPopup() {
return new BasicComboPopup(comboBox) {
@Override
protected JList createList() {
// use the WrappingList for the popup to make it update its
// sizes
return new WrappingList(comboBox.getModel());
// BasicComboPopup overrides default, apparently fixes some
// bug but I can't use it because BasicGraphicsUtils
// functions are private
/*return new JList(comboBox.getModel()) {
@Override
public void processMouseEvent(MouseEvent e) {
if (BasicGraphicsUtils.isMenuShortcutKeyDown(e)) {
// Fix for 4234053. Filter out the Control Key
// from the list.
// ie., don't allow CTRL key deselection.
Toolkit toolkit = Toolkit.getDefaultToolkit();
e = new MouseEvent((Component) e.getSource(), e
.getID(), e.getWhen(), e.getModifiers()
^ toolkit.getMenuShortcutKeyMask(), e
.getX(), e.getY(), e.getXOnScreen(), e
.getYOnScreen(), e.getClickCount(), e
.isPopupTrigger(), MouseEvent.NOBUTTON);
}
super.processMouseEvent(e);
}
};*/
}
};
}

/*Have to overrige getMinimumSize, rectangleForCurrentValue and layoutContainer in the Helper class
* to make a smaller (reasonable) sized popup button, otherwise, for multi-line combobox you get
* ridiculously large buttons.
* */
@Override
public Dimension getMinimumSize(JComponent c) {
if (!isMinimumSizeDirty) {
return new Dimension(cachedMinimumSize);
}
Dimension size = getDisplaySize();
Insets insets = getInsets();
// calculate the width and height of the button
int buttonHeight = size.height;
if (buttonHeight > arrowButton.getPreferredSize().width)
buttonHeight = arrowButton.getPreferredSize().width;
int buttonWidth = arrowButton.getPreferredSize().width;
// adjust the size based on the button width
size.height += insets.top + insets.bottom;
size.width += insets.left + insets.right + buttonWidth;

cachedMinimumSize.setSize(size.width, size.height);
isMinimumSizeDirty = false;

return new Dimension(size);
}

/**
* Returns the area that is reserved for drawing the currently selected
* item.
*/
@Override
protected Rectangle rectangleForCurrentValue() {
int width = comboBox.getWidth();
int height = comboBox.getHeight();
Insets insets = getInsets();
int buttonSize = height - (insets.top + insets.bottom);
if (arrowButton != null) {
buttonSize = arrowButton.getWidth();
}
if (true) {// BasicGraphicsUtils.isLeftToRight(comboBox)) { // this
// method is not visible
return new Rectangle(insets.left, insets.top, width
- (insets.left + insets.right + buttonSize), height
- (insets.top + insets.bottom));
} else { // if I could tell, put the box on the other side for right
// to left checkboxes
return new Rectangle(insets.left + buttonSize, insets.top,
width - (insets.left + insets.right + buttonSize),
height - (insets.top + insets.bottom));
}
}

@Override
protected LayoutManager createLayoutManager() {
return getWrappingHelper();
}

private class WrappingHelper implements LayoutManager {

//
// LayoutManager
//
/* Need to override layoutContainer to put in a smaller popup button
* */
public void addLayoutComponent(String name, Component comp) {} // TODO

public void removeLayoutComponent(Component comp) {} // TODO

public Dimension preferredLayoutSize(Container parent) {
return parent.getPreferredSize();
}

public Dimension minimumLayoutSize(Container parent) {
return parent.getMinimumSize();
}

public void layoutContainer(Container parent) {
JComboBox cb = (JComboBox) parent;
int width = cb.getWidth();
int height = cb.getHeight();

Insets insets = getInsets();
int buttonHeight = height - (insets.top + insets.bottom);

int buttonWidth = buttonHeight;
if (arrowButton != null) {
if (buttonHeight > arrowButton.getPreferredSize().width)
buttonHeight = arrowButton.getPreferredSize().width;
Insets arrowInsets = arrowButton.getInsets();
buttonWidth = arrowButton.getPreferredSize().width
+ arrowInsets.left + arrowInsets.right;
}
Rectangle cvb;

if (arrowButton != null) {
if (true) {// BasicGraphicsUtils.isLeftToRight(cb)) { //
// this method is not visible
arrowButton.setBounds(width
- (insets.right + buttonWidth), insets.top,
buttonWidth, buttonHeight);
} else { // if I could tell, put the box on the other side
// for right to left checkboxes
arrowButton.setBounds(insets.left, insets.top,
buttonWidth, buttonHeight);
}
}
if (editor != null) {
cvb = rectangleForCurrentValue();
editor.setBounds(cvb);
}
}
}

}

public static class WrappingComboBoxEditor extends JScrollPane implements
ComboBoxEditor, ComponentListener {
EventListenerList listenerList = new EventListenerList();
LineAwareTextArea text;

public WrappingComboBoxEditor() {
super();
this.text = new LineAwareTextArea();
this.setViewportView(text);

setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
setPreferredSize(new Dimension(100, 30));

this.setItem("");
text.setLineWrap(true);
text.setWrapStyleWord(false);
text.setEditable(true);

text.addInputMethodListener(new InputMethodListener() {
@Override
public void caretPositionChanged(InputMethodEvent arg0) {}

@Override
public void inputMethodTextChanged(InputMethodEvent arg0) {
Object[] listeners = WrappingComboBoxEditor.this.listenerList
.getListenerList();
// Process the listeners last to first, notifying
// those that are interested in this event
ActionEvent actionEvent = null;
for (int i = listeners.length - 2; i >= 0; i -= 2) {
if (listeners[i] == ActionListener.class) {
if (actionEvent == null)
actionEvent = new ActionEvent(this,
ActionEvent.ACTION_PERFORMED, null);
((ActionListener) listeners[i + 1])
.actionPerformed(actionEvent);
}
}

}

});
text.addComponentListener(this);
}

@Override
public void addActionListener(ActionListener l) {
listenerList.add(ActionListener.class, l);
}

@Override
public Component getEditorComponent() {
return this;
}

@Override
public Object getItem() {
return text.getText();
}

@Override
public void removeActionListener(ActionListener l) {
listenerList.remove(ActionListener.class, l);
}

@Override
public void setItem(Object anObject) {
if (anObject == null)
text.setText("");
else
text.setText(String.valueOf(anObject));
}

@Override
public void selectAll() {
text.selectAll();
}

public void componentHidden(ComponentEvent arg0) {}// TODO

public void componentMoved(ComponentEvent arg0) {}// TODO

@Override
public void componentResized(ComponentEvent arg0) {
JViewport view = this.getViewport();
Dimension viewDim = view.getExtentSize();
Insets insets = view.getInsets();
Dimension wantDim = text.getPreferredScrollableViewportSize();
// update preferred size of combobox
if (true) {// wantDim.height > viewDim.height) { // there may be
// times where
// you don't want it to get smaller, would need to think some
// more about this
viewDim.height = wantDim.height + insets.top + insets.bottom;
view.setPreferredSize(viewDim);
view.revalidate();
((JComponent) this.getParent()).revalidate();
SwingUtilities.windowForComponent(this).pack();
}
}

@Override
public void componentShown(ComponentEvent arg0) {}

}

/**
* Only really need to expose rowHeight but might as well determine the
* correct size it wants its viewport
*
* @author jfolson
*
*/
public static class LineAwareTextArea extends JTextArea {
private int maxVisibleRows = 3; // arbitrary default value, should
// probably be passed in instead

public LineAwareTextArea() {
super();
}

public LineAwareTextArea(String text) {
this();
setText(text);
}

@Override
public Dimension getPreferredScrollableViewportSize() {
Dimension d = this.getPreferredSize();
float hspan = getWidth();
View view = (getUI()).getRootView(this);
view.setSize(hspan, Float.MAX_VALUE);
// float vspan = view.getPreferredSpan(View.Y_AXIS);

Insets insets = getInsets();
// get the desired number of rows
int rows = (d.height / this.getRowHeight());
if (rows > maxVisibleRows)
rows = maxVisibleRows;

d.height = rows * getRowHeight() + insets.top + insets.bottom;
/*System.out.println("prefer view height: " + d.height + " (" + rows
+ " line(s))");*/
return d;
}

public void setMaxVisibleRows(int rows) {
this.maxVisibleRows = rows;
}

public int getMaxVisibleRows() {
return this.maxVisibleRows;
}
}
}



import java.awt.Component;
import java.awt.Dimension;
import java.awt.Insets;

import javax.swing.JFrame;
import javax.swing.JList;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.ListCellRenderer;
import javax.swing.ListModel;
import javax.swing.border.EmptyBorder;
import javax.swing.plaf.basic.BasicListUI;
import javax.swing.text.View;

/**
* List that wraps its text! Whenever the list's layout (e.g. size) changes the
* list's UI is instructed to update its layout too.
*/
public class WrappingList extends JList {

public WrappingList() {
setUI(new WrappingListUI());
setCellRenderer(new WrappingListCellRenderer());
}

public WrappingList(ListModel model) {
super(model);
setUI(new WrappingListUI());
setCellRenderer(new WrappingListCellRenderer());
}

@Override
public void doLayout() {
((WrappingListUI) getUI()).updateLayoutState();
super.doLayout();
}

@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}

/**
* ListUI implementation that exposes the method for updating its layout
*/
private static class WrappingListUI extends BasicListUI {

@Override
public void updateLayoutState() {
super.updateLayoutState();
}

}

/**
* List cell renderer that uses the list's width to alter its preferred size
* TODO - override bound properties in the same way as
* DefaultListCellRenderer
*/
public static class WrappingListCellRenderer extends JTextPane implements
ListCellRenderer {

public WrappingListCellRenderer() {
setBorder(new EmptyBorder(1, 1, 1, 1));
}

public Component getListCellRendererComponent(JList list, Object value,
int index, boolean selected, boolean hasFocus) {

setBackground(selected ? list.getSelectionBackground() : list
.getBackground());
setForeground(selected ? list.getSelectionForeground() : list
.getForeground());
// TODO - border, font etc.

setText(String.valueOf(value));

float hspan = list.getWidth();
View view = (getUI()).getRootView(this);
view.setSize(hspan, Float.MAX_VALUE);
float vspan = view.getPreferredSpan(View.Y_AXIS);

Insets insets = getInsets();
setPreferredSize(new Dimension(insets.left + insets.right
+ (int) hspan, insets.top + insets.bottom + (int) vspan));

return this;
}
}

public static void main(String[] args) {
new Test();
}

public static class Test extends JFrame {

public Test() {
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
init();
pack();
show();
}

private void init() {
JList list = new WrappingList();
list
.setListData(new Object[] {
"Item One",
"Item Two content content content content Item Two content content content content Item Two content content content content Item Two content content content content" });

setContentPane(new JScrollPane(list,
JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
JScrollPane.HORIZONTAL_SCROLLBAR_NEVER));
}

}
}


And a main to run:


public class ComboTest extends JFrame {

public ComboTest() {
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
init();
pack();
show();
}

private void init() {

ScrollablePanel mainPanel = new ScrollablePanel();
mainPanel.setScrollableWidth(ScrollableSizeHint.FIT);
mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
ScrollablePanel fieldPanel = new ScrollablePanel();
fieldPanel.setLayout(new BoxLayout(fieldPanel, BoxLayout.X_AXIS));
fieldPanel.setScrollableWidth(ScrollableSizeHint.FIT);

Vector<String> items = new Vector<String>();
items.add("Item One");

JComboBox list = new WrappingComboBox(items);
list.setEditable(true);

JLabel fieldLabel = new JLabel("Label: ");
fieldLabel.setAlignmentY(TOP_ALIGNMENT);
list.setAlignmentY(TOP_ALIGNMENT);
fieldPanel.add(fieldLabel);
fieldPanel.add(list);
mainPanel.add(fieldPanel);

fieldPanel = new ScrollablePanel();
fieldPanel.setLayout(new BoxLayout(fieldPanel, BoxLayout.X_AXIS));
fieldPanel.setScrollableWidth(ScrollableSizeHint.FIT);

items
.add("Item Two content content content content Item Two content content content content Item Two content content content content Item Two content content content content");

list = new WrappingComboBox(items);
list.setEditable(true);

fieldLabel = new JLabel("Label: ");
fieldLabel.setAlignmentY(TOP_ALIGNMENT);
list.setAlignmentY(TOP_ALIGNMENT);
fieldPanel.add(fieldLabel);
fieldPanel.add(list);
mainPanel.add(fieldPanel);
JScrollPane mainScrolls = new JScrollPane(mainPanel,
JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
mainScrolls.setPreferredSize(new Dimension(200, 200));
setContentPane(mainScrolls);
}
}

public static void main(String[] args) {
new ComboTest();
}
}

So now it doesn't look nearly so bad, but Swing does a lot of delegating to helper classes, and I had to track down those helper classes, none of which make much of an appearance in the API or javadoc and make sure that my versions were being used. In particular, I had to override createEditor(), createRenderer() and createPopup() or else the super classes would just replace my classes with their own.

There was also a bit of math in determining sizes that I had to consult the OpenJDK to determine the right things to query. I changed it up a bit in order to reduce the size of the popup button for taller WrappingComboBoxes.

The ScrollablePanel class referenced comes from here but I don't think it's actually necessary for simple use cases but it's definitely worth knowing about. Feel free to ask questions. Hopefully this is helpful to at least one person somewhere sometime.

No comments:

Post a Comment