package com.onaro.util.jfc; import java.awt.AlphaComposite; import java.awt.Composite; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.GraphicsConfiguration; import java.awt.GraphicsEnvironment; import java.awt.GridBagLayout; import java.awt.GridLayout; import java.awt.LayoutManager; import java.awt.Rectangle; import java.awt.Transparency; import java.awt.image.BufferedImage; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import javax.swing.SwingUtilities; /** * A {@link CompoundField} that's painted in one * of two states. In the collapsed state, only the specified * portion of the field is displayed (the default is the first * row or 20 pixels). In the expanded state, all the child * fields are displayed. The default state is collapsed. *

* It does this by rendering the expanded field in an * off-screen bitmap, and then displaying only part of the * bitmap when the field is collapsed. *

* TODO: Handle display more efficiently when the field is * expanded. Use animation for the expand and collapse actions. */ public class CollapsibleField extends CompoundField { private static final long serialVersionUID = 1L; // By default, collapsed state shows first row of grid or grid bag. private static final int DEFAULT_COLLAPSED_ROWS = 1; // Default height for non-grid layouts. private static final int DEFAULT_COLLAPSED_HEIGHT = 20; // Off-screen bitmap. private BufferedImage bitmap = null; // Is the bitmap scheduled to be repainted. private boolean isBitmapDirty = false; // Is the bitmap in a state where we shouldn't modify it. private boolean isPaintingInProgress = false; // Collapsed row count. Only used for grid layouts. private int collapsedRowCount = -1; // Negative when not used. // Collapsed height. Derived from collapsedRowCount for grid layouts. private int collapsedHeight = DEFAULT_COLLAPSED_HEIGHT; // Field is expanded by default. private boolean isExpanded = true; private boolean isCollapsing = false; /** * Creates a collapsible group from an {@link Accordion} control * and one or more {@link CollapsibleField}s. * * @param control that when clicked will collapse or expand the fields * @param fields fields that will collapse or expand together * when the button is clicked */ public static void makeAccordion ( final Accordion control, final CollapsibleField... fields) { control.addPropertyChangeListener (Accordion.PROP_EXPANDED, new PropertyChangeListener() { @Override public void propertyChange (PropertyChangeEvent event) { boolean expand = control.isExpanded(); setExpanded (expand, fields); } }); // Add highlight listeners addHighlightListeners (control, fields); } /** * Expands or collapses one or more {@link CollapsibleField}s. The * fields all expand or all collapse. *

* The change in the fields will not occur immediately. This method * is implemented by adding a job to the Swing event queue. This * allows the task that triggered the change to complete without waiting * for the changes to be done. * @param expand {@code true} to expand, {@code false} to collapse * @param fields to expand or collapse */ public static void setExpanded ( final boolean expand, final CollapsibleField... fields) { SwingUtilities.invokeLater (new Runnable() { @Override public void run() { for (CollapsibleField field: fields) { field.setExpanded (expand); } } }); } /** * Creates a {@link CollapsibleField} with the default layout. * The default layout is a left-aligned column. * @param name of the field, for testing purposes */ public CollapsibleField (String name) { super (name); // Note the supertype constructor invokes setLayout(). } /** * Creates a {@link CollapsibleField} with the specified layout. * @param name of the field, for testing purposes * @param layout {@link LayoutManager} */ public CollapsibleField (String name, LayoutManager layout) { super (name, layout); // Note the supertype constructor invokes setLayout(). } @Override public void setLayout (LayoutManager layout) { super.setLayout (layout); /* * Set default collapsed row count for grid layouts if a * different value hasn't been specified. * * Note non-grid layouts don't use the row count. */ if (layout instanceof GridBagLayout || layout instanceof GridLayout) { if (collapsedRowCount < 0) { collapsedRowCount = DEFAULT_COLLAPSED_ROWS; } } } /** * Expands or collapses the field * @param expand {@code true} to expand, {@code false} to collapse */ public void setExpanded (boolean expand) { if (expand) { if (! isExpanded()) { // Set progress flag to prevent validation loops. isPaintingInProgress = true; expand (false); } } else if (isExpanded()) { // Set progress flag to prevent validation loops. isPaintingInProgress = true; isExpanded = false; revalidate(); } } /** * Expand the field. * @param isTemporary {@code true} if expansion is only * being done to capture an image of the expanded field * in the bitmap, in which case the field should * be collapsed again as soon as the expansion is done */ private void expand (boolean isTemporary) { isExpanded = true; isCollapsing = isTemporary; // Reset sizes to default (not collapsed) values. setPreferredSize (null); setMinimumSize (null); revalidate(); } /** * @return {@code true} if expanded or in the process of expanding */ public boolean isExpanded() { return isExpanded && ! isCollapsing; } /** * Sets the height the control will have when collapsed. * @param collapsedHeight * @throws IllegalArgumentException if collapsedHeight is negative */ public void setCollapsedHeight (int collapsedHeight) throws IllegalArgumentException { if (collapsedHeight < 0) { throw new IllegalArgumentException ("collapsedHeight is negative"); //$NON-NLS-1$ } this.collapsedHeight = collapsedHeight; } /** * @return the height the control will have when collapsed */ public int getCollapsedHeight() { return collapsedHeight; } /** * If the field is using a {@link GridLayout} or {@link GridBagLayout}, * sets the number of rows to be displayed when the field is collapsed. * The default is to display one row. *

* This method may only be called after using the * {@link #CollapsibleField (String)} constructor * (which creates a GridLayout by default) or after passing * a GridLayout or GridBagLayout * to the {@link #CollapsibleField (String, LayoutManager)} constructor * or {@link #setLayout} method. *

* An alternative to this method is to invoke {@link #setCollapsedHeight}, * which sets the collapsed height to a value not dependent * on the layout. * * @param collapsedRowCount the number of rows * @throws IllegalArgumentException if collapsedRowCount is negative * @throws UnsupportedOperationException if the layout of the field * is not a GridLayout or GridBagLayout. * This is because the field must calculate the height of the rows. * It only knows how to do this for certain layouts. */ public void setCollapsedRowCount (int collapsedRowCount) throws IllegalArgumentException, UnsupportedOperationException { if (collapsedHeight < 0) { throw new IllegalArgumentException ("collapsedRowCount is negative"); //$NON-NLS-1$ } LayoutManager layout = getLayout(); if (layout instanceof GridBagLayout || layout instanceof GridLayout) { this.collapsedRowCount = collapsedRowCount; } else { throw new UnsupportedOperationException ("Unsupported layout"); //$NON-NLS-1$ } } /** * Gets the number of rows displayed when the field is collapsed and is * using a grid layout ({@link GridLayout} or {@link GridBagLayout}). * The returned value is irrelevant if the field isn't using a grid layout. * In that case, only {@link #getCollapsedHeight()} is relevant. * * @return number of rows or {@code -1} if the field never used a grid layout * and {@link #setCollapsedRowCount} wasn't called */ public int getCollapsedRowCount() { return collapsedRowCount; } private void updateCollapsedHeight() { if (collapsedRowCount >= 0) { LayoutManager layout = getLayout(); if (layout instanceof GridBagLayout) { computeCollapsedHeight ((GridBagLayout)layout); } if (layout instanceof GridLayout) { computeCollapsedHeight ((GridLayout)layout); } } } private void computeCollapsedHeight (GridBagLayout layout) { // Get dimensions: dimensions[0] is column widths, dimensions[1] is row heights. int[][] dimensions = layout.getLayoutDimensions(); collapsedHeight = 0; for (int i= 0; i < collapsedRowCount; i++) { collapsedHeight += dimensions[1][i]; } } private void computeCollapsedHeight (GridLayout layout) { if (collapsedRowCount == 0) { collapsedHeight = 0; } else { int rowCount = layout.getRows(); if (rowCount == 0) { // GridLayout automatically creates rows if layout.rows is 0. rowCount = getComponentCount() / layout.getColumns(); } if (rowCount == 0) { collapsedHeight = 0; } else { collapsedHeight = getHeight() * collapsedRowCount / rowCount; if (collapsedRowCount > 1) { collapsedHeight += layout.getVgap() * (collapsedRowCount - 1); } } } } @Override public Dimension getMinimumSize() { // Redefine size if field is collapsed. if (! isExpanded && ! isMinimumSizeSet()) { Dimension size = super.getMinimumSize(); size.height = getCollapsedHeight(); setMinimumSize (size); } return super.getMinimumSize(); } @Override public Dimension getPreferredSize() { // Redefine size if field is collapsed. if (! isExpanded && ! isPreferredSizeSet()) { Dimension size = super.getPreferredSize(); size.height = getCollapsedHeight(); setPreferredSize (size); } return super.getPreferredSize(); } @Override public void invalidate() { // Repaint off-screen bitmap if not already doing it. if (! isPaintingInProgress) { isBitmapDirty = true; } super.invalidate(); } @Override public void doLayout() { // If not expanded, layout will be done during painting. if (isExpanded) { super.doLayout(); updateCollapsedHeight(); } else if (! isPaintingInProgress) { isBitmapDirty = true; } } @Override public void paint (Graphics graphics) { // Set flag to avoid validation loops in case we temporarily expand field. isPaintingInProgress = true; // Create the off-screen bitmap if necessary. if (createBitmap (graphics)) { // Creating the off-screen bitmap causes a repaint. So we'll be back. return; } // Paint the off-screen bitmap if necessary. if (paintBitmap()) { // Sometimes painting the off-screen bitmap causes a repaint. So we'll be back. return; } // Blit the off-screen bitmap onto the screen. Rectangle clip = graphics.getClipBounds(); if (clip.width > 0 && clip.height > 0) { graphics.drawImage (bitmap, 0, 0, null); } isPaintingInProgress = false; } /** * Creates bitmap if necessary. * @param graphics context * @return {@code true} if bitmap created, additional painting required */ private boolean createBitmap (Graphics graphics) { // Determine if a new off-screen bitmap is required. boolean isNewBitmapRequired = false; int bitmapWidth; int bitmapHeight; if (bitmap == null) { isNewBitmapRequired = true; bitmapWidth = getWidth(); bitmapHeight = getHeight(); } else { if (isOpaque() != (bitmap.getTransparency() == Transparency.OPAQUE)) { isNewBitmapRequired = true; } if (bitmap.getWidth() < getWidth()) { isNewBitmapRequired = true; bitmapWidth = getWidth(); } else { bitmapWidth = bitmap.getWidth(); } if (bitmap.getHeight() < getHeight()) { isNewBitmapRequired = true; bitmapHeight = getHeight(); } else { bitmapHeight = bitmap.getHeight(); } } if (isNewBitmapRequired) { // Create off-screen bitmap compatible with screen bitmap (for efficiency when blit'd to screen). GraphicsConfiguration graphicsConfig = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration(); if (isOpaque()) { bitmap = graphicsConfig.createCompatibleImage (bitmapWidth, bitmapHeight); } else { bitmap = graphicsConfig.createCompatibleImage (bitmapWidth, bitmapHeight, Transparency.BITMASK); } // Set painting attributes of off-screen bitmap to those of screen bitmap. Graphics2D screenGraphics = (Graphics2D)graphics; Graphics2D bitmapGraphics = bitmap.createGraphics(); bitmapGraphics.setPaint (screenGraphics.getPaint()); bitmapGraphics.setBackground (screenGraphics.getBackground()); bitmapGraphics.setStroke (screenGraphics.getStroke()); bitmapGraphics.setFont (screenGraphics.getFont()); bitmapGraphics.setComposite (screenGraphics.getComposite()); bitmapGraphics.setRenderingHints (screenGraphics.getRenderingHints()); // Mark bitmap dirty (even though painting is in progress) and call paint again to paint it. isBitmapDirty = true; repaint(); } return isNewBitmapRequired; } /** * Paints off-screen bitmap if necessary * @return {@code true} if additional painting required; {@code false} if painting done */ private boolean paintBitmap() { if (isBitmapDirty) { // Must paint off-screen bitmap in expanded state. if (! isExpanded) { // Reset state temporarily and try again. SwingUtilities.invokeLater (new Runnable(){ @Override public void run() { expand (true); } }); return true; } // Erase off-screen bitmap. Graphics2D graphics = bitmap.createGraphics(); if (isOpaque()) { graphics.clearRect (0, 0, bitmap.getWidth(), bitmap.getHeight()); } else { Composite oldComposite = graphics.getComposite(); graphics.setComposite (AlphaComposite.Clear); graphics.fillRect (0, 0, bitmap.getWidth(), bitmap.getHeight()); graphics.setComposite (oldComposite); } // Paint off-screen bitmap. super.paint (graphics); isBitmapDirty = false; // Collapse if we only expanded for painting off-screen bitmap. if (isCollapsing) { SwingUtilities.invokeLater (new Runnable(){ @Override public void run() { isExpanded = false; isCollapsing = false; revalidate(); } }); return true; } } return false; } }