1/*
2 * Copyright (C) 2015 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 android.support.v17.leanback.widget;
18
19import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
20
21import android.animation.Animator;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.animation.TimeInterpolator;
25import android.content.Context;
26import android.content.res.Resources;
27import android.content.res.TypedArray;
28import android.graphics.Bitmap;
29import android.graphics.BitmapFactory;
30import android.graphics.Canvas;
31import android.graphics.Color;
32import android.graphics.Matrix;
33import android.graphics.Paint;
34import android.graphics.PorterDuff;
35import android.graphics.PorterDuffColorFilter;
36import android.graphics.Rect;
37import android.support.annotation.ColorInt;
38import android.support.annotation.RestrictTo;
39import android.support.annotation.VisibleForTesting;
40import android.support.v17.leanback.R;
41import android.util.AttributeSet;
42import android.util.Property;
43import android.view.View;
44import android.view.animation.DecelerateInterpolator;
45
46/**
47 * A page indicator with dots.
48 * @hide
49 */
50@RestrictTo(LIBRARY_GROUP)
51public class PagingIndicator extends View {
52    private static final long DURATION_ALPHA = 167;
53    private static final long DURATION_DIAMETER = 417;
54    private static final long DURATION_TRANSLATION_X = DURATION_DIAMETER;
55    private static final TimeInterpolator DECELERATE_INTERPOLATOR = new DecelerateInterpolator();
56
57    private static final Property<Dot, Float> DOT_ALPHA =
58            new Property<Dot, Float>(Float.class, "alpha") {
59        @Override
60        public Float get(Dot dot) {
61            return dot.getAlpha();
62        }
63
64        @Override
65        public void set(Dot dot, Float value) {
66            dot.setAlpha(value);
67        }
68    };
69
70    private static final Property<Dot, Float> DOT_DIAMETER =
71            new Property<Dot, Float>(Float.class, "diameter") {
72        @Override
73        public Float get(Dot dot) {
74            return dot.getDiameter();
75        }
76
77        @Override
78        public void set(Dot dot, Float value) {
79            dot.setDiameter(value);
80        }
81    };
82
83    private static final Property<Dot, Float> DOT_TRANSLATION_X =
84            new Property<Dot, Float>(Float.class, "translation_x") {
85        @Override
86        public Float get(Dot dot) {
87            return dot.getTranslationX();
88        }
89
90        @Override
91        public void set(Dot dot, Float value) {
92            dot.setTranslationX(value);
93        }
94    };
95
96    // attribute
97    boolean mIsLtr;
98    final int mDotDiameter;
99    final int mDotRadius;
100    private final int mDotGap;
101    final int mArrowDiameter;
102    final int mArrowRadius;
103    private final int mArrowGap;
104    private final int mShadowRadius;
105    private Dot[] mDots;
106    // X position when the dot is selected.
107    private int[] mDotSelectedX;
108    // X position when the dot is located to the left of the selected dot.
109    private int[] mDotSelectedPrevX;
110    // X position when the dot is located to the right of the selected dot.
111    private int[] mDotSelectedNextX;
112    int mDotCenterY;
113
114    // state
115    private int mPageCount;
116    private int mCurrentPage;
117    private int mPreviousPage;
118
119    // drawing
120    @ColorInt
121    int mDotFgSelectColor;
122    final Paint mBgPaint;
123    final Paint mFgPaint;
124    private final AnimatorSet mShowAnimator;
125    private final AnimatorSet mHideAnimator;
126    private final AnimatorSet mAnimator = new AnimatorSet();
127    Bitmap mArrow;
128    Paint mArrowPaint;
129    final Rect mArrowRect;
130    final float mArrowToBgRatio;
131
132    public PagingIndicator(Context context) {
133        this(context, null, 0);
134    }
135
136    public PagingIndicator(Context context, AttributeSet attrs) {
137        this(context, attrs, 0);
138    }
139
140    public PagingIndicator(Context context, AttributeSet attrs, int defStyle) {
141        super(context, attrs, defStyle);
142        Resources res = getResources();
143        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.PagingIndicator,
144                defStyle, 0);
145        mDotRadius = getDimensionFromTypedArray(typedArray, R.styleable.PagingIndicator_lbDotRadius,
146                R.dimen.lb_page_indicator_dot_radius);
147        mDotDiameter = mDotRadius * 2;
148        mArrowRadius = getDimensionFromTypedArray(typedArray,
149                R.styleable.PagingIndicator_arrowRadius, R.dimen.lb_page_indicator_arrow_radius);
150        mArrowDiameter = mArrowRadius * 2;
151        mDotGap = getDimensionFromTypedArray(typedArray, R.styleable.PagingIndicator_dotToDotGap,
152                R.dimen.lb_page_indicator_dot_gap);
153        mArrowGap = getDimensionFromTypedArray(typedArray,
154                R.styleable.PagingIndicator_dotToArrowGap, R.dimen.lb_page_indicator_arrow_gap);
155
156        int dotBgColor = getColorFromTypedArray(typedArray, R.styleable.PagingIndicator_dotBgColor,
157                R.color.lb_page_indicator_dot);
158        mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
159        mBgPaint.setColor(dotBgColor);
160        mDotFgSelectColor = getColorFromTypedArray(typedArray,
161                R.styleable.PagingIndicator_arrowBgColor,
162                R.color.lb_page_indicator_arrow_background);
163        if (mArrowPaint == null && typedArray.hasValue(R.styleable.PagingIndicator_arrowColor)) {
164            setArrowColor(typedArray.getColor(R.styleable.PagingIndicator_arrowColor, 0));
165        }
166        typedArray.recycle();
167
168        mIsLtr = res.getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR;
169        int shadowColor = res.getColor(R.color.lb_page_indicator_arrow_shadow);
170        mShadowRadius = res.getDimensionPixelSize(R.dimen.lb_page_indicator_arrow_shadow_radius);
171        mFgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
172        int shadowOffset = res.getDimensionPixelSize(R.dimen.lb_page_indicator_arrow_shadow_offset);
173        mFgPaint.setShadowLayer(mShadowRadius, shadowOffset, shadowOffset, shadowColor);
174        mArrow = loadArrow();
175        mArrowRect = new Rect(0, 0, mArrow.getWidth(), mArrow.getHeight());
176        mArrowToBgRatio = (float) mArrow.getWidth() / (float) mArrowDiameter;
177        // Initialize animations.
178        mShowAnimator = new AnimatorSet();
179        mShowAnimator.playTogether(createDotAlphaAnimator(0.0f, 1.0f),
180                createDotDiameterAnimator(mDotRadius * 2, mArrowRadius * 2),
181                createDotTranslationXAnimator());
182        mHideAnimator = new AnimatorSet();
183        mHideAnimator.playTogether(createDotAlphaAnimator(1.0f, 0.0f),
184                createDotDiameterAnimator(mArrowRadius * 2, mDotRadius * 2),
185                createDotTranslationXAnimator());
186        mAnimator.playTogether(mShowAnimator, mHideAnimator);
187        // Use software layer to show shadows.
188        setLayerType(View.LAYER_TYPE_SOFTWARE, null);
189    }
190
191    private int getDimensionFromTypedArray(TypedArray typedArray, int attr, int defaultId) {
192        return typedArray.getDimensionPixelOffset(attr,
193                getResources().getDimensionPixelOffset(defaultId));
194    }
195
196    private int getColorFromTypedArray(TypedArray typedArray, int attr, int defaultId) {
197        return typedArray.getColor(attr, getResources().getColor(defaultId));
198    }
199
200    private Bitmap loadArrow() {
201        Bitmap arrow = BitmapFactory.decodeResource(getResources(), R.drawable.lb_ic_nav_arrow);
202        if (mIsLtr) {
203            return arrow;
204        } else {
205            Matrix matrix = new Matrix();
206            matrix.preScale(-1, 1);
207            return Bitmap.createBitmap(arrow, 0, 0, arrow.getWidth(), arrow.getHeight(), matrix,
208                    false);
209        }
210    }
211
212    /**
213     * Sets the color of the arrow. This color will take over the value set through the
214     * theme attribute {@link R.styleable#PagingIndicator_arrowColor} if provided.
215     *
216     * @param color the color of the arrow
217     */
218    public void setArrowColor(@ColorInt int color) {
219        if (mArrowPaint == null) {
220            mArrowPaint = new Paint();
221        }
222        mArrowPaint.setColorFilter(new PorterDuffColorFilter(color,
223                PorterDuff.Mode.SRC_IN));
224    }
225
226    /**
227     * Set the background color of the dot. This color will take over the value set through the
228     * theme attribute.
229     *
230     * @param color the background color of the dot
231     */
232    public void setDotBackgroundColor(@ColorInt int color) {
233        mBgPaint.setColor(color);
234    }
235
236    /**
237     * Sets the background color of the arrow. This color will take over the value set through the
238     * theme attribute.
239     *
240     * @param color the background color of the arrow
241     */
242    public void setArrowBackgroundColor(@ColorInt int color) {
243        mDotFgSelectColor = color;
244    }
245
246    private Animator createDotAlphaAnimator(float from, float to) {
247        ObjectAnimator animator = ObjectAnimator.ofFloat(null, DOT_ALPHA, from, to);
248        animator.setDuration(DURATION_ALPHA);
249        animator.setInterpolator(DECELERATE_INTERPOLATOR);
250        return animator;
251    }
252
253    private Animator createDotDiameterAnimator(float from, float to) {
254        ObjectAnimator animator = ObjectAnimator.ofFloat(null, DOT_DIAMETER, from, to);
255        animator.setDuration(DURATION_DIAMETER);
256        animator.setInterpolator(DECELERATE_INTERPOLATOR);
257        return animator;
258    }
259
260    private Animator createDotTranslationXAnimator() {
261        // The direction is determined in the Dot.
262        ObjectAnimator animator = ObjectAnimator.ofFloat(null, DOT_TRANSLATION_X,
263                -mArrowGap + mDotGap, 0.0f);
264        animator.setDuration(DURATION_TRANSLATION_X);
265        animator.setInterpolator(DECELERATE_INTERPOLATOR);
266        return animator;
267    }
268
269    /**
270     * Sets the page count.
271     */
272    public void setPageCount(int pages) {
273        if (pages <= 0) {
274            throw new IllegalArgumentException("The page count should be a positive integer");
275        }
276        mPageCount = pages;
277        mDots = new Dot[mPageCount];
278        for (int i = 0; i < mPageCount; ++i) {
279            mDots[i] = new Dot();
280        }
281        calculateDotPositions();
282        setSelectedPage(0);
283    }
284
285    /**
286     * Called when the page has been selected.
287     */
288    public void onPageSelected(int pageIndex, boolean withAnimation) {
289        if (mCurrentPage == pageIndex) {
290            return;
291        }
292        if (mAnimator.isStarted()) {
293            mAnimator.end();
294        }
295        mPreviousPage = mCurrentPage;
296        if (withAnimation) {
297            mHideAnimator.setTarget(mDots[mPreviousPage]);
298            mShowAnimator.setTarget(mDots[pageIndex]);
299            mAnimator.start();
300        }
301        setSelectedPage(pageIndex);
302    }
303
304    private void calculateDotPositions() {
305        int left = getPaddingLeft();
306        int top = getPaddingTop();
307        int right = getWidth() - getPaddingRight();
308        int requiredWidth = getRequiredWidth();
309        int mid = (left + right) / 2;
310        mDotSelectedX = new int[mPageCount];
311        mDotSelectedPrevX = new int[mPageCount];
312        mDotSelectedNextX = new int[mPageCount];
313        if (mIsLtr) {
314            int startLeft = mid - requiredWidth / 2;
315            // mDotSelectedX[0] should be mDotSelectedPrevX[-1] + mArrowGap
316            mDotSelectedX[0] = startLeft + mDotRadius - mDotGap + mArrowGap;
317            mDotSelectedPrevX[0] = startLeft + mDotRadius;
318            mDotSelectedNextX[0] = startLeft + mDotRadius - 2 * mDotGap + 2 * mArrowGap;
319            for (int i = 1; i < mPageCount; i++) {
320                mDotSelectedX[i] = mDotSelectedPrevX[i - 1] + mArrowGap;
321                mDotSelectedPrevX[i] = mDotSelectedPrevX[i - 1] + mDotGap;
322                mDotSelectedNextX[i] = mDotSelectedX[i - 1] + mArrowGap;
323            }
324        } else {
325            int startRight = mid + requiredWidth / 2;
326            // mDotSelectedX[0] should be mDotSelectedPrevX[-1] - mArrowGap
327            mDotSelectedX[0] = startRight - mDotRadius + mDotGap - mArrowGap;
328            mDotSelectedPrevX[0] = startRight - mDotRadius;
329            mDotSelectedNextX[0] = startRight - mDotRadius + 2 * mDotGap - 2 * mArrowGap;
330            for (int i = 1; i < mPageCount; i++) {
331                mDotSelectedX[i] = mDotSelectedPrevX[i - 1] - mArrowGap;
332                mDotSelectedPrevX[i] = mDotSelectedPrevX[i - 1] - mDotGap;
333                mDotSelectedNextX[i] = mDotSelectedX[i - 1] - mArrowGap;
334            }
335        }
336        mDotCenterY = top + mArrowRadius;
337        adjustDotPosition();
338    }
339
340    @VisibleForTesting
341    int getPageCount() {
342        return mPageCount;
343    }
344
345    @VisibleForTesting
346    int[] getDotSelectedX() {
347        return mDotSelectedX;
348    }
349
350    @VisibleForTesting
351    int[] getDotSelectedLeftX() {
352        return mDotSelectedPrevX;
353    }
354
355    @VisibleForTesting
356    int[] getDotSelectedRightX() {
357        return mDotSelectedNextX;
358    }
359
360    @Override
361    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
362        int desiredHeight = getDesiredHeight();
363        int height;
364        switch (MeasureSpec.getMode(heightMeasureSpec)) {
365            case MeasureSpec.EXACTLY:
366                height = MeasureSpec.getSize(heightMeasureSpec);
367                break;
368            case MeasureSpec.AT_MOST:
369                height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec));
370                break;
371            case MeasureSpec.UNSPECIFIED:
372            default:
373                height = desiredHeight;
374                break;
375        }
376        int desiredWidth = getDesiredWidth();
377        int width;
378        switch (MeasureSpec.getMode(widthMeasureSpec)) {
379            case MeasureSpec.EXACTLY:
380                width = MeasureSpec.getSize(widthMeasureSpec);
381                break;
382            case MeasureSpec.AT_MOST:
383                width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec));
384                break;
385            case MeasureSpec.UNSPECIFIED:
386            default:
387                width = desiredWidth;
388                break;
389        }
390        setMeasuredDimension(width, height);
391    }
392
393    @Override
394    protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
395        setMeasuredDimension(width, height);
396        calculateDotPositions();
397    }
398
399    private int getDesiredHeight() {
400        return getPaddingTop() + mArrowDiameter + getPaddingBottom() + mShadowRadius;
401    }
402
403    private int getRequiredWidth() {
404        return 2 * mDotRadius + 2 * mArrowGap + (mPageCount - 3) * mDotGap;
405    }
406
407    private int getDesiredWidth() {
408        return getPaddingLeft() + getRequiredWidth() + getPaddingRight();
409    }
410
411    @Override
412    protected void onDraw(Canvas canvas) {
413        for (int i = 0; i < mPageCount; ++i) {
414            mDots[i].draw(canvas);
415        }
416    }
417
418    private void setSelectedPage(int now) {
419        if (now == mCurrentPage) {
420            return;
421        }
422
423        mCurrentPage = now;
424        adjustDotPosition();
425    }
426
427    private void adjustDotPosition() {
428        for (int i = 0; i < mCurrentPage; ++i) {
429            mDots[i].deselect();
430            mDots[i].mDirection = i == mPreviousPage ? Dot.LEFT : Dot.RIGHT;
431            mDots[i].mCenterX = mDotSelectedPrevX[i];
432        }
433        mDots[mCurrentPage].select();
434        mDots[mCurrentPage].mDirection = mPreviousPage < mCurrentPage ? Dot.LEFT : Dot.RIGHT;
435        mDots[mCurrentPage].mCenterX = mDotSelectedX[mCurrentPage];
436        for (int i = mCurrentPage + 1; i < mPageCount; ++i) {
437            mDots[i].deselect();
438            mDots[i].mDirection = Dot.RIGHT;
439            mDots[i].mCenterX = mDotSelectedNextX[i];
440        }
441    }
442
443    @Override
444    public void onRtlPropertiesChanged(int layoutDirection) {
445        super.onRtlPropertiesChanged(layoutDirection);
446        boolean isLtr = layoutDirection == View.LAYOUT_DIRECTION_LTR;
447        if (mIsLtr != isLtr) {
448            mIsLtr = isLtr;
449            mArrow = loadArrow();
450            if (mDots != null) {
451                for (Dot dot : mDots) {
452                    dot.onRtlPropertiesChanged();
453                }
454            }
455            calculateDotPositions();
456            invalidate();
457        }
458    }
459
460    public class Dot {
461        static final float LEFT = -1;
462        static final float RIGHT = 1;
463        static final float LTR = 1;
464        static final float RTL = -1;
465
466        float mAlpha;
467        @ColorInt
468        int mFgColor;
469        float mTranslationX;
470        float mCenterX;
471        float mDiameter;
472        float mRadius;
473        float mArrowImageRadius;
474        float mDirection = RIGHT;
475        float mLayoutDirection = mIsLtr ? LTR : RTL;
476
477        void select() {
478            mTranslationX = 0.0f;
479            mCenterX = 0.0f;
480            mDiameter = mArrowDiameter;
481            mRadius = mArrowRadius;
482            mArrowImageRadius = mRadius * mArrowToBgRatio;
483            mAlpha = 1.0f;
484            adjustAlpha();
485        }
486
487        void deselect() {
488            mTranslationX = 0.0f;
489            mCenterX = 0.0f;
490            mDiameter = mDotDiameter;
491            mRadius = mDotRadius;
492            mArrowImageRadius = mRadius * mArrowToBgRatio;
493            mAlpha = 0.0f;
494            adjustAlpha();
495        }
496
497        public void adjustAlpha() {
498            int alpha = Math.round(0xFF * mAlpha);
499            int red = Color.red(mDotFgSelectColor);
500            int green = Color.green(mDotFgSelectColor);
501            int blue = Color.blue(mDotFgSelectColor);
502            mFgColor = Color.argb(alpha, red, green, blue);
503        }
504
505        public float getAlpha() {
506            return mAlpha;
507        }
508
509        public void setAlpha(float alpha) {
510            this.mAlpha = alpha;
511            adjustAlpha();
512            invalidate();
513        }
514
515        public float getTranslationX() {
516            return mTranslationX;
517        }
518
519        public void setTranslationX(float translationX) {
520            this.mTranslationX = translationX * mDirection * mLayoutDirection;
521            invalidate();
522        }
523
524        public float getDiameter() {
525            return mDiameter;
526        }
527
528        public void setDiameter(float diameter) {
529            this.mDiameter = diameter;
530            this.mRadius = diameter / 2;
531            this.mArrowImageRadius = diameter / 2 * mArrowToBgRatio;
532            invalidate();
533        }
534
535        void draw(Canvas canvas) {
536            float centerX = mCenterX + mTranslationX;
537            canvas.drawCircle(centerX, mDotCenterY, mRadius, mBgPaint);
538            if (mAlpha > 0) {
539                mFgPaint.setColor(mFgColor);
540                canvas.drawCircle(centerX, mDotCenterY, mRadius, mFgPaint);
541                canvas.drawBitmap(mArrow, mArrowRect, new Rect((int) (centerX - mArrowImageRadius),
542                        (int) (mDotCenterY - mArrowImageRadius),
543                        (int) (centerX + mArrowImageRadius),
544                        (int) (mDotCenterY + mArrowImageRadius)), mArrowPaint);
545            }
546        }
547
548        void onRtlPropertiesChanged() {
549            mLayoutDirection = mIsLtr ? LTR : RTL;
550        }
551    }
552}
553