1/* 2 * Copyright (C) 2016 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package android.support.v7.widget; 18 19import android.support.annotation.NonNull; 20import android.support.annotation.Nullable; 21import android.support.v7.widget.RecyclerView.LayoutManager; 22import android.support.v7.widget.RecyclerView.SmoothScroller.ScrollVectorProvider; 23import android.util.DisplayMetrics; 24import android.view.View; 25import android.view.animation.DecelerateInterpolator; 26import android.widget.Scroller; 27 28/** 29 * Class intended to support snapping for a {@link RecyclerView}. 30 * <p> 31 * SnapHelper tries to handle fling as well but for this to work properly, the 32 * {@link RecyclerView.LayoutManager} must implement the {@link ScrollVectorProvider} interface or 33 * you should override {@link #onFling(int, int)} and handle fling manually. 34 */ 35public abstract class SnapHelper extends RecyclerView.OnFlingListener { 36 37 static final float MILLISECONDS_PER_INCH = 100f; 38 39 RecyclerView mRecyclerView; 40 private Scroller mGravityScroller; 41 42 // Handles the snap on scroll case. 43 private final RecyclerView.OnScrollListener mScrollListener = 44 new RecyclerView.OnScrollListener() { 45 boolean mScrolled = false; 46 47 @Override 48 public void onScrollStateChanged(RecyclerView recyclerView, int newState) { 49 super.onScrollStateChanged(recyclerView, newState); 50 if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) { 51 mScrolled = false; 52 snapToTargetExistingView(); 53 } 54 } 55 56 @Override 57 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 58 if (dx != 0 || dy != 0) { 59 mScrolled = true; 60 } 61 } 62 }; 63 64 @Override 65 public boolean onFling(int velocityX, int velocityY) { 66 LayoutManager layoutManager = mRecyclerView.getLayoutManager(); 67 if (layoutManager == null) { 68 return false; 69 } 70 RecyclerView.Adapter adapter = mRecyclerView.getAdapter(); 71 if (adapter == null) { 72 return false; 73 } 74 int minFlingVelocity = mRecyclerView.getMinFlingVelocity(); 75 return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity) 76 && snapFromFling(layoutManager, velocityX, velocityY); 77 } 78 79 /** 80 * Attaches the {@link SnapHelper} to the provided RecyclerView, by calling 81 * {@link RecyclerView#setOnFlingListener(RecyclerView.OnFlingListener)}. 82 * You can call this method with {@code null} to detach it from the current RecyclerView. 83 * 84 * @param recyclerView The RecyclerView instance to which you want to add this helper or 85 * {@code null} if you want to remove SnapHelper from the current 86 * RecyclerView. 87 * 88 * @throws IllegalArgumentException if there is already a {@link RecyclerView.OnFlingListener} 89 * attached to the provided {@link RecyclerView}. 90 * 91 */ 92 public void attachToRecyclerView(@Nullable RecyclerView recyclerView) 93 throws IllegalStateException { 94 if (mRecyclerView == recyclerView) { 95 return; // nothing to do 96 } 97 if (mRecyclerView != null) { 98 destroyCallbacks(); 99 } 100 mRecyclerView = recyclerView; 101 if (mRecyclerView != null) { 102 setupCallbacks(); 103 mGravityScroller = new Scroller(mRecyclerView.getContext(), 104 new DecelerateInterpolator()); 105 snapToTargetExistingView(); 106 } 107 } 108 109 /** 110 * Called when an instance of a {@link RecyclerView} is attached. 111 */ 112 private void setupCallbacks() throws IllegalStateException { 113 if (mRecyclerView.getOnFlingListener() != null) { 114 throw new IllegalStateException("An instance of OnFlingListener already set."); 115 } 116 mRecyclerView.addOnScrollListener(mScrollListener); 117 mRecyclerView.setOnFlingListener(this); 118 } 119 120 /** 121 * Called when the instance of a {@link RecyclerView} is detached. 122 */ 123 private void destroyCallbacks() { 124 mRecyclerView.removeOnScrollListener(mScrollListener); 125 mRecyclerView.setOnFlingListener(null); 126 } 127 128 /** 129 * Calculated the estimated scroll distance in each direction given velocities on both axes. 130 * 131 * @param velocityX Fling velocity on the horizontal axis. 132 * @param velocityY Fling velocity on the vertical axis. 133 * 134 * @return array holding the calculated distances in x and y directions 135 * respectively. 136 */ 137 public int[] calculateScrollDistance(int velocityX, int velocityY) { 138 int[] outDist = new int[2]; 139 mGravityScroller.fling(0, 0, velocityX, velocityY, 140 Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE); 141 outDist[0] = mGravityScroller.getFinalX(); 142 outDist[1] = mGravityScroller.getFinalY(); 143 return outDist; 144 } 145 146 /** 147 * Helper method to facilitate for snapping triggered by a fling. 148 * 149 * @param layoutManager The {@link LayoutManager} associated with the attached 150 * {@link RecyclerView}. 151 * @param velocityX Fling velocity on the horizontal axis. 152 * @param velocityY Fling velocity on the vertical axis. 153 * 154 * @return true if it is handled, false otherwise. 155 */ 156 private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX, 157 int velocityY) { 158 if (!(layoutManager instanceof ScrollVectorProvider)) { 159 return false; 160 } 161 162 RecyclerView.SmoothScroller smoothScroller = createSnapScroller(layoutManager); 163 if (smoothScroller == null) { 164 return false; 165 } 166 167 int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY); 168 if (targetPosition == RecyclerView.NO_POSITION) { 169 return false; 170 } 171 172 smoothScroller.setTargetPosition(targetPosition); 173 layoutManager.startSmoothScroll(smoothScroller); 174 return true; 175 } 176 177 /** 178 * Snaps to a target view which currently exists in the attached {@link RecyclerView}. This 179 * method is used to snap the view when the {@link RecyclerView} is first attached; when 180 * snapping was triggered by a scroll and when the fling is at its final stages. 181 */ 182 void snapToTargetExistingView() { 183 if (mRecyclerView == null) { 184 return; 185 } 186 LayoutManager layoutManager = mRecyclerView.getLayoutManager(); 187 if (layoutManager == null) { 188 return; 189 } 190 View snapView = findSnapView(layoutManager); 191 if (snapView == null) { 192 return; 193 } 194 int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView); 195 if (snapDistance[0] != 0 || snapDistance[1] != 0) { 196 mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]); 197 } 198 } 199 200 /** 201 * Creates a scroller to be used in the snapping implementation. 202 * 203 * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached 204 * {@link RecyclerView}. 205 * 206 * @return a {@link LinearSmoothScroller} which will handle the scrolling. 207 */ 208 @Nullable 209 protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) { 210 if (!(layoutManager instanceof ScrollVectorProvider)) { 211 return null; 212 } 213 return new LinearSmoothScroller(mRecyclerView.getContext()) { 214 @Override 215 protected void onTargetFound(View targetView, RecyclerView.State state, Action action) { 216 int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(), 217 targetView); 218 final int dx = snapDistances[0]; 219 final int dy = snapDistances[1]; 220 final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy))); 221 if (time > 0) { 222 action.update(dx, dy, time, mDecelerateInterpolator); 223 } 224 } 225 226 @Override 227 protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { 228 return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; 229 } 230 }; 231 } 232 233 /** 234 * Override this method to snap to a particular point within the target view or the container 235 * view on any axis. 236 * <p> 237 * This method is called when the {@link SnapHelper} has intercepted a fling and it needs 238 * to know the exact distance required to scroll by in order to snap to the target view. 239 * 240 * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached 241 * {@link RecyclerView} 242 * @param targetView the target view that is chosen as the view to snap 243 * 244 * @return the output coordinates the put the result into. out[0] is the distance 245 * on horizontal axis and out[1] is the distance on vertical axis. 246 */ 247 @SuppressWarnings("WeakerAccess") 248 @Nullable 249 public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, 250 @NonNull View targetView); 251 252 /** 253 * Override this method to provide a particular target view for snapping. 254 * <p> 255 * This method is called when the {@link SnapHelper} is ready to start snapping and requires 256 * a target view to snap to. It will be explicitly called when the scroll state becomes idle 257 * after a scroll. It will also be called when the {@link SnapHelper} is preparing to snap 258 * after a fling and requires a reference view from the current set of child views. 259 * <p> 260 * If this method returns {@code null}, SnapHelper will not snap to any view. 261 * 262 * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached 263 * {@link RecyclerView} 264 * 265 * @return the target view to which to snap on fling or end of scroll 266 */ 267 @SuppressWarnings("WeakerAccess") 268 @Nullable 269 public abstract View findSnapView(LayoutManager layoutManager); 270 271 /** 272 * Override to provide a particular adapter target position for snapping. 273 * 274 * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached 275 * {@link RecyclerView} 276 * @param velocityX fling velocity on the horizontal axis 277 * @param velocityY fling velocity on the vertical axis 278 * 279 * @return the target adapter position to you want to snap or {@link RecyclerView#NO_POSITION} 280 * if no snapping should happen 281 */ 282 public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, 283 int velocityY); 284}