PagedScrollBarView.java

/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.car.widget;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.ColorRes;
import androidx.annotation.IntRange;
import androidx.annotation.VisibleForTesting;
import androidx.car.R;
import androidx.core.content.ContextCompat;

/** A custom view to provide list scroll behaviour -- up/down buttons and scroll indicator. */
public class PagedScrollBarView extends ViewGroup {
    private static final float BUTTON_DISABLED_ALPHA = 0.2f;

    @DayNightStyle private int mDayNightStyle;

    /** Listener for when the list should paginate. */
    public interface PaginationListener {
        int PAGE_UP = 0;
        int PAGE_DOWN = 1;

        /** Called when the linked view should be paged in the given direction */
        void onPaginate(int direction);

        /**
         * Called when the 'alpha jump' button is clicked and the linked view should switch into
         * alpha jump mode, where we display a list of buttons to allow the user to quickly scroll
         * to a certain point in the list, bypassing a lot of manual scrolling.
         */
        void onAlphaJump();
    }

    private final ImageView mUpButton;
    private final PaginateButtonClickListener mUpButtonClickListener;
    private final ImageView mDownButton;
    private final PaginateButtonClickListener mDownButtonClickListener;
    private final TextView mAlphaJumpButton;
    private final AlphaJumpButtonClickListener mAlphaJumpButtonClickListener;
    private final View mScrollThumb;

    private final int mSeparatingMargin;
    private final int mScrollBarThumbWidth;

    /** The amount of space that the scroll thumb is allowed to roam over. */
    private int mScrollThumbTrackHeight;

    private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator();
    private boolean mUseCustomThumbBackground;
    @ColorRes private int mCustomThumbBackgroundResId;

    public PagedScrollBarView(Context context) {
        super(context);
    }

    public PagedScrollBarView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public PagedScrollBarView(Context context, AttributeSet attrs, int defStyleAttrs) {
        super(context, attrs, defStyleAttrs);
    }

    public PagedScrollBarView(
            Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
        super(context, attrs, defStyleAttrs, defStyleRes);
    }

    // Using an initialization block so that the fields referenced in this block can be marked
    // as "final". This block will run after the super() call in constructors.
    {
        Resources res = getResources();
        mSeparatingMargin = res.getDimensionPixelSize(R.dimen.car_padding_2);
        mScrollBarThumbWidth = res.getDimensionPixelSize(R.dimen.car_scroll_bar_thumb_width);

        LayoutInflater inflater =
                (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.car_paged_scrollbar_buttons, this /* root */,
                true /* attachToRoot */);

        mUpButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_UP);
        mDownButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_DOWN);
        mAlphaJumpButtonClickListener = new AlphaJumpButtonClickListener();

        mUpButton = findViewById(R.id.page_up);
        mUpButton.setOnClickListener(mUpButtonClickListener);
        mDownButton = findViewById(R.id.page_down);
        mDownButton.setOnClickListener(mDownButtonClickListener);
        mAlphaJumpButton = findViewById(R.id.alpha_jump);
        mAlphaJumpButton.setOnClickListener(mAlphaJumpButtonClickListener);

        mScrollThumb = findViewById(R.id.scrollbar_thumb);
    }

    /** Sets the icon to be used for the up button. */
    public void setUpButtonIcon(Drawable icon) {
        mUpButton.setImageDrawable(icon);
    }

    /** Sets the icon to be used for the down button. */
    public void setDownButtonIcon(Drawable icon) {
        mDownButton.setImageDrawable(icon);
    }

    /**
     * Sets the listener that will be notified when the up and down buttons have been pressed.
     *
     * @param listener The listener to set.
     */
    public void setPaginationListener(PaginationListener listener) {
        mUpButtonClickListener.setPaginationListener(listener);
        mDownButtonClickListener.setPaginationListener(listener);
        mAlphaJumpButtonClickListener.setPaginationListener(listener);
    }

    /** Returns {@code true} if the "up" button is pressed */
    public boolean isUpPressed() {
        return mUpButton.isPressed();
    }

    /** Returns {@code true} if the "down" button is pressed */
    public boolean isDownPressed() {
        return mDownButton.isPressed();
    }

    void setShowAlphaJump(boolean show) {
        mAlphaJumpButton.setVisibility(show ? View.VISIBLE : View.GONE);
    }

    /**
     * Sets the range, offset and extent of the scroll bar. The range represents the size of a
     * container for the scrollbar thumb; offset is the distance from the start of the container
     * to where the thumb should be; and finally, extent is the size of the thumb.
     *
     * <p>These values can be expressed in arbitrary units, so long as they share the same units.
     * The values should also be positive.
     *
     * @param range The range of the scrollbar's thumb
     * @param offset The offset of the scrollbar's thumb
     * @param extent The extent of the scrollbar's thumb
     * @param animate Whether or not the thumb should animate from its current position to the
     *                position specified by the given range, offset and extent.
     *
     * @see View#computeVerticalScrollRange()
     * @see View#computeVerticalScrollOffset()
     * @see View#computeVerticalScrollExtent()
     */
    public void setParameters(
            @IntRange(from = 0) int range,
            @IntRange(from = 0) int offset,
            @IntRange(from = 0) int extent, boolean animate) {
        // Not laid out yet, so values cannot be calculated.
        if (!isLaidOut()) {
            return;
        }

        // If the scroll bars aren't visible, then no need to update.
        if (getVisibility() == View.GONE || range == 0) {
            return;
        }

        int thumbLength = calculateScrollThumbLength(range, extent);
        int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength);

        // Sets the size of the thumb and request a redraw if needed.
        ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams();

        if (lp.height != thumbLength) {
            lp.height = thumbLength;
            mScrollThumb.requestLayout();
        }

        moveY(mScrollThumb, thumbOffset, animate);
    }

    /**
     * An optimized version of {@link #setParameters(int, int, int, boolean)} that is meant to be
     * called if a view is laying itself out. This method will avoid a complete remeasure of
     * the views in the {@code PagedScrollBarView} if the scroll thumb's height needs to be changed.
     * Instead, only the thumb itself will be remeasured and laid out.
     *
     * <p>These values can be expressed in arbitrary units, so long as they share the same units.
     *
     * @param range The range of the scrollbar's thumb
     * @param offset The offset of the scrollbar's thumb
     * @param extent The extent of the scrollbar's thumb
     *
     * @see #setParameters(int, int, int, boolean)
     */
    void setParametersInLayout(int range, int offset, int extent) {
        // If the scroll bars aren't visible, then no need to update.
        if (getVisibility() == View.GONE || range == 0) {
            return;
        }

        int thumbLength = calculateScrollThumbLength(range, extent);
        int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength);

        // Sets the size of the thumb and request a redraw if needed.
        ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams();

        if (lp.height != thumbLength) {
            lp.height = thumbLength;
            measureAndLayoutScrollThumb();
        }

        mScrollThumb.setY(thumbOffset);
    }

    /**
     * Sets how this {@link PagedScrollBarView} responds to day/night configuration changes. By
     * default, the PagedScrollBarView is darker in the day and lighter at night.
     *
     * @param dayNightStyle A value from {@link DayNightStyle}.
     * @see DayNightStyle
     */
    public void setDayNightStyle(@DayNightStyle int dayNightStyle) {
        mDayNightStyle = dayNightStyle;
        reloadColors();
    }

    /**
     * Sets whether or not the up button on the scroll bar is clickable.
     *
     * @param enabled {@code true} if the up button is enabled.
     */
    public void setUpEnabled(boolean enabled) {
        mUpButton.setEnabled(enabled);
        mUpButton.setAlpha(enabled ? 1f : BUTTON_DISABLED_ALPHA);
    }

    /**
     * Sets whether or not the down button on the scroll bar is clickable.
     *
     * @param enabled {@code true} if the down button is enabled.
     */
    public void setDownEnabled(boolean enabled) {
        mDownButton.setEnabled(enabled);
        mDownButton.setAlpha(enabled ? 1f : BUTTON_DISABLED_ALPHA);
    }

    /**
     * Returns whether or not the down button on the scroll bar is clickable.
     *
     * @return {@code true} if the down button is enabled. {@code false} otherwise.
     */
    public boolean isDownEnabled() {
        return mDownButton.isEnabled();
    }

    /**
     * Sets the color of thumb.
     *
     * <p>Custom thumb color ignores {@link DayNightStyle}. Calling {@link #resetThumbColor} resets
     * to default color.
     *
     * @param color Resource identifier of the color.
     */
    public void setThumbColor(@ColorRes int color) {
        mUseCustomThumbBackground = true;
        mCustomThumbBackgroundResId = color;
        reloadColors();
    }

    /**
     * Resets the color of thumb to default.
     */
    public void resetThumbColor() {
        mUseCustomThumbBackground = false;
        reloadColors();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int requestedWidth = MeasureSpec.getSize(widthMeasureSpec);
        int requestedHeight = MeasureSpec.getSize(heightMeasureSpec);

        int wrapMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);

        mUpButton.measure(wrapMeasureSpec, wrapMeasureSpec);
        mDownButton.measure(wrapMeasureSpec, wrapMeasureSpec);

        measureScrollThumb();

        if (mAlphaJumpButton.getVisibility() != GONE) {
            mAlphaJumpButton.measure(wrapMeasureSpec, wrapMeasureSpec);
        }

        setMeasuredDimension(requestedWidth, requestedHeight);
    }

    @Override
    public void onLayout(boolean changed, int left, int top, int right, int bottom) {
        int width = right - left;
        int height = bottom - top;

        // This value will keep track of the top of the current view being laid out.
        int layoutTop = getPaddingTop();

        // Lay out the up button at the top of the view.
        layoutViewCenteredFromTop(mUpButton, layoutTop, width);
        layoutTop = mUpButton.getBottom();

        // Lay out the alpha jump button if it exists. This button goes below the up button.
        if (mAlphaJumpButton.getVisibility() != GONE) {
            layoutTop += mSeparatingMargin;

            layoutViewCenteredFromTop(mAlphaJumpButton, layoutTop, width);

            layoutTop = mAlphaJumpButton.getBottom();
        }

        // Lay out the scroll thumb
        layoutTop += mSeparatingMargin;
        layoutViewCenteredFromTop(mScrollThumb, layoutTop, width);

        // Lay out the bottom button at the bottom of the view.
        int downBottom = height - getPaddingBottom();
        layoutViewCenteredFromBottom(mDownButton, downBottom, width);

        calculateScrollThumbTrackHeight();
    }

    /**
     * Calculate the amount of space that the scroll bar thumb is allowed to roam. The thumb
     * is allowed to take up the space between the down bottom and the up or alpha jump
     * button, depending on if the latter is visible.
     */
    private void calculateScrollThumbTrackHeight() {
        // Subtracting (2 * mSeparatingMargin) for the top/bottom margin above and below the
        // scroll bar thumb.
        mScrollThumbTrackHeight = mDownButton.getTop() - (2 * mSeparatingMargin);

        // If there's an alpha jump button, then the thumb is laid out starting from below that.
        if (mAlphaJumpButton.getVisibility() != GONE) {
            mScrollThumbTrackHeight -= mAlphaJumpButton.getBottom();
        } else {
            mScrollThumbTrackHeight -= mUpButton.getBottom();
        }
    }

    private void measureScrollThumb() {
        int scrollWidth = MeasureSpec.makeMeasureSpec(mScrollBarThumbWidth, MeasureSpec.EXACTLY);
        int scrollHeight = MeasureSpec.makeMeasureSpec(
                mScrollThumb.getLayoutParams().height,
                MeasureSpec.EXACTLY);
        mScrollThumb.measure(scrollWidth, scrollHeight);
    }

    /**
     * An optimization method to only remeasure and lay out the scroll thumb. This method should be
     * used when the height of the thumb has changed, but no other views need to be remeasured.
     */
    private void measureAndLayoutScrollThumb() {
        measureScrollThumb();

        // The top value should not change from what it was before; only the height is assumed to
        // be changing.
        int layoutTop = mScrollThumb.getTop();
        layoutViewCenteredFromTop(mScrollThumb, layoutTop, getMeasuredWidth());
    }

    /**
     * Lays out the given View starting from the given {@code top} value downwards and centered
     * within the given {@code availableWidth}.
     *
     * @param  view The view to lay out.
     * @param  top The top value to start laying out from. This value will be the resulting top
     *             value of the view.
     * @param  availableWidth The width in which to center the given view.
     */
    private void layoutViewCenteredFromTop(View view, int top, int availableWidth) {
        int viewWidth = view.getMeasuredWidth();
        int viewLeft = (availableWidth - viewWidth) / 2;
        view.layout(viewLeft, top, viewLeft + viewWidth,
                top + view.getMeasuredHeight());
    }

    /**
     * Lays out the given View starting from the given {@code bottom} value upwards and centered
     * within the given {@code availableSpace}.
     *
     * @param  view The view to lay out.
     * @param  bottom The bottom value to start laying out from. This value will be the resulting
     *                bottom value of the view.
     * @param  availableWidth The width in which to center the given view.
     */
    private void layoutViewCenteredFromBottom(View view, int bottom, int availableWidth) {
        int viewWidth = view.getMeasuredWidth();
        int viewLeft = (availableWidth - viewWidth) / 2;
        view.layout(viewLeft, bottom - view.getMeasuredHeight(),
                viewLeft + viewWidth, bottom);
    }

    /** Reload the colors for the current {@link DayNightStyle}. */
    @SuppressWarnings("deprecation")
    private void reloadColors() {
        int tintResId;
        int thumbColorResId;
        int upDownBackgroundResId;

        switch (mDayNightStyle) {
            case DayNightStyle.AUTO:
                tintResId = R.color.car_tint;
                thumbColorResId = R.color.car_scrollbar_thumb;
                upDownBackgroundResId = R.drawable.car_button_ripple_background;
                break;
            case DayNightStyle.AUTO_INVERSE:
                tintResId = R.color.car_tint_inverse;
                thumbColorResId = R.color.car_scrollbar_thumb_inverse;
                upDownBackgroundResId = R.drawable.car_button_ripple_background_inverse;
                break;
            case DayNightStyle.ALWAYS_LIGHT:
                tintResId = R.color.car_tint_light;
                thumbColorResId = R.color.car_scrollbar_thumb_light;
                upDownBackgroundResId = R.drawable.car_button_ripple_background_night;
                break;
            case DayNightStyle.ALWAYS_DARK:
                tintResId = R.color.car_tint_dark;
                thumbColorResId = R.color.car_scrollbar_thumb_dark;
                upDownBackgroundResId = R.drawable.car_button_ripple_background_day;
                break;
            default:
                throw new IllegalArgumentException("Unknown DayNightStyle: " + mDayNightStyle);
        }

        if (mUseCustomThumbBackground) {
            thumbColorResId = mCustomThumbBackgroundResId;
        }

        setScrollbarThumbColor(thumbColorResId);

        int tint = ContextCompat.getColor(getContext(), tintResId);
        mUpButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN);
        mUpButton.setBackgroundResource(upDownBackgroundResId);

        mDownButton.setColorFilter(tint, PorterDuff.Mode.SRC_IN);
        mDownButton.setBackgroundResource(upDownBackgroundResId);

        mAlphaJumpButton.setBackgroundResource(upDownBackgroundResId);
    }

    private void setScrollbarThumbColor(@ColorRes int color) {
        GradientDrawable background = (GradientDrawable) mScrollThumb.getBackground();
        background.setColor(getContext().getColor(color));
    }

    @VisibleForTesting
    int getScrollbarThumbColor() {
        return ((GradientDrawable) mScrollThumb.getBackground()).getColor().getDefaultColor();
    }

    /**
     * Calculates and returns how big the scroll bar thumb should be based on the given range and
     * extent.
     *
     * @param range The total amount of space the scroll bar is allowed to roam over.
     * @param extent The amount of space that the scroll bar takes up relative to the range.
     * @return The height of the scroll bar thumb in pixels.
     */
    private int calculateScrollThumbLength(int range, int extent) {
        // Scale the length by the available space that the thumb can fill.
        return Math.round(((float) extent / range) * mScrollThumbTrackHeight);
    }

    /**
     * Calculates and returns how much the scroll thumb should be offset from the top of where it
     * has been laid out.
     *
     * @param  range The total amount of space the scroll bar is allowed to roam over.
     * @param  offset The amount the scroll bar should be offset, expressed in the same units as
     *                the given range.
     * @param  thumbLength The current length of the thumb in pixels.
     * @return The amount the thumb should be offset in pixels.
     */
    private int calculateScrollThumbOffset(int range, int offset, int thumbLength) {
        // Ensure that if the user has reached the bottom of the list, then the scroll bar is
        // aligned to the bottom as well. Otherwise, scale the offset appropriately.
        // This offset will be a value relative to the parent of this scrollbar, so start by where
        // the top of mScrollThumb is.
        return mScrollThumb.getTop() + (isDownEnabled()
                ? Math.round(((float) offset / range) * mScrollThumbTrackHeight)
                : mScrollThumbTrackHeight - thumbLength);
    }

    /** Moves the given view to the specified 'y' position. */
    private void moveY(final View view, float newPosition, boolean animate) {
        final int duration = animate ? 200 : 0;
        view.animate()
                .y(newPosition)
                .setDuration(duration)
                .setInterpolator(mPaginationInterpolator)
                .start();
    }

    private static class PaginateButtonClickListener implements View.OnClickListener {
        private final int mPaginateDirection;
        private PaginationListener mPaginationListener;

        PaginateButtonClickListener(int paginateDirection) {
            mPaginateDirection = paginateDirection;
        }

        public void setPaginationListener(PaginationListener listener) {
            mPaginationListener = listener;
        }

        @Override
        public void onClick(View v) {
            if (mPaginationListener != null) {
                mPaginationListener.onPaginate(mPaginateDirection);
            }
        }
    }

    private static class AlphaJumpButtonClickListener implements View.OnClickListener {
        private PaginationListener mPaginationListener;

        AlphaJumpButtonClickListener() {
        }

        public void setPaginationListener(PaginationListener listener) {
            mPaginationListener = listener;
        }

        @Override
        public void onClick(View v) {
            if (mPaginationListener != null) {
                mPaginationListener.onAlphaJump();
            }
        }

    }
}