/******************************************************************************* * Copyright (c) 2012-2017 Codenvy, S.A. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Codenvy, S.A. - initial API and implementation *******************************************************************************/ package org.eclipse.che.ide.util; import elemental.css.CSSStyleDeclaration; import elemental.dom.Element; import elemental.events.Event; import elemental.events.EventListener; import org.eclipse.che.ide.collections.Jso; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; /* * TODO: Here's the list of short-term TODOs: * * - Make sure our client measurements are accurate if the element has padding, * margins, border, etc. Luckily, the current clients don't have any of these * * - So far, clients have used pixels for height, etc., I need to figure out * whether other units are kosher with CSS transition. * * - There's a bug in Chrome that I need to file, to repro click four times * quickly on an the expander arrow, and notice that element is no longer * expandable (the element doesn't accept changes with setClassName anymore). * * - A way to give the controller multiple mutually exclusive elements and * transition smoothly (e.g. I am doing this with the * CollaborationNavigationSection, but manually with a show/hide. I think the * animation aesthetics can be improved if this controller knows/manages both * together as one.) * * - For things like fixed height, we can figure it out based on the element. * But, we don't want to do it at time of animation because we have to walk the * CSSStyleRules. Instead, allow the builder to take in a "template" element * that will look like the elements passed to show/hide. * * - Add support for non-animated initial state. */ /** * Controller to aid in animating elements. * <p/> * Rules: * <ul> * <li>Initialize the element by calling * {@link AnimationController#hideWithoutAnimating(Element)}. Don't set * "display: none" in your CSS since the animation controller cannot undo it. * <li>Do not modify the {@link Builder} after calling its * {@link Builder#build()}. * <li>Padding and margins must be specified in px units. * </ul> */ public class AnimationController { public interface AnimationStateListener { void onAnimationStateChanged(Element element, State state); } /** Expands and collapses an element into and out of view. */ public static final AnimationController COLLAPSE_ANIMATION_CONTROLLER = new AnimationController.Builder().setCollapse(true).build(); /** Fades an element into and out of view. */ public static final AnimationController FADE_ANIMATION_CONTROLLER = new AnimationController.Builder().setFade(true).build(); public static final AnimationController COLLAPSE_FADE_ANIMATION_CONTROLLER = new AnimationController.Builder().setCollapse(true).setFade(true).build(); /** Does not animate. */ public static final AnimationController NO_ANIMATION_CONTROLLER = new AnimationController.Builder().build(); /** * Builder for the {@link AnimationController}. Do not modify after calling * {@link #build()}. */ public static class Builder { private boolean collapse; private boolean fade; private boolean fixedHeight; public AnimationController build() { return new AnimationController(this); } // TODO: shrink height or width? /** Defaults to false */ public Builder setCollapse(boolean collapse) { this.collapse = collapse; return this; } /** Defaults to false */ public Builder setFade(boolean fade) { this.fade = fade; return this; } /** Defaults to false */ public Builder setFixedHeight(boolean fixedHeight) { this.fixedHeight = fixedHeight; return this; } } /** Handles the end of a CSS transition. */ private abstract class AbstractTransitionEndHandler implements EventListener { public void handleEndFor(Element elem) { // TODO: Keep an eye on whether or not webkit supports the // vendor prefix free version. If they ever do we should remove this. elem.addEventListener(Event.WEBKITTRANSITIONEND, this, false); // For FF4 when we are ready. elem.addEventListener("transitionend", this, false); } public void unhandleEndFor(Element elem) { elem.removeEventListener(Event.WEBKITTRANSITIONEND, this, false); // For FF4 when we are ready. elem.removeEventListener("transitionend", this, false); } /* * GWT complains that AbstractTransitionEndHandler doesn't define * handleEvent() if we do not include this abstract method to override the * interface method. */ @Override public abstract void handleEvent(Event evt); } /** Handles the end of the show transition. */ private class ShowTransitionEndHandler extends AbstractTransitionEndHandler { @Override public void handleEvent(Event evt) { /* * Transition events propagate, so the event target could be a child of * the element that we are controlling. For example, the child could be a * button (with transitions enabled) within a form that is being animated. * * We verify that the target is actually being animated by the * AnimationController by checking its current state. It will only have a * state if the AnimationController added the state attribute to the * target. */ Element target = (Element)evt.getTarget(); if (isAnyState(target, State.SHOWING)) { showWithoutAnimating(target); // Puts element in SHOWN state } } } /** Handles the end of the hide transition. */ private class HideTransitionEndHandler extends AbstractTransitionEndHandler { @Override public void handleEvent(Event evt) { /* * Transition events propagate, so the event target could be a child of * the element that we are controlling. For example, the child could be a * button (with transitions enabled) within a form that is being animated. * * We verify that the target is actually being animated by the * AnimationController by checking its current state. It will only have a * state if the AnimationController added the state attribute to the * target. */ Element target = (Element)evt.getTarget(); if (isAnyState(target, State.HIDING)) { hideWithoutAnimating(target); // Puts element in HIDDEN state } } } /** An attribute added to an element to indicate its state. */ private static final String ATTR_STATE = "__animControllerState"; /** An attribute added to an element to stash its animation state listener. */ private static final String ATTR_STATE_LISTENER = "__animControllerStateListener"; /** * The states that an element can be in. * <p/> * The only method we call on the state is {@link State#ordinal()}, which * allows the GWT compiler to ordinalize the enums into integer constants. */ public static enum State { /** The element is completely hidden. */ HIDDEN, /** The element is transitioning to the hidden state. */ HIDING, /** The element is completely shown. */ SHOWN, /** The element is transitioning to the shown state. */ SHOWING } final boolean isAnimated; // Visible for testing. private final Builder options; private final ShowTransitionEndHandler showEndHandler; private final HideTransitionEndHandler hideEndHandler; private AnimationController(Builder builder) { this.options = builder; this.showEndHandler = new ShowTransitionEndHandler(); this.hideEndHandler = new HideTransitionEndHandler(); /* * If none of the animated properties are being animated, then the CSS * transition end listener may not execute at all. In that case, we * show/hide the element immediately. */ this.isAnimated = options.collapse || options.fade; } /** * Animate the element out of view. Do not enable transitions in the CSS for this element, or the * animations may not work correctly. AnimationController will enable animations automatically. * * @see #hideWithoutAnimating(Element) */ public void hide(final Element element) { // Early exit if the element is hidden or hiding. if (isAnyState(element, State.HIDDEN, State.HIDING)) { return; } if (!isAnimated) { hideWithoutAnimating(element); return; } // Cancel pending transition event listeners. showEndHandler.unhandleEndFor(element); final CSSStyleDeclaration style = element.getStyle(); if (options.collapse) { // Set height because the CSS transition requires one int height = getCurrentHeight(element); style.setHeight(height + CSSStyleDeclaration.Unit.PX); } // Give the browser a chance to accept the height set above setState(element, State.HIDING); schedule(element, new ScheduledCommand() { @Override public void execute() { // The user changed the state before this command executed. if (!clearLastCommand(element, this) || !isAnyState(element, State.HIDING)) { return; } if (options.collapse) { /* * Hide overflow if changing height, or the overflow will be visible * even as the element collapses. */ AnimationUtils.backupOverflow(style); } AnimationUtils.enableTransitions(style); if (options.collapse) { // Animate all properties that could affect height if collapsing. style.setHeight("0"); style.setMarginTop("0"); style.setMarginBottom("0"); style.setPaddingTop("0"); style.setPaddingBottom("0"); CssUtils.setBoxShadow(element, "0 0"); } if (options.fade) { style.setOpacity(0); } } }); // For webkit based browsers. hideEndHandler.handleEndFor(element); } /** * Animates the element into view. Do not enable transitions in the CSS for this element, or the * animations may not work correctly. AnimationController will enable animations automatically. */ public void show(final Element element) { // Early exit if the element is shown or showing. if (isAnyState(element, State.SHOWN, State.SHOWING)) { return; } if (!isAnimated) { showWithoutAnimating(element); return; } // Cancel pending transition event listeners. hideEndHandler.unhandleEndFor(element); /* * Make this "visible" again so we can measure its eventual height (required * for CSS transitions). We will set its initial state in this event loop, * so the element will not be fully visible. */ final CSSStyleDeclaration style = element.getStyle(); element.getStyle().removeProperty("display"); final int measuredHeight = getCurrentHeight(element); /* * Set the initial state, but not if the element is in the process of * hiding. */ if (!isAnyState(element, State.HIDING)) { if (options.collapse) { // Start the animation at a height of zero. style.setHeight("0"); // We want to animate from total height of 0 style.setMarginTop("0"); style.setMarginBottom("0"); style.setPaddingTop("0"); style.setPaddingBottom("0"); CssUtils.setBoxShadow(element, "0 0"); /* * Hide overflow if expanding the element, or the entire element will be * instantly visible. Do not do this by default, because it could hide * absolutely positioned elements outside of the root element, such as * the arrow on a tooltip. */ AnimationUtils.backupOverflow(style); } if (options.fade) { style.setOpacity(0); } } // Give the browser a chance to accept the properties set above setState(element, State.SHOWING); schedule(element, new ScheduledCommand() { @Override public void execute() { // The user changed the state before this command executed. if (!clearLastCommand(element, this) || !isAnyState(element, State.SHOWING)) { return; } // Enable animations before setting the end state. AnimationUtils.enableTransitions(style); // Set the end state. if (options.collapse) { if (options.fixedHeight) { // The element's styles have a fixed height set, so we just want to // clear our override style.setHeight(""); } else { // Give it an explicit height to animate to, because the element's // height is auto otherwise style.setHeight(measuredHeight + CSSStyleDeclaration.Unit.PX); } style.removeProperty("margin-top"); style.removeProperty("margin-bottom"); style.removeProperty("padding-top"); style.removeProperty("padding-bottom"); CssUtils.removeBoxShadow(element); } if (options.fade) { style.setOpacity(1); } } }); // For webkit based browsers. showEndHandler.handleEndFor(element); } /** * Checks if the specified element is logically hidden, which is true if it is * hidden or in the process of hiding. */ public boolean isHidden(Element element) { return isAnyState(element, State.HIDDEN, State.HIDING); } /** Returns the height as would be set on the CSS "height" property. */ private int getCurrentHeight(final Element element) { // TODO: test to see if horizontal scroll plays nicely CSSStyleDeclaration style = CssUtils.getComputedStyle(element); return element.getClientHeight() - CssUtils.parsePixels(style.getPaddingTop()) - CssUtils.parsePixels(style.getPaddingBottom()); } public void setVisibilityWithoutAnimating(Element element, boolean visibile) { if (visibile) { showWithoutAnimating(element); } else { hideWithoutAnimating(element); } } /** * Hide the element without animating it out of view. Use this method to set * the initial state of the element. */ public void hideWithoutAnimating(Element element) { if (isAnyState(element, State.HIDDEN)) { return; } cancel(element); element.getStyle().setDisplay(CSSStyleDeclaration.Display.NONE); setState(element, State.HIDDEN); } /** Show the element without animating it into view. */ public void showWithoutAnimating(Element element) { if (isAnyState(element, State.SHOWN)) { return; } cancel(element); element.getStyle().removeProperty("display"); setState(element, State.SHOWN); } /** * Sets the listener for animation state change events. * <p/> * <p/> * If an element is not visible in the UI when an animation is applied, the animation will never * complete and the element will stay in the state HIDING until some other animation is applied. */ public void setAnimationStateListener(Element element, AnimationStateListener listener) { ((Jso)element).addField(ATTR_STATE_LISTENER, listener); } public AnimationStateListener getAnimationStateListener(Element element) { return (AnimationStateListener)((Jso)element).getJavaObjectField(ATTR_STATE_LISTENER); } /** Cancel the currently executing animation without completing it. */ private void cancel(Element element) { // Cancel all handlers. setLastCommandImpl(element, null); hideEndHandler.unhandleEndFor(element); showEndHandler.unhandleEndFor(element); // Disable animations. CSSStyleDeclaration style = element.getStyle(); AnimationUtils.removeTransitions(style); if (options.collapse) { AnimationUtils.restoreOverflow(style); } // Remove the height and properties we set. if (options.collapse) { style.removeProperty("height"); style.removeProperty("margin-top"); style.removeProperty("margin-bottom"); style.removeProperty("padding-top"); style.removeProperty("padding-bottom"); } if (options.fade) { style.removeProperty("opacity"); } CssUtils.removeBoxShadow(element); } private void setState(Element element, State state) { element.setAttribute(ATTR_STATE, Integer.toString(state.ordinal())); AnimationStateListener listener = getAnimationStateListener(element); if (listener != null) { listener.onAnimationStateChanged(element, state); } } /** * Check if the element is in any of the specified states. * * @param states * the states to check, null is not allowed * @return true if in any one of the states */ // Visible for testing. boolean isAnyState(Element element, State... states) { // Get the state ordinal from the attribute. String ordinalStr = element.getAttribute(ATTR_STATE); // NOTE: The following NULL check makes a dramatic performance impact! if (ordinalStr == null) { return false; } int ordinal = -1; try { ordinal = Integer.parseInt(ordinalStr); } catch (NumberFormatException e) { // The element's state has not been initialized yet. return false; } for (State state : states) { if (ordinal == state.ordinal()) { return true; } } return false; } /** * Schedule a command to execute on the specified element. Use * {@link #clearLastCommand(Element, ScheduledCommand)} to verify that the * command is still the most recent command scheduled for the element. */ private void schedule(Element element, ScheduledCommand command) { setLastCommandImpl(element, command); Scheduler.get().scheduleDeferred(command); } /** * Clear the last command from the specified element if the last command * scheduled equals the specified command. * * @return true if the last command equals the specified command, false if no */ private native boolean clearLastCommand(Element element, ScheduledCommand command) /*-{ if (element.__gwtLastCommand == command) { element.__gwtLastCommand = null; // Clear the last command if it is about to execute. return true; } return false; }-*/; private native void setLastCommandImpl(Element element, ScheduledCommand command) /*-{ element.__gwtLastCommand = command; }-*/; }