362 lines
15 KiB
Java
362 lines
15 KiB
Java
/*
|
|
* Copyright (C) 2017 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.annotation.Nullable;
|
|
import android.content.Context;
|
|
import android.graphics.PointF;
|
|
import android.util.DisplayMetrics;
|
|
import android.util.Log;
|
|
import android.view.View;
|
|
import android.view.animation.DecelerateInterpolator;
|
|
import android.view.animation.LinearInterpolator;
|
|
|
|
/**
|
|
* {@link RecyclerView.SmoothScroller} implementation which uses a {@link LinearInterpolator} until
|
|
* the target position becomes a child of the RecyclerView and then uses a
|
|
* {@link DecelerateInterpolator} to slowly approach to target position.
|
|
* <p>
|
|
* If the {@link RecyclerView.LayoutManager} you are using does not implement the
|
|
* {@link RecyclerView.SmoothScroller.ScrollVectorProvider} interface, then you must override the
|
|
* {@link #computeScrollVectorForPosition(int)} method. All the LayoutManagers bundled with
|
|
* the support library implement this interface.
|
|
*/
|
|
public class LinearSmoothScroller extends RecyclerView.SmoothScroller {
|
|
|
|
private static final String TAG = "LinearSmoothScroller";
|
|
|
|
private static final boolean DEBUG = false;
|
|
|
|
private static final float MILLISECONDS_PER_INCH = 25f;
|
|
|
|
private static final int TARGET_SEEK_SCROLL_DISTANCE_PX = 10000;
|
|
|
|
/**
|
|
* Align child view's left or top with parent view's left or top
|
|
*
|
|
* @see #calculateDtToFit(int, int, int, int, int)
|
|
* @see #calculateDxToMakeVisible(android.view.View, int)
|
|
* @see #calculateDyToMakeVisible(android.view.View, int)
|
|
*/
|
|
public static final int SNAP_TO_START = -1;
|
|
|
|
/**
|
|
* Align child view's right or bottom with parent view's right or bottom
|
|
*
|
|
* @see #calculateDtToFit(int, int, int, int, int)
|
|
* @see #calculateDxToMakeVisible(android.view.View, int)
|
|
* @see #calculateDyToMakeVisible(android.view.View, int)
|
|
*/
|
|
public static final int SNAP_TO_END = 1;
|
|
|
|
/**
|
|
* <p>Decides if the child should be snapped from start or end, depending on where it
|
|
* currently is in relation to its parent.</p>
|
|
* <p>For instance, if the view is virtually on the left of RecyclerView, using
|
|
* {@code SNAP_TO_ANY} is the same as using {@code SNAP_TO_START}</p>
|
|
*
|
|
* @see #calculateDtToFit(int, int, int, int, int)
|
|
* @see #calculateDxToMakeVisible(android.view.View, int)
|
|
* @see #calculateDyToMakeVisible(android.view.View, int)
|
|
*/
|
|
public static final int SNAP_TO_ANY = 0;
|
|
|
|
// Trigger a scroll to a further distance than TARGET_SEEK_SCROLL_DISTANCE_PX so that if target
|
|
// view is not laid out until interim target position is reached, we can detect the case before
|
|
// scrolling slows down and reschedule another interim target scroll
|
|
private static final float TARGET_SEEK_EXTRA_SCROLL_RATIO = 1.2f;
|
|
|
|
protected final LinearInterpolator mLinearInterpolator = new LinearInterpolator();
|
|
|
|
protected final DecelerateInterpolator mDecelerateInterpolator = new DecelerateInterpolator();
|
|
|
|
protected PointF mTargetVector;
|
|
|
|
private final float MILLISECONDS_PER_PX;
|
|
|
|
// Temporary variables to keep track of the interim scroll target. These values do not
|
|
// point to a real item position, rather point to an estimated location pixels.
|
|
protected int mInterimTargetDx = 0, mInterimTargetDy = 0;
|
|
|
|
public LinearSmoothScroller(Context context) {
|
|
MILLISECONDS_PER_PX = calculateSpeedPerPixel(context.getResources().getDisplayMetrics());
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
@Override
|
|
protected void onStart() {
|
|
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
@Override
|
|
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
|
|
final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
|
|
final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
|
|
final int distance = (int) Math.sqrt(dx * dx + dy * dy);
|
|
final int time = calculateTimeForDeceleration(distance);
|
|
if (time > 0) {
|
|
action.update(-dx, -dy, time, mDecelerateInterpolator);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
@Override
|
|
protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
|
|
if (getChildCount() == 0) {
|
|
stop();
|
|
return;
|
|
}
|
|
//noinspection PointlessBooleanExpression
|
|
if (DEBUG && mTargetVector != null
|
|
&& ((mTargetVector.x * dx < 0 || mTargetVector.y * dy < 0))) {
|
|
throw new IllegalStateException("Scroll happened in the opposite direction"
|
|
+ " of the target. Some calculations are wrong");
|
|
}
|
|
mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx);
|
|
mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy);
|
|
|
|
if (mInterimTargetDx == 0 && mInterimTargetDy == 0) {
|
|
updateActionForInterimTarget(action);
|
|
} // everything is valid, keep going
|
|
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
@Override
|
|
protected void onStop() {
|
|
mInterimTargetDx = mInterimTargetDy = 0;
|
|
mTargetVector = null;
|
|
}
|
|
|
|
/**
|
|
* Calculates the scroll speed.
|
|
*
|
|
* @param displayMetrics DisplayMetrics to be used for real dimension calculations
|
|
* @return The time (in ms) it should take for each pixel. For instance, if returned value is
|
|
* 2 ms, it means scrolling 1000 pixels with LinearInterpolation should take 2 seconds.
|
|
*/
|
|
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
|
|
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
|
|
}
|
|
|
|
/**
|
|
* <p>Calculates the time for deceleration so that transition from LinearInterpolator to
|
|
* DecelerateInterpolator looks smooth.</p>
|
|
*
|
|
* @param dx Distance to scroll
|
|
* @return Time for DecelerateInterpolator to smoothly traverse the distance when transitioning
|
|
* from LinearInterpolation
|
|
*/
|
|
protected int calculateTimeForDeceleration(int dx) {
|
|
// we want to cover same area with the linear interpolator for the first 10% of the
|
|
// interpolation. After that, deceleration will take control.
|
|
// area under curve (1-(1-x)^2) can be calculated as (1 - x/3) * x * x
|
|
// which gives 0.100028 when x = .3356
|
|
// this is why we divide linear scrolling time with .3356
|
|
return (int) Math.ceil(calculateTimeForScrolling(dx) / .3356);
|
|
}
|
|
|
|
/**
|
|
* Calculates the time it should take to scroll the given distance (in pixels)
|
|
*
|
|
* @param dx Distance in pixels that we want to scroll
|
|
* @return Time in milliseconds
|
|
* @see #calculateSpeedPerPixel(android.util.DisplayMetrics)
|
|
*/
|
|
protected int calculateTimeForScrolling(int dx) {
|
|
// In a case where dx is very small, rounding may return 0 although dx > 0.
|
|
// To avoid that issue, ceil the result so that if dx > 0, we'll always return positive
|
|
// time.
|
|
return (int) Math.ceil(Math.abs(dx) * MILLISECONDS_PER_PX);
|
|
}
|
|
|
|
/**
|
|
* When scrolling towards a child view, this method defines whether we should align the left
|
|
* or the right edge of the child with the parent RecyclerView.
|
|
*
|
|
* @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
|
|
* @see #SNAP_TO_START
|
|
* @see #SNAP_TO_END
|
|
* @see #SNAP_TO_ANY
|
|
*/
|
|
protected int getHorizontalSnapPreference() {
|
|
return mTargetVector == null || mTargetVector.x == 0 ? SNAP_TO_ANY :
|
|
mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START;
|
|
}
|
|
|
|
/**
|
|
* When scrolling towards a child view, this method defines whether we should align the top
|
|
* or the bottom edge of the child with the parent RecyclerView.
|
|
*
|
|
* @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
|
|
* @see #SNAP_TO_START
|
|
* @see #SNAP_TO_END
|
|
* @see #SNAP_TO_ANY
|
|
*/
|
|
protected int getVerticalSnapPreference() {
|
|
return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY :
|
|
mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START;
|
|
}
|
|
|
|
/**
|
|
* When the target scroll position is not a child of the RecyclerView, this method calculates
|
|
* a direction vector towards that child and triggers a smooth scroll.
|
|
*
|
|
* @see #computeScrollVectorForPosition(int)
|
|
*/
|
|
protected void updateActionForInterimTarget(Action action) {
|
|
// find an interim target position
|
|
PointF scrollVector = computeScrollVectorForPosition(getTargetPosition());
|
|
if (scrollVector == null || (scrollVector.x == 0 && scrollVector.y == 0)) {
|
|
final int target = getTargetPosition();
|
|
action.jumpTo(target);
|
|
stop();
|
|
return;
|
|
}
|
|
normalize(scrollVector);
|
|
mTargetVector = scrollVector;
|
|
|
|
mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x);
|
|
mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y);
|
|
final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX);
|
|
// To avoid UI hiccups, trigger a smooth scroll to a distance little further than the
|
|
// interim target. Since we track the distance travelled in onSeekTargetStep callback, it
|
|
// won't actually scroll more than what we need.
|
|
action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO),
|
|
(int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO),
|
|
(int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator);
|
|
}
|
|
|
|
private int clampApplyScroll(int tmpDt, int dt) {
|
|
final int before = tmpDt;
|
|
tmpDt -= dt;
|
|
if (before * tmpDt <= 0) { // changed sign, reached 0 or was 0, reset
|
|
return 0;
|
|
}
|
|
return tmpDt;
|
|
}
|
|
|
|
/**
|
|
* Helper method for {@link #calculateDxToMakeVisible(android.view.View, int)} and
|
|
* {@link #calculateDyToMakeVisible(android.view.View, int)}
|
|
*/
|
|
public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int
|
|
snapPreference) {
|
|
switch (snapPreference) {
|
|
case SNAP_TO_START:
|
|
return boxStart - viewStart;
|
|
case SNAP_TO_END:
|
|
return boxEnd - viewEnd;
|
|
case SNAP_TO_ANY:
|
|
final int dtStart = boxStart - viewStart;
|
|
if (dtStart > 0) {
|
|
return dtStart;
|
|
}
|
|
final int dtEnd = boxEnd - viewEnd;
|
|
if (dtEnd < 0) {
|
|
return dtEnd;
|
|
}
|
|
break;
|
|
default:
|
|
throw new IllegalArgumentException("snap preference should be one of the"
|
|
+ " constants defined in SmoothScroller, starting with SNAP_");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Calculates the vertical scroll amount necessary to make the given view fully visible
|
|
* inside the RecyclerView.
|
|
*
|
|
* @param view The view which we want to make fully visible
|
|
* @param snapPreference The edge which the view should snap to when entering the visible
|
|
* area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or
|
|
* {@link #SNAP_TO_ANY}.
|
|
* @return The vertical scroll amount necessary to make the view visible with the given
|
|
* snap preference.
|
|
*/
|
|
public int calculateDyToMakeVisible(View view, int snapPreference) {
|
|
final RecyclerView.LayoutManager layoutManager = getLayoutManager();
|
|
if (layoutManager == null || !layoutManager.canScrollVertically()) {
|
|
return 0;
|
|
}
|
|
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
|
|
view.getLayoutParams();
|
|
final int top = layoutManager.getDecoratedTop(view) - params.topMargin;
|
|
final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin;
|
|
final int start = layoutManager.getPaddingTop();
|
|
final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom();
|
|
return calculateDtToFit(top, bottom, start, end, snapPreference);
|
|
}
|
|
|
|
/**
|
|
* Calculates the horizontal scroll amount necessary to make the given view fully visible
|
|
* inside the RecyclerView.
|
|
*
|
|
* @param view The view which we want to make fully visible
|
|
* @param snapPreference The edge which the view should snap to when entering the visible
|
|
* area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or
|
|
* {@link #SNAP_TO_END}
|
|
* @return The vertical scroll amount necessary to make the view visible with the given
|
|
* snap preference.
|
|
*/
|
|
public int calculateDxToMakeVisible(View view, int snapPreference) {
|
|
final RecyclerView.LayoutManager layoutManager = getLayoutManager();
|
|
if (layoutManager == null || !layoutManager.canScrollHorizontally()) {
|
|
return 0;
|
|
}
|
|
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
|
|
view.getLayoutParams();
|
|
final int left = layoutManager.getDecoratedLeft(view) - params.leftMargin;
|
|
final int right = layoutManager.getDecoratedRight(view) + params.rightMargin;
|
|
final int start = layoutManager.getPaddingLeft();
|
|
final int end = layoutManager.getWidth() - layoutManager.getPaddingRight();
|
|
return calculateDtToFit(left, right, start, end, snapPreference);
|
|
}
|
|
|
|
/**
|
|
* Compute the scroll vector for a given target position.
|
|
* <p>
|
|
* This method can return null if the layout manager cannot calculate a scroll vector
|
|
* for the given position (e.g. it has no current scroll position).
|
|
*
|
|
* @param targetPosition the position to which the scroller is scrolling
|
|
*
|
|
* @return the scroll vector for a given target position
|
|
*/
|
|
@Nullable
|
|
public PointF computeScrollVectorForPosition(int targetPosition) {
|
|
RecyclerView.LayoutManager layoutManager = getLayoutManager();
|
|
if (layoutManager instanceof ScrollVectorProvider) {
|
|
return ((ScrollVectorProvider) layoutManager)
|
|
.computeScrollVectorForPosition(targetPosition);
|
|
}
|
|
Log.w(TAG, "You should override computeScrollVectorForPosition when the LayoutManager"
|
|
+ " does not implement " + ScrollVectorProvider.class.getCanonicalName());
|
|
return null;
|
|
}
|
|
}
|