| /* |
| * Copyright (C) 2015 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.leanback.widget; |
| |
| import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.animation.TimeInterpolator; |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.content.res.TypedArray; |
| import android.graphics.Bitmap; |
| import android.graphics.BitmapFactory; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.Matrix; |
| import android.graphics.Paint; |
| import android.graphics.PorterDuff; |
| import android.graphics.PorterDuffColorFilter; |
| import android.graphics.Rect; |
| import android.util.AttributeSet; |
| import android.util.Property; |
| import android.view.View; |
| import android.view.animation.DecelerateInterpolator; |
| |
| import androidx.annotation.ColorInt; |
| import androidx.annotation.RestrictTo; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.leanback.R; |
| |
| /** |
| * A page indicator with dots. |
| * @hide |
| */ |
| @RestrictTo(LIBRARY_GROUP) |
| public class PagingIndicator extends View { |
| private static final long DURATION_ALPHA = 167; |
| private static final long DURATION_DIAMETER = 417; |
| private static final long DURATION_TRANSLATION_X = DURATION_DIAMETER; |
| private static final TimeInterpolator DECELERATE_INTERPOLATOR = new DecelerateInterpolator(); |
| |
| private static final Property<Dot, Float> DOT_ALPHA = |
| new Property<Dot, Float>(Float.class, "alpha") { |
| @Override |
| public Float get(Dot dot) { |
| return dot.getAlpha(); |
| } |
| |
| @Override |
| public void set(Dot dot, Float value) { |
| dot.setAlpha(value); |
| } |
| }; |
| |
| private static final Property<Dot, Float> DOT_DIAMETER = |
| new Property<Dot, Float>(Float.class, "diameter") { |
| @Override |
| public Float get(Dot dot) { |
| return dot.getDiameter(); |
| } |
| |
| @Override |
| public void set(Dot dot, Float value) { |
| dot.setDiameter(value); |
| } |
| }; |
| |
| private static final Property<Dot, Float> DOT_TRANSLATION_X = |
| new Property<Dot, Float>(Float.class, "translation_x") { |
| @Override |
| public Float get(Dot dot) { |
| return dot.getTranslationX(); |
| } |
| |
| @Override |
| public void set(Dot dot, Float value) { |
| dot.setTranslationX(value); |
| } |
| }; |
| |
| // attribute |
| boolean mIsLtr; |
| final int mDotDiameter; |
| final int mDotRadius; |
| private final int mDotGap; |
| final int mArrowDiameter; |
| final int mArrowRadius; |
| private final int mArrowGap; |
| private final int mShadowRadius; |
| private Dot[] mDots; |
| // X position when the dot is selected. |
| private int[] mDotSelectedX; |
| // X position when the dot is located to the left of the selected dot. |
| private int[] mDotSelectedPrevX; |
| // X position when the dot is located to the right of the selected dot. |
| private int[] mDotSelectedNextX; |
| int mDotCenterY; |
| |
| // state |
| private int mPageCount; |
| private int mCurrentPage; |
| private int mPreviousPage; |
| |
| // drawing |
| @ColorInt |
| int mDotFgSelectColor; |
| final Paint mBgPaint; |
| final Paint mFgPaint; |
| private final AnimatorSet mShowAnimator; |
| private final AnimatorSet mHideAnimator; |
| private final AnimatorSet mAnimator = new AnimatorSet(); |
| Bitmap mArrow; |
| Paint mArrowPaint; |
| final Rect mArrowRect; |
| final float mArrowToBgRatio; |
| |
| public PagingIndicator(Context context) { |
| this(context, null, 0); |
| } |
| |
| public PagingIndicator(Context context, AttributeSet attrs) { |
| this(context, attrs, 0); |
| } |
| |
| public PagingIndicator(Context context, AttributeSet attrs, int defStyle) { |
| super(context, attrs, defStyle); |
| Resources res = getResources(); |
| TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.PagingIndicator, |
| defStyle, 0); |
| mDotRadius = getDimensionFromTypedArray(typedArray, R.styleable.PagingIndicator_lbDotRadius, |
| R.dimen.lb_page_indicator_dot_radius); |
| mDotDiameter = mDotRadius * 2; |
| mArrowRadius = getDimensionFromTypedArray(typedArray, |
| R.styleable.PagingIndicator_arrowRadius, R.dimen.lb_page_indicator_arrow_radius); |
| mArrowDiameter = mArrowRadius * 2; |
| mDotGap = getDimensionFromTypedArray(typedArray, R.styleable.PagingIndicator_dotToDotGap, |
| R.dimen.lb_page_indicator_dot_gap); |
| mArrowGap = getDimensionFromTypedArray(typedArray, |
| R.styleable.PagingIndicator_dotToArrowGap, R.dimen.lb_page_indicator_arrow_gap); |
| |
| int dotBgColor = getColorFromTypedArray(typedArray, R.styleable.PagingIndicator_dotBgColor, |
| R.color.lb_page_indicator_dot); |
| mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| mBgPaint.setColor(dotBgColor); |
| mDotFgSelectColor = getColorFromTypedArray(typedArray, |
| R.styleable.PagingIndicator_arrowBgColor, |
| R.color.lb_page_indicator_arrow_background); |
| if (mArrowPaint == null && typedArray.hasValue(R.styleable.PagingIndicator_arrowColor)) { |
| setArrowColor(typedArray.getColor(R.styleable.PagingIndicator_arrowColor, 0)); |
| } |
| typedArray.recycle(); |
| |
| mIsLtr = res.getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR; |
| int shadowColor = res.getColor(R.color.lb_page_indicator_arrow_shadow); |
| mShadowRadius = res.getDimensionPixelSize(R.dimen.lb_page_indicator_arrow_shadow_radius); |
| mFgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); |
| int shadowOffset = res.getDimensionPixelSize(R.dimen.lb_page_indicator_arrow_shadow_offset); |
| mFgPaint.setShadowLayer(mShadowRadius, shadowOffset, shadowOffset, shadowColor); |
| mArrow = loadArrow(); |
| mArrowRect = new Rect(0, 0, mArrow.getWidth(), mArrow.getHeight()); |
| mArrowToBgRatio = (float) mArrow.getWidth() / (float) mArrowDiameter; |
| // Initialize animations. |
| mShowAnimator = new AnimatorSet(); |
| mShowAnimator.playTogether(createDotAlphaAnimator(0.0f, 1.0f), |
| createDotDiameterAnimator(mDotRadius * 2, mArrowRadius * 2), |
| createDotTranslationXAnimator()); |
| mHideAnimator = new AnimatorSet(); |
| mHideAnimator.playTogether(createDotAlphaAnimator(1.0f, 0.0f), |
| createDotDiameterAnimator(mArrowRadius * 2, mDotRadius * 2), |
| createDotTranslationXAnimator()); |
| mAnimator.playTogether(mShowAnimator, mHideAnimator); |
| // Use software layer to show shadows. |
| setLayerType(View.LAYER_TYPE_SOFTWARE, null); |
| } |
| |
| private int getDimensionFromTypedArray(TypedArray typedArray, int attr, int defaultId) { |
| return typedArray.getDimensionPixelOffset(attr, |
| getResources().getDimensionPixelOffset(defaultId)); |
| } |
| |
| private int getColorFromTypedArray(TypedArray typedArray, int attr, int defaultId) { |
| return typedArray.getColor(attr, getResources().getColor(defaultId)); |
| } |
| |
| private Bitmap loadArrow() { |
| Bitmap arrow = BitmapFactory.decodeResource(getResources(), R.drawable.lb_ic_nav_arrow); |
| if (mIsLtr) { |
| return arrow; |
| } else { |
| Matrix matrix = new Matrix(); |
| matrix.preScale(-1, 1); |
| return Bitmap.createBitmap(arrow, 0, 0, arrow.getWidth(), arrow.getHeight(), matrix, |
| false); |
| } |
| } |
| |
| /** |
| * Sets the color of the arrow. This color will take over the value set through the |
| * theme attribute {@link R.styleable#PagingIndicator_arrowColor} if provided. |
| * |
| * @param color the color of the arrow |
| */ |
| public void setArrowColor(@ColorInt int color) { |
| if (mArrowPaint == null) { |
| mArrowPaint = new Paint(); |
| } |
| mArrowPaint.setColorFilter(new PorterDuffColorFilter(color, |
| PorterDuff.Mode.SRC_IN)); |
| } |
| |
| /** |
| * Set the background color of the dot. This color will take over the value set through the |
| * theme attribute. |
| * |
| * @param color the background color of the dot |
| */ |
| public void setDotBackgroundColor(@ColorInt int color) { |
| mBgPaint.setColor(color); |
| } |
| |
| /** |
| * Sets the background color of the arrow. This color will take over the value set through the |
| * theme attribute. |
| * |
| * @param color the background color of the arrow |
| */ |
| public void setArrowBackgroundColor(@ColorInt int color) { |
| mDotFgSelectColor = color; |
| } |
| |
| private Animator createDotAlphaAnimator(float from, float to) { |
| ObjectAnimator animator = ObjectAnimator.ofFloat(null, DOT_ALPHA, from, to); |
| animator.setDuration(DURATION_ALPHA); |
| animator.setInterpolator(DECELERATE_INTERPOLATOR); |
| return animator; |
| } |
| |
| private Animator createDotDiameterAnimator(float from, float to) { |
| ObjectAnimator animator = ObjectAnimator.ofFloat(null, DOT_DIAMETER, from, to); |
| animator.setDuration(DURATION_DIAMETER); |
| animator.setInterpolator(DECELERATE_INTERPOLATOR); |
| return animator; |
| } |
| |
| private Animator createDotTranslationXAnimator() { |
| // The direction is determined in the Dot. |
| ObjectAnimator animator = ObjectAnimator.ofFloat(null, DOT_TRANSLATION_X, |
| -mArrowGap + mDotGap, 0.0f); |
| animator.setDuration(DURATION_TRANSLATION_X); |
| animator.setInterpolator(DECELERATE_INTERPOLATOR); |
| return animator; |
| } |
| |
| /** |
| * Sets the page count. |
| */ |
| public void setPageCount(int pages) { |
| if (pages <= 0) { |
| throw new IllegalArgumentException("The page count should be a positive integer"); |
| } |
| mPageCount = pages; |
| mDots = new Dot[mPageCount]; |
| for (int i = 0; i < mPageCount; ++i) { |
| mDots[i] = new Dot(); |
| } |
| calculateDotPositions(); |
| setSelectedPage(0); |
| } |
| |
| /** |
| * Called when the page has been selected. |
| */ |
| public void onPageSelected(int pageIndex, boolean withAnimation) { |
| if (mCurrentPage == pageIndex) { |
| return; |
| } |
| if (mAnimator.isStarted()) { |
| mAnimator.end(); |
| } |
| mPreviousPage = mCurrentPage; |
| if (withAnimation) { |
| mHideAnimator.setTarget(mDots[mPreviousPage]); |
| mShowAnimator.setTarget(mDots[pageIndex]); |
| mAnimator.start(); |
| } |
| setSelectedPage(pageIndex); |
| } |
| |
| private void calculateDotPositions() { |
| int left = getPaddingLeft(); |
| int top = getPaddingTop(); |
| int right = getWidth() - getPaddingRight(); |
| int requiredWidth = getRequiredWidth(); |
| int mid = (left + right) / 2; |
| mDotSelectedX = new int[mPageCount]; |
| mDotSelectedPrevX = new int[mPageCount]; |
| mDotSelectedNextX = new int[mPageCount]; |
| if (mIsLtr) { |
| int startLeft = mid - requiredWidth / 2; |
| // mDotSelectedX[0] should be mDotSelectedPrevX[-1] + mArrowGap |
| mDotSelectedX[0] = startLeft + mDotRadius - mDotGap + mArrowGap; |
| mDotSelectedPrevX[0] = startLeft + mDotRadius; |
| mDotSelectedNextX[0] = startLeft + mDotRadius - 2 * mDotGap + 2 * mArrowGap; |
| for (int i = 1; i < mPageCount; i++) { |
| mDotSelectedX[i] = mDotSelectedPrevX[i - 1] + mArrowGap; |
| mDotSelectedPrevX[i] = mDotSelectedPrevX[i - 1] + mDotGap; |
| mDotSelectedNextX[i] = mDotSelectedX[i - 1] + mArrowGap; |
| } |
| } else { |
| int startRight = mid + requiredWidth / 2; |
| // mDotSelectedX[0] should be mDotSelectedPrevX[-1] - mArrowGap |
| mDotSelectedX[0] = startRight - mDotRadius + mDotGap - mArrowGap; |
| mDotSelectedPrevX[0] = startRight - mDotRadius; |
| mDotSelectedNextX[0] = startRight - mDotRadius + 2 * mDotGap - 2 * mArrowGap; |
| for (int i = 1; i < mPageCount; i++) { |
| mDotSelectedX[i] = mDotSelectedPrevX[i - 1] - mArrowGap; |
| mDotSelectedPrevX[i] = mDotSelectedPrevX[i - 1] - mDotGap; |
| mDotSelectedNextX[i] = mDotSelectedX[i - 1] - mArrowGap; |
| } |
| } |
| mDotCenterY = top + mArrowRadius; |
| adjustDotPosition(); |
| } |
| |
| @VisibleForTesting |
| int getPageCount() { |
| return mPageCount; |
| } |
| |
| @VisibleForTesting |
| int[] getDotSelectedX() { |
| return mDotSelectedX; |
| } |
| |
| @VisibleForTesting |
| int[] getDotSelectedLeftX() { |
| return mDotSelectedPrevX; |
| } |
| |
| @VisibleForTesting |
| int[] getDotSelectedRightX() { |
| return mDotSelectedNextX; |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| int desiredHeight = getDesiredHeight(); |
| int height; |
| switch (MeasureSpec.getMode(heightMeasureSpec)) { |
| case MeasureSpec.EXACTLY: |
| height = MeasureSpec.getSize(heightMeasureSpec); |
| break; |
| case MeasureSpec.AT_MOST: |
| height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); |
| break; |
| case MeasureSpec.UNSPECIFIED: |
| default: |
| height = desiredHeight; |
| break; |
| } |
| int desiredWidth = getDesiredWidth(); |
| int width; |
| switch (MeasureSpec.getMode(widthMeasureSpec)) { |
| case MeasureSpec.EXACTLY: |
| width = MeasureSpec.getSize(widthMeasureSpec); |
| break; |
| case MeasureSpec.AT_MOST: |
| width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec)); |
| break; |
| case MeasureSpec.UNSPECIFIED: |
| default: |
| width = desiredWidth; |
| break; |
| } |
| setMeasuredDimension(width, height); |
| } |
| |
| @Override |
| protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { |
| setMeasuredDimension(width, height); |
| calculateDotPositions(); |
| } |
| |
| private int getDesiredHeight() { |
| return getPaddingTop() + mArrowDiameter + getPaddingBottom() + mShadowRadius; |
| } |
| |
| private int getRequiredWidth() { |
| return 2 * mDotRadius + 2 * mArrowGap + (mPageCount - 3) * mDotGap; |
| } |
| |
| private int getDesiredWidth() { |
| return getPaddingLeft() + getRequiredWidth() + getPaddingRight(); |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| for (int i = 0; i < mPageCount; ++i) { |
| mDots[i].draw(canvas); |
| } |
| } |
| |
| private void setSelectedPage(int now) { |
| if (now == mCurrentPage) { |
| return; |
| } |
| |
| mCurrentPage = now; |
| adjustDotPosition(); |
| } |
| |
| private void adjustDotPosition() { |
| for (int i = 0; i < mCurrentPage; ++i) { |
| mDots[i].deselect(); |
| mDots[i].mDirection = i == mPreviousPage ? Dot.LEFT : Dot.RIGHT; |
| mDots[i].mCenterX = mDotSelectedPrevX[i]; |
| } |
| mDots[mCurrentPage].select(); |
| mDots[mCurrentPage].mDirection = mPreviousPage < mCurrentPage ? Dot.LEFT : Dot.RIGHT; |
| mDots[mCurrentPage].mCenterX = mDotSelectedX[mCurrentPage]; |
| for (int i = mCurrentPage + 1; i < mPageCount; ++i) { |
| mDots[i].deselect(); |
| mDots[i].mDirection = Dot.RIGHT; |
| mDots[i].mCenterX = mDotSelectedNextX[i]; |
| } |
| } |
| |
| @Override |
| public void onRtlPropertiesChanged(int layoutDirection) { |
| super.onRtlPropertiesChanged(layoutDirection); |
| boolean isLtr = layoutDirection == View.LAYOUT_DIRECTION_LTR; |
| if (mIsLtr != isLtr) { |
| mIsLtr = isLtr; |
| mArrow = loadArrow(); |
| if (mDots != null) { |
| for (Dot dot : mDots) { |
| dot.onRtlPropertiesChanged(); |
| } |
| } |
| calculateDotPositions(); |
| invalidate(); |
| } |
| } |
| |
| public class Dot { |
| static final float LEFT = -1; |
| static final float RIGHT = 1; |
| static final float LTR = 1; |
| static final float RTL = -1; |
| |
| float mAlpha; |
| @ColorInt |
| int mFgColor; |
| float mTranslationX; |
| float mCenterX; |
| float mDiameter; |
| float mRadius; |
| float mArrowImageRadius; |
| float mDirection = RIGHT; |
| float mLayoutDirection = mIsLtr ? LTR : RTL; |
| |
| void select() { |
| mTranslationX = 0.0f; |
| mCenterX = 0.0f; |
| mDiameter = mArrowDiameter; |
| mRadius = mArrowRadius; |
| mArrowImageRadius = mRadius * mArrowToBgRatio; |
| mAlpha = 1.0f; |
| adjustAlpha(); |
| } |
| |
| void deselect() { |
| mTranslationX = 0.0f; |
| mCenterX = 0.0f; |
| mDiameter = mDotDiameter; |
| mRadius = mDotRadius; |
| mArrowImageRadius = mRadius * mArrowToBgRatio; |
| mAlpha = 0.0f; |
| adjustAlpha(); |
| } |
| |
| public void adjustAlpha() { |
| int alpha = Math.round(0xFF * mAlpha); |
| int red = Color.red(mDotFgSelectColor); |
| int green = Color.green(mDotFgSelectColor); |
| int blue = Color.blue(mDotFgSelectColor); |
| mFgColor = Color.argb(alpha, red, green, blue); |
| } |
| |
| public float getAlpha() { |
| return mAlpha; |
| } |
| |
| public void setAlpha(float alpha) { |
| this.mAlpha = alpha; |
| adjustAlpha(); |
| invalidate(); |
| } |
| |
| public float getTranslationX() { |
| return mTranslationX; |
| } |
| |
| public void setTranslationX(float translationX) { |
| this.mTranslationX = translationX * mDirection * mLayoutDirection; |
| invalidate(); |
| } |
| |
| public float getDiameter() { |
| return mDiameter; |
| } |
| |
| public void setDiameter(float diameter) { |
| this.mDiameter = diameter; |
| this.mRadius = diameter / 2; |
| this.mArrowImageRadius = diameter / 2 * mArrowToBgRatio; |
| invalidate(); |
| } |
| |
| void draw(Canvas canvas) { |
| float centerX = mCenterX + mTranslationX; |
| canvas.drawCircle(centerX, mDotCenterY, mRadius, mBgPaint); |
| if (mAlpha > 0) { |
| mFgPaint.setColor(mFgColor); |
| canvas.drawCircle(centerX, mDotCenterY, mRadius, mFgPaint); |
| canvas.drawBitmap(mArrow, mArrowRect, new Rect((int) (centerX - mArrowImageRadius), |
| (int) (mDotCenterY - mArrowImageRadius), |
| (int) (centerX + mArrowImageRadius), |
| (int) (mDotCenterY + mArrowImageRadius)), mArrowPaint); |
| } |
| } |
| |
| void onRtlPropertiesChanged() { |
| mLayoutDirection = mIsLtr ? LTR : RTL; |
| } |
| } |
| } |