1 /* 2 * Copyright (C) 2017 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.car.widget; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.graphics.PorterDuff; 22 import android.graphics.drawable.Drawable; 23 import android.graphics.drawable.GradientDrawable; 24 import android.util.AttributeSet; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.view.animation.AccelerateDecelerateInterpolator; 29 import android.view.animation.Interpolator; 30 import android.widget.ImageView; 31 import android.widget.TextView; 32 33 import androidx.annotation.ColorRes; 34 import androidx.annotation.IntRange; 35 import androidx.annotation.VisibleForTesting; 36 import androidx.car.R; 37 import androidx.core.content.ContextCompat; 38 39 /** A custom view to provide list scroll behaviour -- up/down buttons and scroll indicator. */ 40 public class PagedScrollBarView extends ViewGroup { 41 private static final float BUTTON_DISABLED_ALPHA = 0.2f; 42 43 @DayNightStyle private int mDayNightStyle; 44 45 /** Listener for when the list should paginate. */ 46 public interface PaginationListener { 47 int PAGE_UP = 0; 48 int PAGE_DOWN = 1; 49 50 /** Called when the linked view should be paged in the given direction */ onPaginate(int direction)51 void onPaginate(int direction); 52 53 /** 54 * Called when the 'alpha jump' button is clicked and the linked view should switch into 55 * alpha jump mode, where we display a list of buttons to allow the user to quickly scroll 56 * to a certain point in the list, bypassing a lot of manual scrolling. 57 */ onAlphaJump()58 void onAlphaJump(); 59 } 60 61 private final ImageView mUpButton; 62 private final PaginateButtonClickListener mUpButtonClickListener; 63 private final ImageView mDownButton; 64 private final PaginateButtonClickListener mDownButtonClickListener; 65 private final TextView mAlphaJumpButton; 66 private final AlphaJumpButtonClickListener mAlphaJumpButtonClickListener; 67 private final View mScrollThumb; 68 69 private final int mSeparatingMargin; 70 private final int mScrollBarThumbWidth; 71 72 /** The amount of space that the scroll thumb is allowed to roam over. */ 73 private int mScrollThumbTrackHeight; 74 75 private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator(); 76 private boolean mUseCustomThumbBackground; 77 @ColorRes private int mCustomThumbBackgroundResId; 78 PagedScrollBarView(Context context)79 public PagedScrollBarView(Context context) { 80 super(context); 81 } 82 PagedScrollBarView(Context context, AttributeSet attrs)83 public PagedScrollBarView(Context context, AttributeSet attrs) { 84 super(context, attrs); 85 } 86 PagedScrollBarView(Context context, AttributeSet attrs, int defStyleAttrs)87 public PagedScrollBarView(Context context, AttributeSet attrs, int defStyleAttrs) { 88 super(context, attrs, defStyleAttrs); 89 } 90 PagedScrollBarView( Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)91 public PagedScrollBarView( 92 Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) { 93 super(context, attrs, defStyleAttrs, defStyleRes); 94 } 95 96 // Using an initialization block so that the fields referenced in this block can be marked 97 // as "final". This block will run after the super() call in constructors. 98 { 99 Resources res = getResources(); 100 mSeparatingMargin = res.getDimensionPixelSize(R.dimen.car_padding_2); 101 mScrollBarThumbWidth = res.getDimensionPixelSize(R.dimen.car_scroll_bar_thumb_width); 102 103 LayoutInflater inflater = 104 (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); inflater.inflate(R.layout.car_paged_scrollbar_buttons, this , true )105 inflater.inflate(R.layout.car_paged_scrollbar_buttons, this /* root */, 106 true /* attachToRoot */); 107 108 mUpButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_UP); 109 mDownButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_DOWN); 110 mAlphaJumpButtonClickListener = new AlphaJumpButtonClickListener(); 111 112 mUpButton = findViewById(R.id.page_up); 113 mUpButton.setOnClickListener(mUpButtonClickListener); 114 mDownButton = findViewById(R.id.page_down); 115 mDownButton.setOnClickListener(mDownButtonClickListener); 116 mAlphaJumpButton = findViewById(R.id.alpha_jump); 117 mAlphaJumpButton.setOnClickListener(mAlphaJumpButtonClickListener); 118 119 mScrollThumb = findViewById(R.id.scrollbar_thumb); 120 } 121 122 /** Sets the icon to be used for the up button. */ setUpButtonIcon(Drawable icon)123 public void setUpButtonIcon(Drawable icon) { 124 mUpButton.setImageDrawable(icon); 125 } 126 127 /** Sets the icon to be used for the down button. */ setDownButtonIcon(Drawable icon)128 public void setDownButtonIcon(Drawable icon) { 129 mDownButton.setImageDrawable(icon); 130 } 131 132 /** 133 * Sets the listener that will be notified when the up and down buttons have been pressed. 134 * 135 * @param listener The listener to set. 136 */ setPaginationListener(PaginationListener listener)137 public void setPaginationListener(PaginationListener listener) { 138 mUpButtonClickListener.setPaginationListener(listener); 139 mDownButtonClickListener.setPaginationListener(listener); 140 mAlphaJumpButtonClickListener.setPaginationListener(listener); 141 } 142 143 /** Returns {@code true} if the "up" button is pressed */ isUpPressed()144 public boolean isUpPressed() { 145 return mUpButton.isPressed(); 146 } 147 148 /** Returns {@code true} if the "down" button is pressed */ isDownPressed()149 public boolean isDownPressed() { 150 return mDownButton.isPressed(); 151 } 152 setShowAlphaJump(boolean show)153 void setShowAlphaJump(boolean show) { 154 mAlphaJumpButton.setVisibility(show ? View.VISIBLE : View.GONE); 155 } 156 157 /** 158 * Sets the range, offset and extent of the scroll bar. The range represents the size of a 159 * container for the scrollbar thumb; offset is the distance from the start of the container 160 * to where the thumb should be; and finally, extent is the size of the thumb. 161 * 162 * <p>These values can be expressed in arbitrary units, so long as they share the same units. 163 * The values should also be positive. 164 * 165 * @param range The range of the scrollbar's thumb 166 * @param offset The offset of the scrollbar's thumb 167 * @param extent The extent of the scrollbar's thumb 168 * @param animate Whether or not the thumb should animate from its current position to the 169 * position specified by the given range, offset and extent. 170 * 171 * @see View#computeVerticalScrollRange() 172 * @see View#computeVerticalScrollOffset() 173 * @see View#computeVerticalScrollExtent() 174 */ setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent, boolean animate)175 public void setParameters( 176 @IntRange(from = 0) int range, 177 @IntRange(from = 0) int offset, 178 @IntRange(from = 0) int extent, boolean animate) { 179 // Not laid out yet, so values cannot be calculated. 180 if (!isLaidOut()) { 181 return; 182 } 183 184 // If the scroll bars aren't visible, then no need to update. 185 if (getVisibility() == View.GONE || range == 0) { 186 return; 187 } 188 189 int thumbLength = calculateScrollThumbLength(range, extent); 190 int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); 191 192 // Sets the size of the thumb and request a redraw if needed. 193 ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); 194 195 if (lp.height != thumbLength) { 196 lp.height = thumbLength; 197 mScrollThumb.requestLayout(); 198 } 199 200 moveY(mScrollThumb, thumbOffset, animate); 201 } 202 203 /** 204 * An optimized version of {@link #setParameters(int, int, int, boolean)} that is meant to be 205 * called if a view is laying itself out. This method will avoid a complete remeasure of 206 * the views in the {@code PagedScrollBarView} if the scroll thumb's height needs to be changed. 207 * Instead, only the thumb itself will be remeasured and laid out. 208 * 209 * <p>These values can be expressed in arbitrary units, so long as they share the same units. 210 * 211 * @param range The range of the scrollbar's thumb 212 * @param offset The offset of the scrollbar's thumb 213 * @param extent The extent of the scrollbar's thumb 214 * 215 * @see #setParameters(int, int, int, boolean) 216 */ setParametersInLayout(int range, int offset, int extent)217 void setParametersInLayout(int range, int offset, int extent) { 218 // If the scroll bars aren't visible, then no need to update. 219 if (getVisibility() == View.GONE || range == 0) { 220 return; 221 } 222 223 int thumbLength = calculateScrollThumbLength(range, extent); 224 int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); 225 226 // Sets the size of the thumb and request a redraw if needed. 227 ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); 228 229 if (lp.height != thumbLength) { 230 lp.height = thumbLength; 231 measureAndLayoutScrollThumb(); 232 } 233 234 mScrollThumb.setY(thumbOffset); 235 } 236 237 /** 238 * Sets how this {@link PagedScrollBarView} responds to day/night configuration changes. By 239 * default, the PagedScrollBarView is darker in the day and lighter at night. 240 * 241 * @param dayNightStyle A value from {@link DayNightStyle}. 242 * @see DayNightStyle 243 */ setDayNightStyle(@ayNightStyle int dayNightStyle)244 public void setDayNightStyle(@DayNightStyle int dayNightStyle) { 245 mDayNightStyle = dayNightStyle; 246 reloadColors(); 247 } 248 249 /** 250 * Sets whether or not the up button on the scroll bar is clickable. 251 * 252 * @param enabled {@code true} if the up button is enabled. 253 */ setUpEnabled(boolean enabled)254 public void setUpEnabled(boolean enabled) { 255 mUpButton.setEnabled(enabled); 256 mUpButton.setAlpha(enabled ? 1f : BUTTON_DISABLED_ALPHA); 257 } 258 259 /** 260 * Sets whether or not the down button on the scroll bar is clickable. 261 * 262 * @param enabled {@code true} if the down button is enabled. 263 */ setDownEnabled(boolean enabled)264 public void setDownEnabled(boolean enabled) { 265 mDownButton.setEnabled(enabled); 266 mDownButton.setAlpha(enabled ? 1f : BUTTON_DISABLED_ALPHA); 267 } 268 269 /** 270 * Returns whether or not the down button on the scroll bar is clickable. 271 * 272 * @return {@code true} if the down button is enabled. {@code false} otherwise. 273 */ isDownEnabled()274 public boolean isDownEnabled() { 275 return mDownButton.isEnabled(); 276 } 277 278 /** 279 * Sets the color of thumb. 280 * 281 * <p>Custom thumb color ignores {@link DayNightStyle}. Calling {@link #resetThumbColor} resets 282 * to default color. 283 * 284 * @param color Resource identifier of the color. 285 */ setThumbColor(@olorRes int color)286 public void setThumbColor(@ColorRes int color) { 287 mUseCustomThumbBackground = true; 288 mCustomThumbBackgroundResId = color; 289 reloadColors(); 290 } 291 292 /** 293 * Resets the color of thumb to default. 294 */ resetThumbColor()295 public void resetThumbColor() { 296 mUseCustomThumbBackground = false; 297 reloadColors(); 298 } 299 300 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)301 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 302 int requestedWidth = MeasureSpec.getSize(widthMeasureSpec); 303 int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); 304 305 int wrapMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 306 307 mUpButton.measure(wrapMeasureSpec, wrapMeasureSpec); 308 mDownButton.measure(wrapMeasureSpec, wrapMeasureSpec); 309 310 measureScrollThumb(); 311 312 if (mAlphaJumpButton.getVisibility() != GONE) { 313 mAlphaJumpButton.measure(wrapMeasureSpec, wrapMeasureSpec); 314 } 315 316 setMeasuredDimension(requestedWidth, requestedHeight); 317 } 318 319 @Override onLayout(boolean changed, int left, int top, int right, int bottom)320 public void onLayout(boolean changed, int left, int top, int right, int bottom) { 321 int width = right - left; 322 int height = bottom - top; 323 324 // This value will keep track of the top of the current view being laid out. 325 int layoutTop = getPaddingTop(); 326 327 // Lay out the up button at the top of the view. 328 layoutViewCenteredFromTop(mUpButton, layoutTop, width); 329 layoutTop = mUpButton.getBottom(); 330 331 // Lay out the alpha jump button if it exists. This button goes below the up button. 332 if (mAlphaJumpButton.getVisibility() != GONE) { 333 layoutTop += mSeparatingMargin; 334 335 layoutViewCenteredFromTop(mAlphaJumpButton, layoutTop, width); 336 337 layoutTop = mAlphaJumpButton.getBottom(); 338 } 339 340 // Lay out the scroll thumb 341 layoutTop += mSeparatingMargin; 342 layoutViewCenteredFromTop(mScrollThumb, layoutTop, width); 343 344 // Lay out the bottom button at the bottom of the view. 345 int downBottom = height - getPaddingBottom(); 346 layoutViewCenteredFromBottom(mDownButton, downBottom, width); 347 348 calculateScrollThumbTrackHeight(); 349 } 350 351 /** 352 * Calculate the amount of space that the scroll bar thumb is allowed to roam. The thumb 353 * is allowed to take up the space between the down bottom and the up or alpha jump 354 * button, depending on if the latter is visible. 355 */ calculateScrollThumbTrackHeight()356 private void calculateScrollThumbTrackHeight() { 357 // Subtracting (2 * mSeparatingMargin) for the top/bottom margin above and below the 358 // scroll bar thumb. 359 mScrollThumbTrackHeight = mDownButton.getTop() - (2 * mSeparatingMargin); 360 361 // If there's an alpha jump button, then the thumb is laid out starting from below that. 362 if (mAlphaJumpButton.getVisibility() != GONE) { 363 mScrollThumbTrackHeight -= mAlphaJumpButton.getBottom(); 364 } else { 365 mScrollThumbTrackHeight -= mUpButton.getBottom(); 366 } 367 } 368 measureScrollThumb()369 private void measureScrollThumb() { 370 int scrollWidth = MeasureSpec.makeMeasureSpec(mScrollBarThumbWidth, MeasureSpec.EXACTLY); 371 int scrollHeight = MeasureSpec.makeMeasureSpec( 372 mScrollThumb.getLayoutParams().height, 373 MeasureSpec.EXACTLY); 374 mScrollThumb.measure(scrollWidth, scrollHeight); 375 } 376 377 /** 378 * An optimization method to only remeasure and lay out the scroll thumb. This method should be 379 * used when the height of the thumb has changed, but no other views need to be remeasured. 380 */ measureAndLayoutScrollThumb()381 private void measureAndLayoutScrollThumb() { 382 measureScrollThumb(); 383 384 // The top value should not change from what it was before; only the height is assumed to 385 // be changing. 386 int layoutTop = mScrollThumb.getTop(); 387 layoutViewCenteredFromTop(mScrollThumb, layoutTop, getMeasuredWidth()); 388 } 389 390 /** 391 * Lays out the given View starting from the given {@code top} value downwards and centered 392 * within the given {@code availableWidth}. 393 * 394 * @param view The view to lay out. 395 * @param top The top value to start laying out from. This value will be the resulting top 396 * value of the view. 397 * @param availableWidth The width in which to center the given view. 398 */ layoutViewCenteredFromTop(View view, int top, int availableWidth)399 private void layoutViewCenteredFromTop(View view, int top, int availableWidth) { 400 int viewWidth = view.getMeasuredWidth(); 401 int viewLeft = (availableWidth - viewWidth) / 2; 402 view.layout(viewLeft, top, viewLeft + viewWidth, 403 top + view.getMeasuredHeight()); 404 } 405 406 /** 407 * Lays out the given View starting from the given {@code bottom} value upwards and centered 408 * within the given {@code availableSpace}. 409 * 410 * @param view The view to lay out. 411 * @param bottom The bottom value to start laying out from. This value will be the resulting 412 * bottom value of the view. 413 * @param availableWidth The width in which to center the given view. 414 */ layoutViewCenteredFromBottom(View view, int bottom, int availableWidth)415 private void layoutViewCenteredFromBottom(View view, int bottom, int availableWidth) { 416 int viewWidth = view.getMeasuredWidth(); 417 int viewLeft = (availableWidth - viewWidth) / 2; 418 view.layout(viewLeft, bottom - view.getMeasuredHeight(), 419 viewLeft + viewWidth, bottom); 420 } 421 422 /** Reload the colors for the current {@link DayNightStyle}. */ 423 @SuppressWarnings("deprecation") reloadColors()424 private void reloadColors() { 425 int tintResId; 426 int thumbColorResId; 427 int upDownBackgroundResId; 428 429 switch (mDayNightStyle) { 430 case DayNightStyle.AUTO: 431 tintResId = R.color.car_tint; 432 thumbColorResId = R.color.car_scrollbar_thumb; 433 upDownBackgroundResId = R.drawable.car_button_ripple_background; 434 break; 435 case DayNightStyle.AUTO_INVERSE: 436 tintResId = R.color.car_tint_inverse; 437 thumbColorResId = R.color.car_scrollbar_thumb_inverse; 438 upDownBackgroundResId = R.drawable.car_button_ripple_background_inverse; 439 break; 440 case DayNightStyle.FORCE_NIGHT: 441 case DayNightStyle.ALWAYS_LIGHT: 442 tintResId = R.color.car_tint_light; 443 thumbColorResId = R.color.car_scrollbar_thumb_light; 444 upDownBackgroundResId = R.drawable.car_button_ripple_background_night; 445 break; 446 case DayNightStyle.FORCE_DAY: 447 case DayNightStyle.ALWAYS_DARK: 448 tintResId = R.color.car_tint_dark; 449 thumbColorResId = R.color.car_scrollbar_thumb_dark; 450 upDownBackgroundResId = R.drawable.car_button_ripple_background_day; 451 break; 452 default: 453 throw new IllegalArgumentException("Unknown DayNightStyle: " + mDayNightStyle); 454 } 455 456 if (mUseCustomThumbBackground) { 457 thumbColorResId = mCustomThumbBackgroundResId; 458 } 459 460 setScrollbarThumbColor(thumbColorResId); 461 462 int tint = ContextCompat.getColor(getContext(), tintResId); 463 mUpButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); 464 mUpButton.setBackgroundResource(upDownBackgroundResId); 465 466 mDownButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN); 467 mDownButton.setBackgroundResource(upDownBackgroundResId); 468 469 mAlphaJumpButton.setBackgroundResource(upDownBackgroundResId); 470 } 471 setScrollbarThumbColor(@olorRes int color)472 private void setScrollbarThumbColor(@ColorRes int color) { 473 GradientDrawable background = (GradientDrawable) mScrollThumb.getBackground(); 474 background.setColor(getContext().getColor(color)); 475 } 476 477 @VisibleForTesting getScrollbarThumbColor()478 int getScrollbarThumbColor() { 479 return ((GradientDrawable) mScrollThumb.getBackground()).getColor().getDefaultColor(); 480 } 481 482 /** 483 * Calculates and returns how big the scroll bar thumb should be based on the given range and 484 * extent. 485 * 486 * @param range The total amount of space the scroll bar is allowed to roam over. 487 * @param extent The amount of space that the scroll bar takes up relative to the range. 488 * @return The height of the scroll bar thumb in pixels. 489 */ calculateScrollThumbLength(int range, int extent)490 private int calculateScrollThumbLength(int range, int extent) { 491 // Scale the length by the available space that the thumb can fill. 492 return Math.round(((float) extent / range) * mScrollThumbTrackHeight); 493 } 494 495 /** 496 * Calculates and returns how much the scroll thumb should be offset from the top of where it 497 * has been laid out. 498 * 499 * @param range The total amount of space the scroll bar is allowed to roam over. 500 * @param offset The amount the scroll bar should be offset, expressed in the same units as 501 * the given range. 502 * @param thumbLength The current length of the thumb in pixels. 503 * @return The amount the thumb should be offset in pixels. 504 */ calculateScrollThumbOffset(int range, int offset, int thumbLength)505 private int calculateScrollThumbOffset(int range, int offset, int thumbLength) { 506 // Ensure that if the user has reached the bottom of the list, then the scroll bar is 507 // aligned to the bottom as well. Otherwise, scale the offset appropriately. 508 // This offset will be a value relative to the parent of this scrollbar, so start by where 509 // the top of mScrollThumb is. 510 return mScrollThumb.getTop() + (isDownEnabled() 511 ? Math.round(((float) offset / range) * mScrollThumbTrackHeight) 512 : mScrollThumbTrackHeight - thumbLength); 513 } 514 515 /** Moves the given view to the specified 'y' position. */ moveY(final View view, float newPosition, boolean animate)516 private void moveY(final View view, float newPosition, boolean animate) { 517 final int duration = animate ? 200 : 0; 518 view.animate() 519 .y(newPosition) 520 .setDuration(duration) 521 .setInterpolator(mPaginationInterpolator) 522 .start(); 523 } 524 525 private static class PaginateButtonClickListener implements View.OnClickListener { 526 private final int mPaginateDirection; 527 private PaginationListener mPaginationListener; 528 PaginateButtonClickListener(int paginateDirection)529 PaginateButtonClickListener(int paginateDirection) { 530 mPaginateDirection = paginateDirection; 531 } 532 setPaginationListener(PaginationListener listener)533 public void setPaginationListener(PaginationListener listener) { 534 mPaginationListener = listener; 535 } 536 537 @Override onClick(View v)538 public void onClick(View v) { 539 if (mPaginationListener != null) { 540 mPaginationListener.onPaginate(mPaginateDirection); 541 } 542 } 543 } 544 545 private static class AlphaJumpButtonClickListener implements View.OnClickListener { 546 private PaginationListener mPaginationListener; 547 setPaginationListener(PaginationListener listener)548 public void setPaginationListener(PaginationListener listener) { 549 mPaginationListener = listener; 550 } 551 552 @Override onClick(View v)553 public void onClick(View v) { 554 if (mPaginationListener != null) { 555 mPaginationListener.onAlphaJump(); 556 } 557 } 558 559 } 560 } 561