blob: 840365aa9f4607d705660ee6899e33a237f09ca8 [file] [log] [blame]
// 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 &lt;= {@link #getScrollPosition()}
* <li>{@link #getScrollPosition()} &lt;= 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();
}
}