1 /* 2 * Copyright (C) 2019 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 package com.android.car.ui.recyclerview; 17 18 import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId; 19 20 import android.content.res.Resources; 21 import android.os.Handler; 22 import android.view.View; 23 import android.view.ViewGroup; 24 import android.view.animation.AccelerateDecelerateInterpolator; 25 import android.view.animation.Interpolator; 26 27 import androidx.annotation.IntRange; 28 import androidx.annotation.NonNull; 29 import androidx.recyclerview.widget.OrientationHelper; 30 import androidx.recyclerview.widget.RecyclerView; 31 32 import com.android.car.ui.R; 33 import com.android.car.ui.utils.CarUiUtils; 34 35 /** 36 * The default scroll bar widget for the {@link CarUiRecyclerView}. 37 * 38 * <p>Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic has 39 * been ported from the PLV with minor updates. 40 */ 41 class DefaultScrollBar implements ScrollBar { 42 43 private float mButtonDisabledAlpha; 44 private CarUiSnapHelper mSnapHelper; 45 46 private View mScrollView; 47 private View mScrollTrack; 48 private View mScrollThumb; 49 private View mUpButton; 50 private View mDownButton; 51 52 private RecyclerView mRecyclerView; 53 54 private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator(); 55 56 private final Handler mHandler = new Handler(); 57 58 private OrientationHelper mOrientationHelper; 59 60 @Override initialize(RecyclerView rv, View scrollView)61 public void initialize(RecyclerView rv, View scrollView) { 62 mRecyclerView = rv; 63 64 mScrollView = scrollView; 65 66 Resources res = rv.getContext().getResources(); 67 68 mButtonDisabledAlpha = CarUiUtils.getFloat(res, R.dimen.car_ui_button_disabled_alpha); 69 70 getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener); 71 getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12); 72 73 mUpButton = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_page_up); 74 View.OnClickListener paginateUpButtonOnClickListener = v -> pageUp(); 75 mUpButton.setOnClickListener(paginateUpButtonOnClickListener); 76 mUpButton.setOnTouchListener( 77 new OnContinuousScrollListener(rv.getContext(), paginateUpButtonOnClickListener)); 78 79 mDownButton = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_page_down); 80 View.OnClickListener paginateDownButtonOnClickListener = v -> pageDown(); 81 mDownButton.setOnClickListener(paginateDownButtonOnClickListener); 82 mDownButton.setOnTouchListener( 83 new OnContinuousScrollListener(rv.getContext(), paginateDownButtonOnClickListener)); 84 85 mScrollTrack = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_track); 86 mScrollThumb = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_thumb); 87 88 mSnapHelper = new CarUiSnapHelper(rv.getContext()); 89 getRecyclerView().setOnFlingListener(null); 90 mSnapHelper.attachToRecyclerView(getRecyclerView()); 91 92 // enables fast scrolling. 93 FastScroller fastScroller = new FastScroller(mRecyclerView, mScrollTrack, mScrollView); 94 fastScroller.enable(); 95 96 mScrollView.setVisibility(View.INVISIBLE); 97 mScrollView.addOnLayoutChangeListener( 98 (View v, 99 int left, 100 int top, 101 int right, 102 int bottom, 103 int oldLeft, 104 int oldTop, 105 int oldRight, 106 int oldBottom) -> mHandler.post(this::updatePaginationButtons)); 107 } 108 getRecyclerView()109 public RecyclerView getRecyclerView() { 110 return mRecyclerView; 111 } 112 113 @Override requestLayout()114 public void requestLayout() { 115 mScrollView.requestLayout(); 116 } 117 118 @Override setPadding(int paddingStart, int paddingEnd)119 public void setPadding(int paddingStart, int paddingEnd) { 120 mScrollView.setPadding(mScrollView.getPaddingLeft(), paddingStart, 121 mScrollView.getPaddingRight(), paddingEnd); 122 } 123 124 /** 125 * Sets whether or not the up button on the scroll bar is clickable. 126 * 127 * @param enabled {@code true} if the up button is enabled. 128 */ setUpEnabled(boolean enabled)129 private void setUpEnabled(boolean enabled) { 130 mUpButton.setEnabled(enabled); 131 mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); 132 } 133 134 /** 135 * Sets whether or not the down button on the scroll bar is clickable. 136 * 137 * @param enabled {@code true} if the down button is enabled. 138 */ setDownEnabled(boolean enabled)139 private void setDownEnabled(boolean enabled) { 140 mDownButton.setEnabled(enabled); 141 mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); 142 } 143 144 /** 145 * Returns whether or not the down button on the scroll bar is clickable. 146 * 147 * @return {@code true} if the down button is enabled. {@code false} otherwise. 148 */ isDownEnabled()149 private boolean isDownEnabled() { 150 return mDownButton.isEnabled(); 151 } 152 153 /** 154 * Sets the range, offset and extent of the scroll bar. The range represents the size of a 155 * container for the scrollbar thumb; offset is the distance from the start of the container to 156 * where the thumb should be; and finally, extent is the size of the thumb. 157 * 158 * <p>These values can be expressed in arbitrary units, so long as they share the same units. 159 * The 160 * values should also be positive. 161 * 162 * @param range The range of the scrollbar's thumb 163 * @param offset The offset of the scrollbar's thumb 164 * @param extent The extent of the scrollbar's thumb 165 */ setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent)166 private void setParameters( 167 @IntRange(from = 0) int range, 168 @IntRange(from = 0) int offset, 169 @IntRange(from = 0) int extent) { 170 // Not laid out yet, so values cannot be calculated. 171 if (!mScrollView.isLaidOut()) { 172 return; 173 } 174 175 // If the scroll bars aren't visible, then no need to update. 176 if (mScrollView.getVisibility() == View.GONE || range == 0) { 177 return; 178 } 179 180 int thumbLength = calculateScrollThumbLength(range, extent); 181 int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); 182 183 // Sets the size of the thumb and request a redraw if needed. 184 ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); 185 186 if (lp.height != thumbLength) { 187 lp.height = thumbLength; 188 mScrollThumb.requestLayout(); 189 } 190 191 moveY(mScrollThumb, thumbOffset); 192 } 193 194 /** 195 * Calculates and returns how big the scroll bar thumb should be based on the given range and 196 * extent. 197 * 198 * @param range The total amount of space the scroll bar is allowed to roam over. 199 * @param extent The amount of space that the scroll bar takes up relative to the range. 200 * @return The height of the scroll bar thumb in pixels. 201 */ calculateScrollThumbLength(int range, int extent)202 private int calculateScrollThumbLength(int range, int extent) { 203 // Scale the length by the available space that the thumb can fill. 204 return Math.round(((float) extent / range) * mScrollTrack.getHeight()); 205 } 206 207 /** 208 * Calculates and returns how much the scroll thumb should be offset from the top of where it 209 * has 210 * been laid out. 211 * 212 * @param range The total amount of space the scroll bar is allowed to roam over. 213 * @param offset The amount the scroll bar should be offset, expressed in the same units as 214 * the 215 * given range. 216 * @param thumbLength The current length of the thumb in pixels. 217 * @return The amount the thumb should be offset in pixels. 218 */ calculateScrollThumbOffset(int range, int offset, int thumbLength)219 private int calculateScrollThumbOffset(int range, int offset, int thumbLength) { 220 // Ensure that if the user has reached the bottom of the list, then the scroll bar is 221 // aligned to the bottom as well. Otherwise, scale the offset appropriately. 222 // This offset will be a value relative to the parent of this scrollbar, so start by where 223 // the top of scrollbar track is. 224 return mScrollTrack.getTop() 225 + (isDownEnabled() 226 ? Math.round(((float) offset / range) * mScrollTrack.getHeight()) 227 : mScrollTrack.getHeight() - thumbLength); 228 } 229 230 /** Moves the given view to the specified 'y' position. */ moveY(final View view, float newPosition)231 private void moveY(final View view, float newPosition) { 232 view.animate() 233 .y(newPosition) 234 .setDuration(/* duration= */ 0) 235 .setInterpolator(mPaginationInterpolator) 236 .start(); 237 } 238 239 private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener = 240 new RecyclerView.OnScrollListener() { 241 @Override 242 public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { 243 updatePaginationButtons(); 244 } 245 }; 246 getOrientationHelper(RecyclerView.LayoutManager layoutManager)247 private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) { 248 if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) { 249 // CarUiRecyclerView is assumed to be a list that always vertically scrolls. 250 mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager); 251 } 252 return mOrientationHelper; 253 } 254 255 /** 256 * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the 257 * {@code CarUiRecyclerView}. 258 * 259 * <p>The resulting first item in the list will be snapped to so that it is completely visible. 260 * If 261 * this is not possible due to the first item being taller than the containing {@code 262 * CarUiRecyclerView}, then the snapping will not occur. 263 */ pageUp()264 void pageUp() { 265 int currentOffset = getRecyclerView().computeVerticalScrollOffset(); 266 if (getRecyclerView().getLayoutManager() == null 267 || getRecyclerView().getChildCount() == 0 268 || currentOffset == 0) { 269 return; 270 } 271 272 // Use OrientationHelper to calculate scroll distance in order to match snapping behavior. 273 OrientationHelper orientationHelper = 274 getOrientationHelper(getRecyclerView().getLayoutManager()); 275 int screenSize = orientationHelper.getTotalSpace(); 276 int scrollDistance = screenSize; 277 // The iteration order matters. In case where there are 2 items longer than screen size, we 278 // want to focus on upcoming view. 279 for (int i = 0; i < getRecyclerView().getChildCount(); i++) { 280 /* 281 * We treat child View longer than screen size differently: 282 * 1) When it enters screen, next pageUp will align its bottom with parent bottom; 283 * 2) When it leaves screen, next pageUp will align its top with parent top. 284 */ 285 View child = getRecyclerView().getChildAt(i); 286 if (child.getHeight() > screenSize) { 287 if (orientationHelper.getDecoratedEnd(child) < screenSize) { 288 // Child view bottom is entering screen. Align its bottom with parent bottom. 289 scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child); 290 } else if (-screenSize < orientationHelper.getDecoratedStart(child) 291 && orientationHelper.getDecoratedStart(child) < 0) { 292 // Child view top is about to enter screen - its distance to parent top 293 // is less than a full scroll. Align child top with parent top. 294 scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child)); 295 } 296 // There can be two items that are longer than the screen. We stop at the first one. 297 // This is affected by the iteration order. 298 break; 299 } 300 } 301 // Distance should always be positive. Negate its value to scroll up. 302 mRecyclerView.smoothScrollBy(0, -scrollDistance); 303 } 304 305 /** 306 * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the 307 * {@code CarUiRecyclerView}. 308 * 309 * <p>This method will attempt to bring the last item in the list as the first item. If the 310 * current first item in the list is taller than the {@code CarUiRecyclerView}, then it will be 311 * scrolled the length of a page, but not snapped to. 312 */ pageDown()313 void pageDown() { 314 if (getRecyclerView().getLayoutManager() == null 315 || getRecyclerView().getChildCount() == 0) { 316 return; 317 } 318 319 OrientationHelper orientationHelper = 320 getOrientationHelper(getRecyclerView().getLayoutManager()); 321 int screenSize = orientationHelper.getTotalSpace(); 322 int scrollDistance = screenSize; 323 324 // If the last item is partially visible, page down should bring it to the top. 325 View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1); 326 if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild, 327 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) { 328 scrollDistance = orientationHelper.getDecoratedStart(lastChild); 329 if (scrollDistance <= 0) { 330 // - Scroll value is zero if the top of last item is aligned with top of the screen; 331 // - Scroll value can be negative if the child is longer than the screen size and 332 // the visible area of the screen does not show the start of the child. 333 // Scroll to the next screen in both cases. 334 scrollDistance = screenSize; 335 } 336 } 337 338 // The iteration order matters. In case where there are 2 items longer than screen size, we 339 // want to focus on upcoming view (the one at the bottom of screen). 340 for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) { 341 /* We treat child View longer than screen size differently: 342 * 1) When it enters screen, next pageDown will align its top with parent top; 343 * 2) When it leaves screen, next pageDown will align its bottom with parent bottom. 344 */ 345 View child = getRecyclerView().getChildAt(i); 346 if (child.getHeight() > screenSize) { 347 if (orientationHelper.getDecoratedStart(child) > 0) { 348 // Child view top is entering screen. Align its top with parent top. 349 scrollDistance = orientationHelper.getDecoratedStart(child); 350 } else if (screenSize < orientationHelper.getDecoratedEnd(child) 351 && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) { 352 // Child view bottom is about to enter screen - its distance to parent bottom 353 // is less than a full scroll. Align child bottom with parent bottom. 354 scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize; 355 } 356 // There can be two items that are longer than the screen. We stop at the first one. 357 // This is affected by the iteration order. 358 break; 359 } 360 } 361 362 mRecyclerView.smoothScrollBy(0, scrollDistance); 363 } 364 365 /** 366 * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is 367 * being called as a result of adapter changes, it should be called after the new layout has 368 * been 369 * calculated because the method of determining scrollbar visibility uses the current layout. 370 * If 371 * this is called after an adapter change but before the new layout, the visibility 372 * determination 373 * may not be correct. 374 */ updatePaginationButtons()375 private void updatePaginationButtons() { 376 377 boolean isAtStart = isAtStart(); 378 boolean isAtEnd = isAtEnd(); 379 RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); 380 381 // enable/disable the button before the view is shown. So there is no flicker. 382 setUpEnabled(!isAtStart); 383 setDownEnabled(!isAtEnd); 384 if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) { 385 mScrollView.setVisibility(View.INVISIBLE); 386 } else { 387 mScrollView.setVisibility(View.VISIBLE); 388 } 389 390 if (layoutManager == null) { 391 return; 392 } 393 394 if (layoutManager.canScrollVertically()) { 395 setParameters( 396 getRecyclerView().computeVerticalScrollRange(), 397 getRecyclerView().computeVerticalScrollOffset(), 398 getRecyclerView().computeVerticalScrollExtent()); 399 } else { 400 setParameters( 401 getRecyclerView().computeHorizontalScrollRange(), 402 getRecyclerView().computeHorizontalScrollOffset(), 403 getRecyclerView().computeHorizontalScrollExtent()); 404 } 405 406 mScrollView.invalidate(); 407 } 408 409 /** Returns {@code true} if the RecyclerView is completely displaying the first item. */ isAtStart()410 boolean isAtStart() { 411 return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager()); 412 } 413 414 /** Returns {@code true} if the RecyclerView is completely displaying the last item. */ isAtEnd()415 boolean isAtEnd() { 416 return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager()); 417 } 418 } 419