/** * L2FProd.com Common Components 7.3 License. * * Copyright 2005-2007 L2FProd.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.l2fprod.common.swing; import java.awt.AlphaComposite; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Composite; import java.awt.Container; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.LayoutManager; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.image.BufferedImage; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import javax.swing.AbstractAction; import javax.swing.JComponent; import javax.swing.JPanel; import javax.swing.SwingUtilities; import javax.swing.Timer; /** * <code>JCollapsiblePane</code> provides a component which can collapse or * expand its content area with animation and fade in/fade out effects. * It also acts as a standard container for other Swing components. * * <p> * In this example, the <code>JCollapsiblePane</code> is used to build * a Search pane which can be shown and hidden on demand. * * <pre> * <code> * JCollapsiblePane cp = new JCollapsiblePane(); * * // JCollapsiblePane can be used like any other container * cp.setLayout(new BorderLayout()); * * // the Controls panel with a textfield to filter the tree * JPanel controls = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); * controls.add(new JLabel("Search:")); * controls.add(new JTextField(10)); * controls.add(new JButton("Refresh")); * controls.setBorder(new TitledBorder("Filters")); * cp.add("Center", controls); * * JFrame frame = new JFrame(); * frame.setLayout(new BorderLayout()); * * // Put the "Controls" first * frame.add("North", cp); * * // Then the tree - we assume the Controls would somehow filter the tree * JScrollPane scroll = new JScrollPane(new JTree()); * frame.add("Center", scroll); * * // Show/hide the "Controls" * JButton toggle = new JButton(cp.getActionMap().get(JCollapsiblePane.TOGGLE_ACTION)); * toggle.setText("Show/Hide Search Panel"); * frame.add("South", toggle); * * frame.pack(); * frame.setVisible(true); * </code> * </pre> * * <p> * Note: <code>JCollapsiblePane</code> requires its parent container to have a * {@link java.awt.LayoutManager} using {@link #getPreferredSize()} when * calculating its layout (example {@link com.l2fprod.common.swing.PercentLayout}, * {@link java.awt.BorderLayout}). * * @javabean.attribute * name="isContainer" * value="Boolean.TRUE" * rtexpr="true" * * @javabean.attribute * name="containerDelegate" * value="getContentPane" * * @javabean.class * name="JCollapsiblePane" * shortDescription="A pane which hides its content with an animation." * stopClass="java.awt.Component" * * @author rbair (from the JDNC project) * @author <a href="mailto:fred@L2FProd.com">Frederic Lavigne</a> */ public class JCollapsiblePane extends JPanel { /** * Used when generating PropertyChangeEvents for the "animationState" property */ public final static String ANIMATION_STATE_KEY = "animationState"; /** * JCollapsible has a built-in toggle action which can be bound to buttons. * Accesses the action through * <code>collapsiblePane.getActionMap().get(JCollapsiblePane.TOGGLE_ACTION)</code>. */ public final static String TOGGLE_ACTION = "toggle"; /** * The icon used by the "toggle" action when the JCollapsiblePane is * expanded, i.e the icon which indicates the pane can be collapsed. */ public final static String COLLAPSE_ICON = "collapseIcon"; /** * The icon used by the "toggle" action when the JCollapsiblePane is * collapsed, i.e the icon which indicates the pane can be expanded. */ public final static String EXPAND_ICON = "expandIcon"; /** * Indicates whether the component is collapsed or expanded */ private boolean collapsed = false; /** * Timer used for doing the transparency animation (fade-in) */ private Timer animateTimer; private AnimationListener animator; private int currentHeight = -1; private WrapperContainer wrapper; private boolean useAnimation = true; private AnimationParams animationParams; /** * Constructs a new JCollapsiblePane with a {@link JPanel} as content pane and * a vertical {@link PercentLayout} with a gap of 2 pixels as layout manager. */ public JCollapsiblePane() { super.setLayout(new BorderLayout(0, 0)); JPanel panel = new JPanel(); panel.setLayout(new PercentLayout(PercentLayout.VERTICAL, 2)); setContentPane(panel); animator = new AnimationListener(); setAnimationParams(new AnimationParams(30, 8, 0.01f, 1.0f)); // add an action to automatically toggle the state of the pane getActionMap().put(TOGGLE_ACTION, new ToggleAction()); } /** * Toggles the JCollapsiblePane state and updates its icon based on the * JCollapsiblePane "collapsed" status. */ private class ToggleAction extends AbstractAction implements PropertyChangeListener { public ToggleAction() { super(TOGGLE_ACTION); updateIcon(); // the action must track the collapsed status of the pane to update its // icon JCollapsiblePane.this.addPropertyChangeListener("collapsed", this); } public void putValue(String key, Object newValue) { super.putValue(key, newValue); if (EXPAND_ICON.equals(key) || COLLAPSE_ICON.equals(key)) { updateIcon(); } } public void actionPerformed(ActionEvent e) { setCollapsed(!isCollapsed()); } public void propertyChange(PropertyChangeEvent evt) { updateIcon(); } void updateIcon() { if (isCollapsed()) { putValue(SMALL_ICON, getValue(EXPAND_ICON)); } else { putValue(SMALL_ICON, getValue(COLLAPSE_ICON)); } } } /** * Sets the content pane of this JCollapsiblePane. Components must be added * to this content pane, not to the JCollapsiblePane. * * @param contentPanel * @throws IllegalArgumentException * if contentPanel is null */ public void setContentPane(Container contentPanel) { if (contentPanel == null) { throw new IllegalArgumentException("Content pane can't be null"); } if (wrapper != null) { super.remove(wrapper); } wrapper = new WrapperContainer(contentPanel); super.addImpl(wrapper, BorderLayout.CENTER, -1); } /** * @return the content pane */ public Container getContentPane() { return wrapper.c; } /** * Overriden to redirect call to the content pane. */ public void setLayout(LayoutManager mgr) { // wrapper can be null when setLayout is called by "super()" constructor if (wrapper != null) { getContentPane().setLayout(mgr); } } /** * Overriden to redirect call to the content pane. */ protected void addImpl(Component comp, Object constraints, int index) { getContentPane().add(comp, constraints, index); } /** * Overriden to redirect call to the content pane */ public void remove(Component comp) { getContentPane().remove(comp); } /** * Overriden to redirect call to the content pane. */ public void remove(int index) { getContentPane().remove(index); } /** * Overriden to redirect call to the content pane. */ public void removeAll() { getContentPane().removeAll(); } /** * If true, enables the animation when pane is collapsed/expanded. If false, * animation is turned off. * * <p> * When animated, the <code>JCollapsiblePane</code> will progressively * reduce (when collapsing) or enlarge (when expanding) the height of its * content area until it becomes 0 or until it reaches the preferred height of * the components it contains. The transparency of the content area will also * change during the animation. * * <p> * If not animated, the <code>JCollapsiblePane</code> will simply hide * (collapsing) or show (expanding) its content area. * * @param animated * @javabean.property bound="true" preferred="true" */ public void setAnimated(boolean animated) { if (animated != useAnimation) { useAnimation = animated; firePropertyChange("animated", !useAnimation, useAnimation); } } /** * @return true if the pane is animated, false otherwise * @see #setAnimated(boolean) */ public boolean isAnimated() { return useAnimation; } /** * @return true if the pane is collapsed, false if expanded */ public boolean isCollapsed() { return collapsed; } /** * Expands or collapses this <code>JCollapsiblePane</code>. * * <p> * If the component is collapsed and <code>val</code> is false, then this * call expands the JCollapsiblePane, such that the entire JCollapsiblePane * will be visible. If {@link #isAnimated()} returns true, the expansion will * be accompanied by an animation. * * <p> * However, if the component is expanded and <code>val</code> is true, then * this call collapses the JCollapsiblePane, such that the entire * JCollapsiblePane will be invisible. If {@link #isAnimated()} returns true, * the collapse will be accompanied by an animation. * * @see #isAnimated() * @see #setAnimated(boolean) * @javabean.property * bound="true" * preferred="true" */ public void setCollapsed(boolean val) { if (collapsed != val) { collapsed = val; if (isAnimated()) { if (collapsed) { setAnimationParams(new AnimationParams(30, Math.max(8, wrapper .getHeight() / 10), 1.0f, 0.01f)); animator.reinit(wrapper.getHeight(), 0); animateTimer.start(); } else { setAnimationParams(new AnimationParams(30, Math.max(8, getContentPane().getPreferredSize().height / 10), 0.01f, 1.0f)); animator.reinit(wrapper.getHeight(), getContentPane() .getPreferredSize().height); animateTimer.start(); } } else { wrapper.c.setVisible(!collapsed); invalidate(); doLayout(); } repaint(); firePropertyChange("collapsed", !collapsed, collapsed); } } public Dimension getMinimumSize() { return getPreferredSize(); } /** * The critical part of the animation of this <code>JCollapsiblePane</code> * relies on the calculation of its preferred size. During the animation, its * preferred size (specially its height) will change, when expanding, from 0 * to the preferred size of the content pane, and the reverse when collapsing. * * @return this component preferred size */ public Dimension getPreferredSize() { /* * The preferred size is calculated based on the current position of the * component in its animation sequence. If the Component is expanded, then * the preferred size will be the preferred size of the top component plus * the preferred size of the embedded content container. <p>However, if the * scroll up is in any state of animation, the height component of the * preferred size will be the current height of the component (as contained * in the currentHeight variable) */ Dimension dim; if (!isAnimated()) { if (getContentPane().isVisible()) { dim = getContentPane().getPreferredSize(); } else { dim = super.getPreferredSize(); } } else { dim = new Dimension(getContentPane().getPreferredSize()); if (!getContentPane().isVisible() && currentHeight != -1) { dim.height = currentHeight; } } return dim; } /** * Sets the parameters controlling the animation * * @param params * @throws IllegalArgumentException * if params is null */ private void setAnimationParams(AnimationParams params) { if (params == null) { throw new IllegalArgumentException( "params can't be null"); } if (animateTimer != null) { animateTimer.stop(); } animationParams = params; animateTimer = new Timer(animationParams.waitTime, animator); animateTimer.setInitialDelay(0); } /** * Tagging interface for containers in a JCollapsiblePane hierarchy who needs * to be revalidated (invalidate/validate/repaint) when the pane is expanding * or collapsing. Usually validating only the parent of the JCollapsiblePane * is enough but there might be cases where the parent parent must be * validated. */ public static interface JCollapsiblePaneContainer { Container getValidatingContainer(); } /** * Parameters controlling the animations */ private static class AnimationParams { final int waitTime; final int deltaY; final float alphaStart; final float alphaEnd; /** * @param waitTime * the amount of time in milliseconds to wait between calls to the * animation thread * @param deltaY * the delta in the Y direction to inc/dec the size of the scroll * up by * @param alphaStart * the starting alpha transparency level * @param alphaEnd * the ending alpha transparency level */ public AnimationParams(int waitTime, int deltaY, float alphaStart, float alphaEnd) { this.waitTime = waitTime; this.deltaY = deltaY; this.alphaStart = alphaStart; this.alphaEnd = alphaEnd; } } /** * This class actual provides the animation support for scrolling up/down this * component. This listener is called whenever the animateTimer fires off. It * fires off in response to scroll up/down requests. This listener is * responsible for modifying the size of the content container and causing it * to be repainted. * * @author Richard Bair */ private final class AnimationListener implements ActionListener { /** * Mutex used to ensure that the startHeight/finalHeight are not changed * during a repaint operation. */ private final Object ANIMATION_MUTEX = "Animation Synchronization Mutex"; /** * This is the starting height when animating. If > finalHeight, then the * animation is going to be to scroll up the component. If it is < then * finalHeight, then the animation will scroll down the component. */ private int startHeight = 0; /** * This is the final height that the content container is going to be when * scrolling is finished. */ private int finalHeight = 0; /** * The current alpha setting used during "animation" (fade-in/fade-out) */ private float animateAlpha = 1.0f; public void actionPerformed(ActionEvent e) { /* * Pre-1) If startHeight == finalHeight, then we're done so stop the timer * 1) Calculate whether we're contracting or expanding. 2) Calculate the * delta (which is either positive or negative, depending on the results * of (1)) 3) Calculate the alpha value 4) Resize the ContentContainer 5) * Revalidate/Repaint the content container */ synchronized (ANIMATION_MUTEX) { if (startHeight == finalHeight) { animateTimer.stop(); animateAlpha = animationParams.alphaEnd; // keep the content pane hidden when it is collapsed, other it may // still receive focus. if (finalHeight > 0) { wrapper.showContent(); validate(); JCollapsiblePane.this.firePropertyChange(ANIMATION_STATE_KEY, null, "expanded"); return; } } final boolean contracting = startHeight > finalHeight; final int delta_y = contracting?-1 * animationParams.deltaY :animationParams.deltaY; int newHeight = wrapper.getHeight() + delta_y; if (contracting) { if (newHeight < finalHeight) { newHeight = finalHeight; } } else { if (newHeight > finalHeight) { newHeight = finalHeight; } } animateAlpha = (float)newHeight / (float)wrapper.c.getPreferredSize().height; Rectangle bounds = wrapper.getBounds(); int oldHeight = bounds.height; bounds.height = newHeight; wrapper.setBounds(bounds); bounds = getBounds(); bounds.height = (bounds.height - oldHeight) + newHeight; currentHeight = bounds.height; setBounds(bounds); startHeight = newHeight; // it happens the animateAlpha goes over the alphaStart/alphaEnd range // this code ensures it stays in bounds. This behavior is seen when // component such as JTextComponents are used in the container. if (contracting) { // alphaStart > animateAlpha > alphaEnd if (animateAlpha < animationParams.alphaEnd) { animateAlpha = animationParams.alphaEnd; } if (animateAlpha > animationParams.alphaStart) { animateAlpha = animationParams.alphaStart; } } else { // alphaStart < animateAlpha < alphaEnd if (animateAlpha > animationParams.alphaEnd) { animateAlpha = animationParams.alphaEnd; } if (animateAlpha < animationParams.alphaStart) { animateAlpha = animationParams.alphaStart; } } wrapper.alpha = animateAlpha; validate(); } } void validate() { Container parent = SwingUtilities.getAncestorOfClass( JCollapsiblePaneContainer.class, JCollapsiblePane.this); if (parent != null) { parent = ((JCollapsiblePaneContainer)parent).getValidatingContainer(); } else { parent = getParent(); } if (parent != null) { if (parent instanceof JComponent) { ((JComponent)parent).revalidate(); } else { parent.invalidate(); } parent.doLayout(); parent.repaint(); } } /** * Reinitializes the timer for scrolling up/down the component. This method * is properly synchronized, so you may make this call regardless of whether * the timer is currently executing or not. * * @param startHeight * @param stopHeight */ public void reinit(int startHeight, int stopHeight) { synchronized (ANIMATION_MUTEX) { JCollapsiblePane.this.firePropertyChange(ANIMATION_STATE_KEY, null, "reinit"); this.startHeight = startHeight; this.finalHeight = stopHeight; animateAlpha = animationParams.alphaStart; currentHeight = -1; wrapper.showImage(); } } } private final class WrapperContainer extends JPanel { private BufferedImage img; private Container c; float alpha = 1.0f; public WrapperContainer(Container c) { super(new BorderLayout()); this.c = c; add(c, BorderLayout.CENTER); // we must ensure the container is opaque. It is not opaque it introduces // painting glitches specially on Linux with JDK 1.5 and GTK look and feel. // GTK look and feel calls setOpaque(false) if (c instanceof JComponent && !((JComponent)c).isOpaque()) { ((JComponent)c).setOpaque(true); } } public void showImage() { // render c into the img makeImage(); c.setVisible(false); } public void showContent() { currentHeight = -1; c.setVisible(true); } void makeImage() { // if we have no image or if the image has changed if (getGraphicsConfiguration() != null && getWidth() > 0) { Dimension dim = c.getPreferredSize(); // width and height must be > 0 to be able to create an image if (dim.height > 0) { img = getGraphicsConfiguration().createCompatibleImage(getWidth(), dim.height); c.setSize(getWidth(), dim.height); c.paint(img.getGraphics()); } else { img = null; } } } public void paintComponent(Graphics g) { if (!useAnimation || c.isVisible()) { super.paintComponent(g); } else { // within netbeans, it happens we arrive here and the image has not been // created yet. We ensure it is. if (img == null) { makeImage(); } // and we paint it only if it has been created and only if we have a // valid graphics if (g != null && img != null) { // draw the image with y being height - imageHeight g.drawImage(img, 0, getHeight() - img.getHeight(), null); } } } public void paint(Graphics g) { Graphics2D g2d = (Graphics2D)g; Composite oldComp = g2d.getComposite(); Composite alphaComp = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha); g2d.setComposite(alphaComp); super.paint(g2d); g2d.setComposite(oldComp); } } }