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
17package androidx.car.widget;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.PorterDuff;
22import android.graphics.drawable.Drawable;
23import android.graphics.drawable.GradientDrawable;
24import android.util.AttributeSet;
25import android.view.LayoutInflater;
26import android.view.View;
27import android.view.ViewGroup;
28import android.view.animation.AccelerateDecelerateInterpolator;
29import android.view.animation.Interpolator;
30import android.widget.ImageView;
31import android.widget.TextView;
32
33import androidx.annotation.ColorRes;
34import androidx.annotation.IntRange;
35import androidx.annotation.VisibleForTesting;
36import androidx.car.R;
37import androidx.core.content.ContextCompat;
38
39/** A custom view to provide list scroll behaviour -- up/down buttons and scroll indicator. */
40public 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 */
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         */
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
79    public PagedScrollBarView(Context context) {
80        super(context);
81    }
82
83    public PagedScrollBarView(Context context, AttributeSet attrs) {
84        super(context, attrs);
85    }
86
87    public PagedScrollBarView(Context context, AttributeSet attrs, int defStyleAttrs) {
88        super(context, attrs, defStyleAttrs);
89    }
90
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);
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. */
123    public void setUpButtonIcon(Drawable icon) {
124        mUpButton.setImageDrawable(icon);
125    }
126
127    /** Sets the icon to be used for the down button. */
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     */
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 */
144    public boolean isUpPressed() {
145        return mUpButton.isPressed();
146    }
147
148    /** Returns {@code true} if the "down" button is pressed */
149    public boolean isDownPressed() {
150        return mDownButton.isPressed();
151    }
152
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     */
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     */
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     */
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     */
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     */
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     */
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     */
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     */
295    public void resetThumbColor() {
296        mUseCustomThumbBackground = false;
297        reloadColors();
298    }
299
300    @Override
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
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     */
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
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     */
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     */
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     */
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")
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
472    private void setScrollbarThumbColor(@ColorRes int color) {
473        GradientDrawable background = (GradientDrawable) mScrollThumb.getBackground();
474        background.setColor(getContext().getColor(color));
475    }
476
477    @VisibleForTesting
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     */
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     */
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. */
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
529        PaginateButtonClickListener(int paginateDirection) {
530            mPaginateDirection = paginateDirection;
531        }
532
533        public void setPaginationListener(PaginationListener listener) {
534            mPaginationListener = listener;
535        }
536
537        @Override
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
548        public void setPaginationListener(PaginationListener listener) {
549            mPaginationListener = listener;
550        }
551
552        @Override
553        public void onClick(View v) {
554            if (mPaginationListener != null) {
555                mPaginationListener.onAlphaJump();
556            }
557        }
558
559    }
560}
561