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