| // Copyright 2010-2015, Google Inc. |
| // All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are |
| // met: |
| // |
| // * Redistributions of source code must retain the above copyright |
| // notice, this list of conditions and the following disclaimer. |
| // * Redistributions in binary form must reproduce the above |
| // copyright notice, this list of conditions and the following disclaimer |
| // in the documentation and/or other materials provided with the |
| // distribution. |
| // * Neither the name of Google Inc. nor the names of its |
| // contributors may be used to endorse or promote products derived from |
| // this software without specific prior written permission. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| package org.mozc.android.inputmethod.japanese.ui; |
| |
| import org.mozc.android.inputmethod.japanese.MozcUtil; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Optional; |
| import com.google.common.base.Preconditions; |
| |
| import android.view.animation.AnimationUtils; |
| import android.view.animation.DecelerateInterpolator; |
| import android.view.animation.Interpolator; |
| |
| /** |
| * Similar class to {@code android.widget.Scroller}, but this class has "snapping" feature. |
| * |
| * This class treats such content which have "page"s. |
| * Every page is supposed to have the same width. |
| * |
| * During the scroll animation, the velocity is reduced on every page edge position and |
| * finally the animation stops on page edge. |
| * |
| */ |
| public class SnapScroller { |
| |
| /** |
| * Interface for getting current time stamp. |
| * |
| * By default {@code #DEFAULT_TIMESTAMP_CALCULATOR} is used. |
| * You can use your own instance for testing via constructor. |
| */ |
| interface TimestampCalculator { |
| long getTimestamp(); |
| } |
| |
| /** The interpolator of scroll animation. */ |
| private static final Interpolator SCROLL_INTERPOLATOR = new DecelerateInterpolator(); |
| |
| static final TimestampCalculator DEFAULT_TIMESTAMP_CALCULATOR = new TimestampCalculator() { |
| @Override |
| public long getTimestamp() { |
| return AnimationUtils.currentAnimationTimeMillis(); |
| } |
| }; |
| |
| private static final Optional<Float> OPTIONAL_ZERO = Optional.of(Float.valueOf(0)); |
| |
| /** The time stamp calculator. */ |
| private final TimestampCalculator timestampCalculator; |
| |
| /** The size of each page (in pixel). */ |
| private int pageSize; |
| |
| /** The total content size (in pixel). */ |
| private int contentSize; |
| |
| private int viewSize; |
| |
| /** |
| * Minimum velocity (in pixel per sec). |
| * |
| * If velocity is lower than this value, scroll animation stops. |
| */ |
| private int minimumVelocity; |
| |
| /** The rate applied to {@code velocity} when scroll position reaches page edge position. */ |
| private float decayRate; |
| |
| /** |
| * Current scroll position (in pixel). |
| * |
| * Updated by {@link #computeScrollOffset()}, {@link #scrollTo(int)} and {@link #scrollBy(int)}. |
| */ |
| @VisibleForTesting int scrollPosition; |
| |
| /** |
| * The scroll position at which scroll animation starts (in pixel). |
| */ |
| private int startScrollPosition; |
| |
| /** |
| * The scroll position to which scroll animation goes (in pixel). |
| * |
| * This value is always a multiple of {@code pageWidth}. |
| * {@code Math.abs(scrollStartPosition - scrollEndPosition) <= pageWidth} |
| * is guaranteed. |
| * If given velocity is enough large, scroll animation is done repeatedly (scroll to |
| * next page edge, and next page edge, and next....). |
| */ |
| private int endScrollPosition; |
| |
| /** Scroll velocity (pixel per sec). */ |
| private int velocity; |
| |
| /** The timestamp when scroll animation starts. */ |
| private long startScrollTime; |
| |
| public SnapScroller() { |
| this(DEFAULT_TIMESTAMP_CALCULATOR); |
| } |
| |
| @VisibleForTesting SnapScroller(TimestampCalculator timestampCalculator) { |
| this.timestampCalculator = Preconditions.checkNotNull(timestampCalculator); |
| } |
| |
| public void setPageSize(int pageSize) { |
| Preconditions.checkArgument(pageSize >= 0, "pageSize must be non-negative: %d", pageSize); |
| this.pageSize = pageSize; |
| } |
| |
| public int getPageSize() { |
| return pageSize; |
| } |
| |
| public void setContentSize(int contentSize) { |
| Preconditions.checkArgument( |
| contentSize >= 0, "contentSize must be non-negative: ", contentSize); |
| this.contentSize = contentSize; |
| } |
| |
| public int getContentSize() { |
| return contentSize; |
| } |
| |
| public void setViewSize(int viewSize) { |
| if (viewSize < 0) { |
| throw new IllegalArgumentException("contentSize must be non-negative: " + contentSize); |
| } |
| this.viewSize = viewSize; |
| } |
| |
| public int getViewSize() { |
| return viewSize; |
| } |
| |
| public int getMaxScrollPosition() { |
| return Math.max(scrollPosition, Math.max(contentSize - viewSize, 0)); |
| } |
| |
| public void setMinimumVelocity(int minimumVelocity) { |
| if (minimumVelocity < 0) { |
| throw new IllegalArgumentException("minimumVelocity must be >= 0."); |
| } |
| this.minimumVelocity = minimumVelocity; |
| } |
| |
| public void setDecayRate(float decayRate) { |
| this.decayRate = decayRate; |
| } |
| |
| public int getScrollPosition() { |
| return scrollPosition; |
| } |
| |
| public int getStartScrollPosition() { |
| return startScrollPosition; |
| } |
| |
| public int getEndScrollPosition() { |
| return endScrollPosition; |
| } |
| |
| public long getStartScrollTime() { |
| return startScrollTime; |
| } |
| |
| public void stopScrolling() { |
| velocity = 0; |
| } |
| |
| public boolean isScrolling() { |
| return velocity != 0; |
| } |
| |
| /** |
| * Scrolls to {@code toPosition}. |
| * |
| * This method stops the scroll animation. |
| * @param toPosition the position to be scrolled to. |
| */ |
| public void scrollTo(int toPosition) { |
| scrollPosition = MozcUtil.clamp(toPosition, 0, getMaxScrollPosition()); |
| velocity = 0; |
| } |
| |
| /** |
| * Scrolls by {@code delta}. |
| * |
| * Completely equivalent to {@code scrollTo(getScrollPosition() + delta)}. |
| * @param delta the delta to be scrolled by. |
| */ |
| public void scrollBy(int delta) { |
| scrollTo(scrollPosition + delta); |
| } |
| |
| /** |
| * Starts scroll animation. |
| * |
| * The scroll position is updated by calling {@link #computeScrollOffset()}. |
| * While scrolling, following conditions are always guaranteed. |
| * <ul> |
| * <li>0 <= {@link #getScrollPosition()} |
| * <li>{@link #getScrollPosition()} <= this.maxPosition |
| * <li>this.scrollStatus is FINISHED or ABOUT_TO_FINISH. |
| * </ul> |
| * Now, the scroll will snap to page boundaries due to historical reason. |
| * We may want to split snapping boundary and page size, |
| * for example, to support snapping to each row/column instead of page boundaries. |
| * @param velocity the velocity of scroll animation (pixel per sec). |
| */ |
| public void fling(int velocity) { |
| int pageSize = this.pageSize; |
| if (Math.abs(velocity) < minimumVelocity || velocity == 0 || pageSize == 0) { |
| // The velocity is low enough, or no page is available. So, do not scroll. |
| this.velocity = 0; |
| return; |
| } |
| |
| int scrollPosition = this.scrollPosition; |
| // Find the end scroll position heuristically. |
| // It is basically the next page end. In more precise: |
| // 1) For right/down scrolling, the end position should be the right/bottom side of |
| // the current page, which is the scrollPosition rounded to the nearest pageSize above. |
| // If the scrollPosition is already aligned to the pageSize, we'd like to scroll one more |
| // page. |
| // 2) For left/up scrolling, the end position should be the left/top side of the current page, |
| // which is the scrollPosition rounded to the nearest pageSize below. |
| // Similar to 1), if the scrollPosition is already aligned to the pageSize, we'd like to |
| // scroll one more page. |
| // The end scroll position should be clipped between 0 and (contentSize - pageSize) to avoid |
| // exceeding the contents. |
| int endScrollPosition = MozcUtil.clamp( |
| (velocity > 0) |
| ? ((scrollPosition + pageSize) / pageSize * pageSize) |
| : ((scrollPosition - 1) / pageSize * pageSize), |
| 0, getMaxScrollPosition()); |
| if (scrollPosition == endScrollPosition) { |
| // No needs to scroll. |
| // Reaches here typically when scrollPosition is at the head or the tail. |
| velocity = 0; |
| } |
| |
| // Update the fields. |
| this.velocity = velocity; |
| startScrollTime = timestampCalculator.getTimestamp(); |
| startScrollPosition = scrollPosition; |
| this.endScrollPosition = endScrollPosition; |
| } |
| |
| public Optional<Float> computeScrollOffset() { |
| if (velocity == 0) { |
| return OPTIONAL_ZERO; |
| } |
| |
| // Scroll animation is on going so the caller needs to call getScrollPosition(). |
| // Update the scroll position. |
| long now = timestampCalculator.getTimestamp(); |
| int distance = endScrollPosition - startScrollPosition; |
| // Note that distance and velocity have the same sign |
| // so totalAnimationDuration becomes always positive. |
| long totalAnimationDuration = 1000 * distance / velocity; |
| long elapsedTime = Math.min(now - startScrollTime, totalAnimationDuration); |
| float rateOfChange = totalAnimationDuration == 0 |
| ? 1f : SCROLL_INTERPOLATOR.getInterpolation(elapsedTime / (float) totalAnimationDuration); |
| scrollPosition = (int) (startScrollPosition + distance * rateOfChange); |
| if (elapsedTime == totalAnimationDuration) { |
| // This scroll animation is finished. |
| // Let's start next animation with decayed velocity. |
| float oldVelocity = velocity; |
| fling((int) (velocity * decayRate)); |
| if (velocity == 0) { |
| if (scrollPosition == 0 || scrollPosition == getMaxScrollPosition()) { |
| return Optional.of(Float.valueOf(oldVelocity)); |
| } else { |
| return OPTIONAL_ZERO; |
| } |
| } |
| } |
| |
| // Continue to scroll. |
| return Optional.absent(); |
| } |
| } |