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