/* * Copyright 2000-2018 Vaadin Ltd. * * 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.vaadin.client.widget.escalator; import com.google.gwt.animation.client.AnimationScheduler; import com.google.gwt.animation.client.AnimationScheduler.AnimationSupportDetector; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.Style.Display; import com.google.gwt.dom.client.Style.Overflow; import com.google.gwt.dom.client.Style.Unit; import com.google.gwt.dom.client.Style.Visibility; import com.google.gwt.event.shared.EventHandler; import com.google.gwt.event.shared.GwtEvent; import com.google.gwt.event.shared.HandlerManager; import com.google.gwt.event.shared.HandlerRegistration; import com.google.gwt.user.client.DOM; import com.google.gwt.user.client.Event; import com.google.gwt.user.client.Timer; import com.vaadin.client.BrowserInfo; import com.vaadin.client.DeferredWorker; import com.vaadin.client.WidgetUtil; import com.vaadin.client.widget.grid.events.ScrollEvent; import com.vaadin.client.widget.grid.events.ScrollHandler; /** * An element-like bundle representing a configurable and visual scrollbar in * one axis. * * @since 7.4 * @author Vaadin Ltd * @see VerticalScrollbarBundle * @see HorizontalScrollbarBundle */ public abstract class ScrollbarBundle implements DeferredWorker { private static final boolean SUPPORTS_REQUEST_ANIMATION_FRAME = new AnimationSupportDetector() .isNativelySupported(); private class ScrollEventFirer { private final ScheduledCommand fireEventCommand = () -> { /* * Some kind of native-scroll-event related asynchronous problem * occurs here (at least on desktops) where the internal bookkeeping * isn't up to date with the real scroll position. The weird thing * is, that happens only once, and if you drag scrollbar fast * enough. After it has failed once, it never fails again. * * Theory: the user drags the scrollbar, and this command is * executed before the browser has a chance to fire a scroll event * (which normally would correct this situation). This would explain * why slow scrolling doesn't trigger the problem, while fast * scrolling does. * * To make absolutely sure that we have the latest scroll position, * let's update the internal value. * * This might lead to a slight performance hit (on my computer it * was never more than 3ms on either of Chrome 38 or Firefox 31). It * also _slightly_ counteracts the purpose of the internal * bookkeeping. But since getScrollPos is called 3 times (on one * direction) per scroll loop, it's still better to have take this * small penalty than removing it altogether. */ updateScrollPosFromDom(); getHandlerManager().fireEvent(new ScrollEvent()); isBeingFired = false; }; private boolean isBeingFired; public void scheduleEvent() { if (!isBeingFired) { /* * We'll gather all the scroll events, and only fire once, once * everything has calmed down. */ if (SUPPORTS_REQUEST_ANIMATION_FRAME) { // Chrome MUST use this as deferred commands will sometimes // be run with a 300+ ms delay when scrolling. AnimationScheduler.get().requestAnimationFrame( timestamp -> fireEventCommand.execute()); } else { // Does not support requestAnimationFrame and the fallback // uses a delay of 16ms, we stick to the old deferred // command which uses a delay of 0ms Scheduler.get().scheduleDeferred(fireEventCommand); } isBeingFired = true; } } } /** * The orientation of the scrollbar. */ public enum Direction { VERTICAL, HORIZONTAL; } private class TemporaryResizer { private static final int TEMPORARY_RESIZE_DELAY = 1000; private final Timer timer = new Timer() { @Override public void run() { internalSetScrollbarThickness(1); root.getStyle().setVisibility(Visibility.HIDDEN); } }; public void show() { internalSetScrollbarThickness(OSX_INVISIBLE_SCROLLBAR_FAKE_SIZE_PX); root.getStyle().setVisibility(Visibility.VISIBLE); timer.schedule(TEMPORARY_RESIZE_DELAY); } } /** * A means to listen to when the scrollbar handle in a * {@link ScrollbarBundle} either appears or is removed. */ public interface VisibilityHandler extends EventHandler { /** * This method is called whenever the scrollbar handle's visibility is * changed in a {@link ScrollbarBundle}. * * @param event * the {@link VisibilityChangeEvent} */ void visibilityChanged(VisibilityChangeEvent event); } public static class VisibilityChangeEvent extends GwtEvent { public static final Type TYPE = new Type() { @Override public String toString() { return "VisibilityChangeEvent"; } }; private final boolean isScrollerVisible; private VisibilityChangeEvent(boolean isScrollerVisible) { this.isScrollerVisible = isScrollerVisible; } /** * Checks whether the scroll handle is currently visible or not. * * @return true if the scroll handle is currently visible. * false if not. */ public boolean isScrollerVisible() { return isScrollerVisible; } @Override public Type getAssociatedType() { return TYPE; } @Override protected void dispatch(VisibilityHandler handler) { handler.visibilityChanged(this); } } /** * The pixel size for OSX's invisible scrollbars. *

* Touch devices don't show a scrollbar at all, so the scrollbar size is * irrelevant in their case. There doesn't seem to be any other popular * platforms that has scrollbars similar to OSX. Thus, this behavior is * tailored for OSX only, until additional platforms start behaving this * way. */ private static final int OSX_INVISIBLE_SCROLLBAR_FAKE_SIZE_PX = 13; /** * A representation of a single vertical scrollbar. * * @see VerticalScrollbarBundle#getElement() */ public static final class VerticalScrollbarBundle extends ScrollbarBundle { @Override public void setStylePrimaryName(String primaryStyleName) { super.setStylePrimaryName(primaryStyleName); root.addClassName(primaryStyleName + "-scroller-vertical"); } @Override protected void internalSetScrollPos(int px) { root.setScrollTop(px); } @Override protected int internalGetScrollPos() { return root.getScrollTop(); } @Override protected void internalSetScrollSize(double px) { scrollSizeElement.getStyle().setHeight(px, Unit.PX); } @Override protected String internalGetScrollSize() { return scrollSizeElement.getStyle().getHeight(); } @Override protected void internalSetOffsetSize(double px) { root.getStyle().setHeight(px, Unit.PX); } @Override public String internalGetOffsetSize() { return root.getStyle().getHeight(); } @Override protected void internalSetScrollbarThickness(double px) { root.getStyle().setPaddingRight(px, Unit.PX); root.getStyle().setWidth(0, Unit.PX); scrollSizeElement.getStyle().setWidth(px, Unit.PX); } @Override protected String internalGetScrollbarThickness() { return scrollSizeElement.getStyle().getWidth(); } @Override protected void internalForceScrollbar(boolean enable) { if (enable) { root.getStyle().setOverflowY(Overflow.SCROLL); } else { root.getStyle().clearOverflowY(); } } @Override public Direction getDirection() { return Direction.VERTICAL; } } /** * A representation of a single horizontal scrollbar. * * @see HorizontalScrollbarBundle#getElement() */ public static final class HorizontalScrollbarBundle extends ScrollbarBundle { @Override public void setStylePrimaryName(String primaryStyleName) { super.setStylePrimaryName(primaryStyleName); root.addClassName(primaryStyleName + "-scroller-horizontal"); } @Override protected void internalSetScrollPos(int px) { root.setScrollLeft(px); } @Override protected int internalGetScrollPos() { return root.getScrollLeft(); } @Override protected void internalSetScrollSize(double px) { scrollSizeElement.getStyle().setWidth(px, Unit.PX); } @Override protected String internalGetScrollSize() { return scrollSizeElement.getStyle().getWidth(); } @Override protected void internalSetOffsetSize(double px) { root.getStyle().setWidth(px, Unit.PX); } @Override public String internalGetOffsetSize() { return root.getStyle().getWidth(); } @Override protected void internalSetScrollbarThickness(double px) { root.getStyle().setPaddingBottom(px, Unit.PX); root.getStyle().setHeight(0, Unit.PX); scrollSizeElement.getStyle().setHeight(px, Unit.PX); } @Override protected String internalGetScrollbarThickness() { return scrollSizeElement.getStyle().getHeight(); } @Override protected void internalForceScrollbar(boolean enable) { if (enable) { root.getStyle().setOverflowX(Overflow.SCROLL); } else { root.getStyle().clearOverflowX(); } } @Override public Direction getDirection() { return Direction.HORIZONTAL; } } protected final Element root = DOM.createDiv(); protected final Element scrollSizeElement = DOM.createDiv(); protected boolean isInvisibleScrollbar = false; private double scrollPos = 0; private double maxScrollPos = 0; private boolean scrollHandleIsVisible = false; private boolean isLocked = false; /** @deprecated access via {@link #getHandlerManager()} instead. */ @Deprecated private HandlerManager handlerManager; private TemporaryResizer invisibleScrollbarTemporaryResizer = new TemporaryResizer(); private final ScrollEventFirer scrollEventFirer = new ScrollEventFirer(); private HandlerRegistration scrollInProgress; private ScrollbarBundle() { root.appendChild(scrollSizeElement); root.getStyle().setDisplay(Display.NONE); root.setTabIndex(-1); } protected abstract String internalGetScrollSize(); /** * Sets the primary style name. * * @param primaryStyleName * The primary style name to use */ public void setStylePrimaryName(String primaryStyleName) { root.setClassName(primaryStyleName + "-scroller"); } /** * Gets the root element of this scrollbar-composition. * * @return the root element */ public final Element getElement() { return root; } /** * Modifies the scroll position of this scrollbar by a number of pixels. *

* Note: Even though {@code double} values are used, they are * currently only used as integers as large {@code int} (or small but fast * {@code long}). This means, all values are truncated to zero decimal * places. * * @param delta * the delta in pixels to change the scroll position by */ public final void setScrollPosByDelta(double delta) { if (delta != 0) { setScrollPos(getScrollPos() + delta); } } /** * Modifies {@link #root root's} dimensions in the axis the scrollbar is * representing. * * @param px * the new size of {@link #root} in the dimension this scrollbar * is representing */ protected abstract void internalSetOffsetSize(double px); /** * Sets the length of the scrollbar. * * @param px * the length of the scrollbar in pixels * @see #setOffsetSizeAndScrollSize(double, double) */ public final void setOffsetSize(final double px) { boolean newOffsetSizeIsGreaterThanScrollSize = px > getScrollSize(); boolean offsetSizeBecomesGreaterThanScrollSize = showsScrollHandle() && newOffsetSizeIsGreaterThanScrollSize; if (offsetSizeBecomesGreaterThanScrollSize && getScrollPos() != 0) { setScrollPos(0); setOffsetSizeNow(px); } else if (px != getOffsetSize()) { setOffsetSizeNow(px); } } private void setOffsetSizeNow(double px) { internalSetOffsetSize(Math.max(0, px)); recalculateMaxScrollPos(); forceScrollbar(showsScrollHandle()); fireVisibilityChangeIfNeeded(); } /** * Sets the length of the scrollbar and the amount of pixels the scrollbar * needs to be able to scroll through. * * @param offsetPx * the length of the scrollbar in pixels * @param scrollPx * the number of pixels the scrollbar should be able to scroll * through */ public final void setOffsetSizeAndScrollSize(final double offsetPx, final double scrollPx) { boolean newOffsetSizeIsGreaterThanScrollSize = offsetPx > scrollPx; boolean offsetSizeBecomesGreaterThanScrollSize = showsScrollHandle() && newOffsetSizeIsGreaterThanScrollSize; boolean needsMoreHandling = false; if (offsetSizeBecomesGreaterThanScrollSize && getScrollPos() != 0) { setScrollPos(0); if (offsetPx != getOffsetSize()) { internalSetOffsetSize(Math.max(0, offsetPx)); } if (scrollPx != getScrollSize()) { internalSetScrollSize(Math.max(0, scrollPx)); } needsMoreHandling = true; } else { if (offsetPx != getOffsetSize()) { internalSetOffsetSize(Math.max(0, offsetPx)); needsMoreHandling = true; } if (scrollPx != getScrollSize()) { internalSetScrollSize(Math.max(0, scrollPx)); needsMoreHandling = true; } } if (needsMoreHandling) { recalculateMaxScrollPos(); forceScrollbar(showsScrollHandle()); fireVisibilityChangeIfNeeded(); } } /** * Force the scrollbar to be visible with CSS. In practice, this means to * set either overflow-x or overflow-y to " * scroll" in the scrollbar's direction. *

* This method is an IE8 workaround, since it doesn't always show scrollbars * with overflow: auto enabled. *

* Firefox on the other hand loses pending scroll events when the scrollbar * is hidden, so the event must be fired manually. *

* When IE8 support is dropped, this should really be simplified. */ protected void forceScrollbar(boolean enable) { if (enable) { root.getStyle().clearDisplay(); } else { if (BrowserInfo.get().isFirefox()) { /* * This is related to the Firefox workaround in setScrollSize * for setScrollPos(0) */ scrollEventFirer.scheduleEvent(); } root.getStyle().setDisplay(Display.NONE); } internalForceScrollbar(enable); } protected abstract void internalForceScrollbar(boolean enable); /** * Gets the length of the scrollbar. * * @return the length of the scrollbar in pixels */ public double getOffsetSize() { return parseCssDimensionToPixels(internalGetOffsetSize()); } public abstract String internalGetOffsetSize(); /** * Sets the scroll position of the scrollbar in the axis the scrollbar is * representing. *

* Note: Even though {@code double} values are used, they are * currently only used as integers as large {@code int} (or small but fast * {@code long}). This means, all values are truncated to zero decimal * places. * * @param px * the new scroll position in pixels */ public final void setScrollPos(double px) { if (isLocked()) { return; } double oldScrollPos = scrollPos; scrollPos = Math.max(0, Math.min(maxScrollPos, truncate(px))); if (!WidgetUtil.pixelValuesEqual(oldScrollPos, scrollPos)) { if (scrollInProgress == null) { // Only used for tracking that there is "workPending" scrollInProgress = addScrollHandler(event -> { scrollInProgress.removeHandler(); scrollInProgress = null; }); } if (isInvisibleScrollbar) { invisibleScrollbarTemporaryResizer.show(); } /* * This is where the value needs to be converted into an integer no * matter how we flip it, since GWT expects an integer value. * There's no point making a JSNI method that accepts doubles as the * scroll position, since the browsers themselves don't support such * large numbers (as of today, 25.3.2014). This double-ranged is * only facilitating future virtual scrollbars. */ internalSetScrollPos(toInt32(scrollPos)); } } /** * Should be called whenever this bundle is attached to the DOM (typically, * from the onLoad of the containing widget). Used to ensure the DOM scroll * position is maintained when detaching and reattaching the bundle. * * @since 7.4.1 */ public void onLoad() { internalSetScrollPos(toInt32(scrollPos)); } /** * Truncates a double such that no decimal places are retained. *

* E.g. {@code trunc(2.3d) == 2.0d} and {@code trunc(-2.3d) == -2.0d}. * * @param num * the double value to be truncated * @return the {@code num} value without any decimal digits */ private static double truncate(double num) { if (num > 0) { return Math.floor(num); } else { return Math.ceil(num); } } /** * Modifies the element's scroll position (scrollTop or scrollLeft). *

* Note: The parameter here is a type of integer (instead of a * double) by design. The browsers internally convert all double values into * an integer value. To make this fact explicit, this API has chosen to * force integers already at this level. * * @param px * integer pixel value to scroll to */ protected abstract void internalSetScrollPos(int px); /** * Gets the scroll position of the scrollbar in the axis the scrollbar is * representing. * * @return the new scroll position in pixels */ public final double getScrollPos() { int internalScrollPos = internalGetScrollPos(); assert Math.abs(internalScrollPos - toInt32(scrollPos)) <= 1 : "calculated scroll position (" + scrollPos + ") did not match the DOM element scroll position (" + internalScrollPos + ")"; return scrollPos; } /** * Retrieves the element's scroll position (scrollTop or scrollLeft). *

* Note: The parameter here is a type of integer (instead of a * double) by design. The browsers internally convert all double values into * an integer value. To make this fact explicit, this API has chosen to * force integers already at this level. * * @return integer pixel value of the scroll position */ protected abstract int internalGetScrollPos(); /** * Modifies {@link #scrollSizeElement scrollSizeElement's} dimensions in * such a way that the scrollbar is able to scroll a certain number of * pixels in the axis it is representing. * * @param px * the new size of {@link #scrollSizeElement} in the dimension * this scrollbar is representing */ protected abstract void internalSetScrollSize(double px); /** * Sets the amount of pixels the scrollbar needs to be able to scroll * through. * * @param px * the number of pixels the scrollbar should be able to scroll * through * @see #setOffsetSizeAndScrollSize(double, double) */ public final void setScrollSize(final double px) { boolean newScrollSizeIsSmallerThanOffsetSize = px <= getOffsetSize(); boolean scrollSizeBecomesSmallerThanOffsetSize = showsScrollHandle() && newScrollSizeIsSmallerThanOffsetSize; if (scrollSizeBecomesSmallerThanOffsetSize && getScrollPos() != 0) { setScrollPos(0); setScrollSizeNow(px); } else if (px != getScrollSize()) { setScrollSizeNow(px); } } private void setScrollSizeNow(double px) { internalSetScrollSize(Math.max(0, px)); recalculateMaxScrollPos(); forceScrollbar(showsScrollHandle()); fireVisibilityChangeIfNeeded(); } /** * Gets the amount of pixels the scrollbar needs to be able to scroll * through. * * @return the number of pixels the scrollbar should be able to scroll * through */ public double getScrollSize() { return parseCssDimensionToPixels(internalGetScrollSize()); } /** * Modifies {@link #scrollSizeElement scrollSizeElement's} dimensions in the * opposite axis to what the scrollbar is representing. * * @param px * the dimension that {@link #scrollSizeElement} should take in * the opposite axis to what the scrollbar is representing */ protected abstract void internalSetScrollbarThickness(double px); /** * Sets the scrollbar's thickness. *

* If the thickness is set to 0, the scrollbar will be treated as an * "invisible" scrollbar. This means, the DOM structure will be given a * non-zero size, but {@link #getScrollbarThickness()} will still return the * value 0. * * @param px * the scrollbar's thickness in pixels */ public final void setScrollbarThickness(double px) { isInvisibleScrollbar = (px == 0); if (isInvisibleScrollbar) { Event.sinkEvents(root, Event.ONSCROLL); Event.setEventListener(root, event -> invisibleScrollbarTemporaryResizer.show()); root.getStyle().setVisibility(Visibility.HIDDEN); } else { Event.sinkEvents(root, 0); Event.setEventListener(root, null); root.getStyle().clearVisibility(); } internalSetScrollbarThickness(Math.max(1d, px)); } /** * Gets the scrollbar's thickness as defined in the DOM. * * @return the scrollbar's thickness as defined in the DOM, in pixels */ protected abstract String internalGetScrollbarThickness(); /** * Gets the scrollbar's thickness. *

* This value will differ from the value in the DOM, if the thickness was * set to 0 with {@link #setScrollbarThickness(double)}, as the scrollbar is * then treated as "invisible." * * @return the scrollbar's thickness in pixels */ public final double getScrollbarThickness() { if (!isInvisibleScrollbar) { return parseCssDimensionToPixels(internalGetScrollbarThickness()); } else { return 0; } } /** * Checks whether the scrollbar's handle is visible. *

* In other words, this method checks whether the contents is larger than * can visually fit in the element. * * @return true if the scrollbar's handle is visible */ public boolean showsScrollHandle() { return getScrollSize() - getOffsetSize() > WidgetUtil.PIXEL_EPSILON; } public void recalculateMaxScrollPos() { double scrollSize = getScrollSize(); double offsetSize = getOffsetSize(); maxScrollPos = Math.max(0, scrollSize - offsetSize); // make sure that the correct max scroll position is maintained. setScrollPos(scrollPos); } /** * This is a method that JSNI can call to synchronize the object state from * the DOM. */ private final void updateScrollPosFromDom() { /* * TODO: this method probably shouldn't be called from Escalator's JSNI, * but probably could be handled internally by this listening to its own * element. Would clean up the code quite a bit. Needs further * investigation. */ int newScrollPos = internalGetScrollPos(); if (!isLocked()) { scrollPos = newScrollPos; scrollEventFirer.scheduleEvent(); } else { if (scrollPos != newScrollPos) { // we need to actually undo the setting of the scroll. internalSetScrollPos(toInt32(scrollPos)); } if (scrollInProgress != null) { // cancel the in-progress indicator scrollInProgress.removeHandler(); scrollInProgress = null; } } } protected HandlerManager getHandlerManager() { if (handlerManager == null) { handlerManager = new HandlerManager(this); } return handlerManager; } /** * Adds handler for the scrollbar handle visibility. * * @param handler * the {@link VisibilityHandler} to add * @return {@link HandlerRegistration} used to remove the handler */ public HandlerRegistration addVisibilityHandler( final VisibilityHandler handler) { return getHandlerManager().addHandler(VisibilityChangeEvent.TYPE, handler); } private void fireVisibilityChangeIfNeeded() { final boolean oldHandleIsVisible = scrollHandleIsVisible; scrollHandleIsVisible = showsScrollHandle(); if (oldHandleIsVisible != scrollHandleIsVisible) { final VisibilityChangeEvent event = new VisibilityChangeEvent( scrollHandleIsVisible); getHandlerManager().fireEvent(event); } } /** * Converts a double into an integer by JavaScript's terms. *

* Implementation copied from {@link Element#toInt32(double)}. * * @param val * the double value to convert into an integer * @return the double value converted to an integer */ private static native int toInt32(double val) /*-{ return Math.round(val) | 0; }-*/; /** * Locks or unlocks the scrollbar bundle. *

* A locked scrollbar bundle will refuse to scroll, both programmatically * and via user-triggered events. * * @param isLocked * true to lock, false to unlock */ public void setLocked(boolean isLocked) { this.isLocked = isLocked; } /** * Checks whether the scrollbar bundle is locked or not. * * @return true if the scrollbar bundle is locked */ public boolean isLocked() { return isLocked; } /** * Returns the scroll direction of this scrollbar bundle. * * @return the scroll direction of this scrollbar bundle */ public abstract Direction getDirection(); /** * Adds a scroll handler to the scrollbar bundle. * * @param handler * the handler to add * @return the registration object for the handler registration */ public HandlerRegistration addScrollHandler(final ScrollHandler handler) { return getHandlerManager().addHandler(ScrollEvent.TYPE, handler); } private static double parseCssDimensionToPixels(String size) { /* * Sizes of elements are calculated from CSS rather than * element.getOffset*() because those values are 0 whenever display: * none. Because we know that all elements have populated * CSS-dimensions, it's better to do it that way. * * Another solution would be to make the elements visible while * measuring and then re-hide them, but that would cause unnecessary * reflows that would probably kill the performance dead. */ if (size.isEmpty()) { return 0; } else { assert size.endsWith("px") : "Can't parse CSS dimension \"" + size + "\""; return Double.parseDouble(size.substring(0, size.length() - 2)); } } @Override public boolean isWorkPending() { // Need to include scrollEventFirer.isBeingFired as it might use // requestAnimationFrame - which is not automatically checked return scrollInProgress != null || scrollEventFirer.isBeingFired; } }