/* * Copyright (C) 2013 The Android Open Source Project * * 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.android.internal.widget; import android.content.res.Resources; import android.os.SystemClock; import android.util.DisplayMetrics; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.animation.AccelerateInterpolator; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; import android.widget.AbsListView; /** * AutoScrollHelper is a utility class for adding automatic edge-triggered * scrolling to Views. *
* Note: Implementing classes are responsible for overriding the * {@link #scrollTargetBy}, {@link #canTargetScrollHorizontally}, and * {@link #canTargetScrollVertically} methods. See * {@link AbsListViewAutoScroller} for an {@link android.widget.AbsListView} * -specific implementation. *
*
* As the user touches closer to the extreme edge of the activation area, * scrolling accelerates up to a maximum velocity. When using the default edge * type, {@link #EDGE_TYPE_INSIDE_EXTEND}, moving outside of the view bounds * will scroll at the maximum velocity. *
* The following activation properties may be configured: *
* The following scrolling properties may be configured: *
* The resulting helper may be configured by chaining setter calls and * should be set as a touch listener on the target view. *
* By default, the helper is disabled and will not respond to touch events * until it is enabled using {@link #setEnabled}. * * @param target The view to automatically scroll. */ public AutoScrollHelper(View target) { mTarget = target; final DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics(); final int maxVelocity = (int) (DEFAULT_MAXIMUM_VELOCITY_DIPS * metrics.density + 0.5f); final int minVelocity = (int) (DEFAULT_MINIMUM_VELOCITY_DIPS * metrics.density + 0.5f); setMaximumVelocity(maxVelocity, maxVelocity); setMinimumVelocity(minVelocity, minVelocity); setEdgeType(DEFAULT_EDGE_TYPE); setMaximumEdges(DEFAULT_MAXIMUM_EDGE, DEFAULT_MAXIMUM_EDGE); setRelativeEdges(DEFAULT_RELATIVE_EDGE, DEFAULT_RELATIVE_EDGE); setRelativeVelocity(DEFAULT_RELATIVE_VELOCITY, DEFAULT_RELATIVE_VELOCITY); setActivationDelay(DEFAULT_ACTIVATION_DELAY); setRampUpDuration(DEFAULT_RAMP_UP_DURATION); setRampDownDuration(DEFAULT_RAMP_DOWN_DURATION); } /** * Sets whether the scroll helper is enabled and should respond to touch * events. * * @param enabled Whether the scroll helper is enabled. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setEnabled(boolean enabled) { if (mEnabled && !enabled) { requestStop(); } mEnabled = enabled; return this; } /** * @return True if this helper is enabled and responding to touch events. */ public boolean isEnabled() { return mEnabled; } /** * Enables or disables exclusive handling of touch events during scrolling. * By default, exclusive handling is disabled and the target view receives * all touch events. *
* When enabled, {@link #onTouch} will return true if the helper is * currently scrolling and false otherwise. * * @param exclusive True to exclusively handle touch events during scrolling, * false to allow the target view to receive all touch events. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setExclusive(boolean exclusive) { mExclusive = exclusive; return this; } /** * Indicates whether the scroll helper handles touch events exclusively * during scrolling. * * @return True if exclusive handling of touch events during scrolling is * enabled, false otherwise. * @see #setExclusive(boolean) */ public boolean isExclusive() { return mExclusive; } /** * Sets the absolute maximum scrolling velocity. *
* If relative velocity is not specified, scrolling will always reach the * same maximum velocity. If both relative and maximum velocities are * specified, the maximum velocity will be used to clamp the calculated * relative velocity. * * @param horizontalMax The maximum horizontal scrolling velocity, or * {@link #NO_MAX} to leave the relative value unconstrained. * @param verticalMax The maximum vertical scrolling velocity, or * {@link #NO_MAX} to leave the relative value unconstrained. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setMaximumVelocity(float horizontalMax, float verticalMax) { mMaximumVelocity[HORIZONTAL] = horizontalMax / 1000f; mMaximumVelocity[VERTICAL] = verticalMax / 1000f; return this; } /** * Sets the absolute minimum scrolling velocity. *
* If both relative and minimum velocities are specified, the minimum * velocity will be used to clamp the calculated relative velocity. * * @param horizontalMin The minimum horizontal scrolling velocity, or * {@link #NO_MIN} to leave the relative value unconstrained. * @param verticalMin The minimum vertical scrolling velocity, or * {@link #NO_MIN} to leave the relative value unconstrained. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setMinimumVelocity(float horizontalMin, float verticalMin) { mMinimumVelocity[HORIZONTAL] = horizontalMin / 1000f; mMinimumVelocity[VERTICAL] = verticalMin / 1000f; return this; } /** * Sets the target scrolling velocity relative to the host view's * dimensions. *
* If both relative and maximum velocities are specified, the maximum * velocity will be used to clamp the calculated relative velocity. * * @param horizontal The target horizontal velocity as a fraction of the * host view width per second, or {@link #RELATIVE_UNSPECIFIED} * to ignore. * @param vertical The target vertical velocity as a fraction of the host * view height per second, or {@link #RELATIVE_UNSPECIFIED} to * ignore. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setRelativeVelocity(float horizontal, float vertical) { mRelativeVelocity[HORIZONTAL] = horizontal / 1000f; mRelativeVelocity[VERTICAL] = vertical / 1000f; return this; } /** * Sets the activation edge type, one of: *
* If both relative and maximum edges are specified, the maximum edge will * be used to constrain the calculated relative edge size. * * @param horizontal The horizontal edge size as a fraction of the host view * width, or {@link #RELATIVE_UNSPECIFIED} to always use the * maximum value. * @param vertical The vertical edge size as a fraction of the host view * height, or {@link #RELATIVE_UNSPECIFIED} to always use the * maximum value. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setRelativeEdges(float horizontal, float vertical) { mRelativeEdges[HORIZONTAL] = horizontal; mRelativeEdges[VERTICAL] = vertical; return this; } /** * Sets the absolute maximum edge size. *
* If relative edge size is not specified, activation edges will always be * the maximum edge size. If both relative and maximum edges are specified, * the maximum edge will be used to constrain the calculated relative edge * size. * * @param horizontalMax The maximum horizontal edge size in pixels, or * {@link #NO_MAX} to use the unconstrained calculated relative * value. * @param verticalMax The maximum vertical edge size in pixels, or * {@link #NO_MAX} to use the unconstrained calculated relative * value. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setMaximumEdges(float horizontalMax, float verticalMax) { mMaximumEdges[HORIZONTAL] = horizontalMax; mMaximumEdges[VERTICAL] = verticalMax; return this; } /** * Sets the delay after entering an activation edge before activation of * auto-scrolling. By default, the activation delay is set to * {@link ViewConfiguration#getTapTimeout()}. *
* Specifying a delay of zero will start auto-scrolling immediately after * the touch position enters an activation edge. * * @param delayMillis The activation delay in milliseconds. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setActivationDelay(int delayMillis) { mActivationDelay = delayMillis; return this; } /** * Sets the amount of time after activation of auto-scrolling that is takes * to reach target velocity for the current touch position. *
* Specifying a duration greater than zero prevents sudden jumps in * velocity. * * @param durationMillis The ramp-up duration in milliseconds. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setRampUpDuration(int durationMillis) { mScroller.setRampUpDuration(durationMillis); return this; } /** * Sets the amount of time after de-activation of auto-scrolling that is * takes to slow to a stop. *
* Specifying a duration greater than zero prevents sudden jumps in * velocity. * * @param durationMillis The ramp-down duration in milliseconds. * @return The scroll helper, which may used to chain setter calls. */ public AutoScrollHelper setRampDownDuration(int durationMillis) { mScroller.setRampDownDuration(durationMillis); return this; } /** * Handles touch events by activating automatic scrolling, adjusting scroll * velocity, or stopping. *
* If {@link #isExclusive()} is false, always returns false so that
* the host view may handle touch events. Otherwise, returns true when
* automatic scrolling is active and false otherwise.
*/
@Override
public boolean onTouch(View v, MotionEvent event) {
if (!mEnabled) {
return false;
}
final int action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_DOWN:
mNeedsCancel = true;
mAlreadyDelayed = false;
// $FALL-THROUGH$
case MotionEvent.ACTION_MOVE:
final float xTargetVelocity = computeTargetVelocity(
HORIZONTAL, event.getX(), v.getWidth(), mTarget.getWidth());
final float yTargetVelocity = computeTargetVelocity(
VERTICAL, event.getY(), v.getHeight(), mTarget.getHeight());
mScroller.setTargetVelocity(xTargetVelocity, yTargetVelocity);
// If the auto scroller was not previously active, but it should
// be, then update the state and start animations.
if (!mAnimating && shouldAnimate()) {
startAnimating();
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
requestStop();
break;
}
return mExclusive && mAnimating;
}
/**
* @return whether the target is able to scroll in the requested direction
*/
private boolean shouldAnimate() {
final ClampedScroller scroller = mScroller;
final int verticalDirection = scroller.getVerticalDirection();
final int horizontalDirection = scroller.getHorizontalDirection();
return verticalDirection != 0 && canTargetScrollVertically(verticalDirection)
|| horizontalDirection != 0 && canTargetScrollHorizontally(horizontalDirection);
}
/**
* Starts the scroll animation.
*/
private void startAnimating() {
if (mRunnable == null) {
mRunnable = new ScrollAnimationRunnable();
}
mAnimating = true;
mNeedsReset = true;
if (!mAlreadyDelayed && mActivationDelay > 0) {
mTarget.postOnAnimationDelayed(mRunnable, mActivationDelay);
} else {
mRunnable.run();
}
// If we start animating again before the user lifts their finger, we
// already know it's not a tap and don't need an activation delay.
mAlreadyDelayed = true;
}
/**
* Requests that the scroll animation slow to a stop. If there is an
* activation delay, this may occur between posting the animation and
* actually running it.
*/
private void requestStop() {
if (mNeedsReset) {
// The animation has been posted, but hasn't run yet. Manually
// stopping animation will prevent it from running.
mAnimating = false;
} else {
mScroller.requestStop();
}
}
private float computeTargetVelocity(
int direction, float coordinate, float srcSize, float dstSize) {
final float relativeEdge = mRelativeEdges[direction];
final float maximumEdge = mMaximumEdges[direction];
final float value = getEdgeValue(relativeEdge, srcSize, maximumEdge, coordinate);
if (value == 0) {
// The edge in this direction is not activated.
return 0;
}
final float relativeVelocity = mRelativeVelocity[direction];
final float minimumVelocity = mMinimumVelocity[direction];
final float maximumVelocity = mMaximumVelocity[direction];
final float targetVelocity = relativeVelocity * dstSize;
// Target velocity is adjusted for interpolated edge position, then
// clamped to the minimum and maximum values. Later, this value will be
// adjusted for time-based acceleration.
if (value > 0) {
return constrain(value * targetVelocity, minimumVelocity, maximumVelocity);
} else {
return -constrain(-value * targetVelocity, minimumVelocity, maximumVelocity);
}
}
/**
* Override this method to scroll the target view by the specified number of
* pixels.
*
* @param deltaX The number of pixels to scroll by horizontally.
* @param deltaY The number of pixels to scroll by vertically.
*/
public abstract void scrollTargetBy(int deltaX, int deltaY);
/**
* Override this method to return whether the target view can be scrolled
* horizontally in a certain direction.
*
* @param direction Negative to check scrolling left, positive to check
* scrolling right.
* @return true if the target view is able to horizontally scroll in the
* specified direction.
*/
public abstract boolean canTargetScrollHorizontally(int direction);
/**
* Override this method to return whether the target view can be scrolled
* vertically in a certain direction.
*
* @param direction Negative to check scrolling up, positive to check
* scrolling down.
* @return true if the target view is able to vertically scroll in the
* specified direction.
*/
public abstract boolean canTargetScrollVertically(int direction);
/**
* Returns the interpolated position of a touch point relative to an edge
* defined by its relative inset, its maximum absolute inset, and the edge
* interpolator.
*
* @param relativeValue The size of the inset relative to the total size.
* @param size Total size.
* @param maxValue The maximum size of the inset, used to clamp (relative *
* total).
* @param current Touch position within within the total size.
* @return Interpolated value of the touch position within the edge.
*/
private float getEdgeValue(float relativeValue, float size, float maxValue, float current) {
// For now, leading and trailing edges are always the same size.
final float edgeSize = constrain(relativeValue * size, NO_MIN, maxValue);
final float valueLeading = constrainEdgeValue(current, edgeSize);
final float valueTrailing = constrainEdgeValue(size - current, edgeSize);
final float value = (valueTrailing - valueLeading);
final float interpolated;
if (value < 0) {
interpolated = -mEdgeInterpolator.getInterpolation(-value);
} else if (value > 0) {
interpolated = mEdgeInterpolator.getInterpolation(value);
} else {
return 0;
}
return constrain(interpolated, -1, 1);
}
private float constrainEdgeValue(float current, float leading) {
if (leading == 0) {
return 0;
}
switch (mEdgeType) {
case EDGE_TYPE_INSIDE:
case EDGE_TYPE_INSIDE_EXTEND:
if (current < leading) {
if (current >= 0) {
// Movement up to the edge is scaled.
return 1f - current / leading;
} else if (mAnimating && (mEdgeType == EDGE_TYPE_INSIDE_EXTEND)) {
// Movement beyond the edge is always maximum.
return 1f;
}
}
break;
case EDGE_TYPE_OUTSIDE:
if (current < 0) {
// Movement beyond the edge is scaled.
return current / -leading;
}
break;
}
return 0;
}
private static int constrain(int value, int min, int max) {
if (value > max) {
return max;
} else if (value < min) {
return min;
} else {
return value;
}
}
private static float constrain(float value, float min, float max) {
if (value > max) {
return max;
} else if (value < min) {
return min;
} else {
return value;
}
}
/**
* Sends a {@link MotionEvent#ACTION_CANCEL} event to the target view,
* canceling any ongoing touch events.
*/
private void cancelTargetTouch() {
final long eventTime = SystemClock.uptimeMillis();
final MotionEvent cancel = MotionEvent.obtain(
eventTime, eventTime, MotionEvent.ACTION_CANCEL, 0, 0, 0);
mTarget.onTouchEvent(cancel);
cancel.recycle();
}
private class ScrollAnimationRunnable implements Runnable {
@Override
public void run() {
if (!mAnimating) {
return;
}
if (mNeedsReset) {
mNeedsReset = false;
mScroller.start();
}
final ClampedScroller scroller = mScroller;
if (scroller.isFinished() || !shouldAnimate()) {
mAnimating = false;
return;
}
if (mNeedsCancel) {
mNeedsCancel = false;
cancelTargetTouch();
}
scroller.computeScrollDelta();
final int deltaX = scroller.getDeltaX();
final int deltaY = scroller.getDeltaY();
scrollTargetBy(deltaX, deltaY);
// Keep going until the scroller has permanently stopped.
mTarget.postOnAnimation(this);
}
}
/**
* Scroller whose velocity follows the curve of an {@link Interpolator} and
* is clamped to the interpolated 0f value before starting and the
* interpolated 1f value after a specified duration.
*/
private static class ClampedScroller {
private int mRampUpDuration;
private int mRampDownDuration;
private float mTargetVelocityX;
private float mTargetVelocityY;
private long mStartTime;
private long mDeltaTime;
private int mDeltaX;
private int mDeltaY;
private long mStopTime;
private float mStopValue;
private int mEffectiveRampDown;
/**
* Creates a new ramp-up scroller that reaches full velocity after a
* specified duration.
*/
public ClampedScroller() {
mStartTime = Long.MIN_VALUE;
mStopTime = -1;
mDeltaTime = 0;
mDeltaX = 0;
mDeltaY = 0;
}
public void setRampUpDuration(int durationMillis) {
mRampUpDuration = durationMillis;
}
public void setRampDownDuration(int durationMillis) {
mRampDownDuration = durationMillis;
}
/**
* Starts the scroller at the current animation time.
*/
public void start() {
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStopTime = -1;
mDeltaTime = mStartTime;
mStopValue = 0.5f;
mDeltaX = 0;
mDeltaY = 0;
}
/**
* Stops the scroller at the current animation time.
*/
public void requestStop() {
final long currentTime = AnimationUtils.currentAnimationTimeMillis();
mEffectiveRampDown = constrain((int) (currentTime - mStartTime), 0, mRampDownDuration);
mStopValue = getValueAt(currentTime);
mStopTime = currentTime;
}
public boolean isFinished() {
return mStopTime > 0
&& AnimationUtils.currentAnimationTimeMillis() > mStopTime + mEffectiveRampDown;
}
private float getValueAt(long currentTime) {
if (currentTime < mStartTime) {
return 0f;
} else if (mStopTime < 0 || currentTime < mStopTime) {
final long elapsedSinceStart = currentTime - mStartTime;
return 0.5f * constrain(elapsedSinceStart / (float) mRampUpDuration, 0, 1);
} else {
final long elapsedSinceEnd = currentTime - mStopTime;
return (1 - mStopValue) + mStopValue
* constrain(elapsedSinceEnd / (float) mEffectiveRampDown, 0, 1);
}
}
/**
* Interpolates the value along a parabolic curve corresponding to the equation
* y = -4x * (x-1)
.
*
* @param value The value to interpolate, between 0 and 1.
* @return the interpolated value, between 0 and 1.
*/
private float interpolateValue(float value) {
return -4 * value * value + 4 * value;
}
/**
* Computes the current scroll deltas. This usually only be called after
* starting the scroller with {@link #start()}.
*
* @see #getDeltaX()
* @see #getDeltaY()
*/
public void computeScrollDelta() {
if (mDeltaTime == 0) {
throw new RuntimeException("Cannot compute scroll delta before calling start()");
}
final long currentTime = AnimationUtils.currentAnimationTimeMillis();
final float value = getValueAt(currentTime);
final float scale = interpolateValue(value);
final long elapsedSinceDelta = currentTime - mDeltaTime;
mDeltaTime = currentTime;
mDeltaX = (int) (elapsedSinceDelta * scale * mTargetVelocityX);
mDeltaY = (int) (elapsedSinceDelta * scale * mTargetVelocityY);
}
/**
* Sets the target velocity for this scroller.
*
* @param x The target X velocity in pixels per millisecond.
* @param y The target Y velocity in pixels per millisecond.
*/
public void setTargetVelocity(float x, float y) {
mTargetVelocityX = x;
mTargetVelocityY = y;
}
public int getHorizontalDirection() {
return (int) (mTargetVelocityX / Math.abs(mTargetVelocityX));
}
public int getVerticalDirection() {
return (int) (mTargetVelocityY / Math.abs(mTargetVelocityY));
}
/**
* The distance traveled in the X-coordinate computed by the last call
* to {@link #computeScrollDelta()}.
*/
public int getDeltaX() {
return mDeltaX;
}
/**
* The distance traveled in the Y-coordinate computed by the last call
* to {@link #computeScrollDelta()}.
*/
public int getDeltaY() {
return mDeltaY;
}
}
/**
* An implementation of {@link AutoScrollHelper} that knows how to scroll
* through an {@link AbsListView}.
*/
public static class AbsListViewAutoScroller extends AutoScrollHelper {
private final AbsListView mTarget;
public AbsListViewAutoScroller(AbsListView target) {
super(target);
mTarget = target;
}
@Override
public void scrollTargetBy(int deltaX, int deltaY) {
mTarget.scrollListBy(deltaY);
}
@Override
public boolean canTargetScrollHorizontally(int direction) {
// List do not scroll horizontally.
return false;
}
@Override
public boolean canTargetScrollVertically(int direction) {
final AbsListView target = mTarget;
final int itemCount = target.getCount();
if (itemCount == 0) {
return false;
}
final int childCount = target.getChildCount();
final int firstPosition = target.getFirstVisiblePosition();
final int lastPosition = firstPosition + childCount;
if (direction > 0) {
// Are we already showing the entire last item?
if (lastPosition >= itemCount) {
final View lastView = target.getChildAt(childCount - 1);
if (lastView.getBottom() <= target.getHeight()) {
return false;
}
}
} else if (direction < 0) {
// Are we already showing the entire first item?
if (firstPosition <= 0) {
final View firstView = target.getChildAt(0);
if (firstView.getTop() >= 0) {
return false;
}
}
} else {
// The behavior for direction 0 is undefined and we can return
// whatever we want.
return false;
}
return true;
}
}
}