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