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 android.support.wear.widget.drawer;
18
19import android.animation.Animator;
20import android.content.Context;
21import android.content.res.TypedArray;
22import android.graphics.Canvas;
23import android.graphics.Color;
24import android.graphics.Paint;
25import android.graphics.Paint.Style;
26import android.graphics.RadialGradient;
27import android.graphics.Shader;
28import android.graphics.Shader.TileMode;
29import android.os.Build;
30import android.support.annotation.RequiresApi;
31import android.support.annotation.RestrictTo;
32import android.support.annotation.RestrictTo.Scope;
33import android.support.v4.view.PagerAdapter;
34import android.support.v4.view.ViewPager;
35import android.support.v4.view.ViewPager.OnPageChangeListener;
36import android.support.wear.R;
37import android.support.wear.widget.SimpleAnimatorListener;
38import android.util.AttributeSet;
39import android.view.View;
40
41import java.util.concurrent.TimeUnit;
42
43/**
44 * A page indicator for {@link ViewPager} based on {@link
45 * android.support.wear.view.DotsPageIndicator} which identifies the current page in relation to
46 * all available pages. Pages are represented as dots. The current page can be highlighted with a
47 * different color or size dot.
48 *
49 * <p>The default behavior is to fade out the dots when the pager is idle (not settling or being
50 * dragged). This can be changed with {@link #setDotFadeWhenIdle(boolean)}.
51 *
52 * <p>Use {@link #setPager(ViewPager)} to connect this view to a pager instance.
53 *
54 * @hide
55 */
56@RequiresApi(Build.VERSION_CODES.M)
57@RestrictTo(Scope.LIBRARY_GROUP)
58public class PageIndicatorView extends View implements OnPageChangeListener {
59
60    private static final String TAG = "Dots";
61    private final Paint mDotPaint;
62    private final Paint mDotPaintShadow;
63    private final Paint mDotPaintSelected;
64    private final Paint mDotPaintShadowSelected;
65    private int mDotSpacing;
66    private float mDotRadius;
67    private float mDotRadiusSelected;
68    private int mDotColor;
69    private int mDotColorSelected;
70    private boolean mDotFadeWhenIdle;
71    private int mDotFadeOutDelay;
72    private int mDotFadeOutDuration;
73    private int mDotFadeInDuration;
74    private float mDotShadowDx;
75    private float mDotShadowDy;
76    private float mDotShadowRadius;
77    private int mDotShadowColor;
78    private PagerAdapter mAdapter;
79    private int mNumberOfPositions;
80    private int mSelectedPosition;
81    private int mCurrentViewPagerState;
82    private boolean mVisible;
83
84    public PageIndicatorView(Context context) {
85        this(context, null);
86    }
87
88    public PageIndicatorView(Context context, AttributeSet attrs) {
89        this(context, attrs, 0);
90    }
91
92    public PageIndicatorView(Context context, AttributeSet attrs, int defStyleAttr) {
93        super(context, attrs, defStyleAttr);
94
95        final TypedArray a =
96                getContext()
97                        .obtainStyledAttributes(
98                                attrs, R.styleable.PageIndicatorView, defStyleAttr,
99                                R.style.WsPageIndicatorViewStyle);
100
101        mDotSpacing = a.getDimensionPixelOffset(
102                R.styleable.PageIndicatorView_wsPageIndicatorDotSpacing, 0);
103        mDotRadius = a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotRadius, 0);
104        mDotRadiusSelected =
105                a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotRadiusSelected, 0);
106        mDotColor = a.getColor(R.styleable.PageIndicatorView_wsPageIndicatorDotColor, 0);
107        mDotColorSelected = a
108                .getColor(R.styleable.PageIndicatorView_wsPageIndicatorDotColorSelected, 0);
109        mDotFadeOutDelay =
110                a.getInt(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeOutDelay, 0);
111        mDotFadeOutDuration =
112                a.getInt(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeOutDuration, 0);
113        mDotFadeInDuration =
114                a.getInt(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeInDuration, 0);
115        mDotFadeWhenIdle =
116                a.getBoolean(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeWhenIdle, false);
117        mDotShadowDx = a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowDx, 0);
118        mDotShadowDy = a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowDy, 0);
119        mDotShadowRadius =
120                a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowRadius, 0);
121        mDotShadowColor =
122                a.getColor(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowColor, 0);
123        a.recycle();
124
125        mDotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
126        mDotPaint.setColor(mDotColor);
127        mDotPaint.setStyle(Style.FILL);
128
129        mDotPaintSelected = new Paint(Paint.ANTI_ALIAS_FLAG);
130        mDotPaintSelected.setColor(mDotColorSelected);
131        mDotPaintSelected.setStyle(Style.FILL);
132        mDotPaintShadow = new Paint(Paint.ANTI_ALIAS_FLAG);
133        mDotPaintShadowSelected = new Paint(Paint.ANTI_ALIAS_FLAG);
134
135        mCurrentViewPagerState = ViewPager.SCROLL_STATE_IDLE;
136        if (isInEditMode()) {
137            // When displayed in layout preview:
138            // Simulate 5 positions, currently on the 3rd position.
139            mNumberOfPositions = 5;
140            mSelectedPosition = 2;
141            mDotFadeWhenIdle = false;
142        }
143
144        if (mDotFadeWhenIdle) {
145            mVisible = false;
146            animate().alpha(0f).setStartDelay(2000).setDuration(mDotFadeOutDuration).start();
147        } else {
148            animate().cancel();
149            setAlpha(1.0f);
150        }
151        updateShadows();
152    }
153
154    private void updateShadows() {
155        updateDotPaint(
156                mDotPaint, mDotPaintShadow, mDotRadius, mDotShadowRadius, mDotColor,
157                mDotShadowColor);
158        updateDotPaint(
159                mDotPaintSelected,
160                mDotPaintShadowSelected,
161                mDotRadiusSelected,
162                mDotShadowRadius,
163                mDotColorSelected,
164                mDotShadowColor);
165    }
166
167    private void updateDotPaint(
168            Paint dotPaint,
169            Paint shadowPaint,
170            float baseRadius,
171            float shadowRadius,
172            int color,
173            int shadowColor) {
174        float radius = baseRadius + shadowRadius;
175        float shadowStart = baseRadius / radius;
176        Shader gradient =
177                new RadialGradient(
178                        0,
179                        0,
180                        radius,
181                        new int[]{shadowColor, shadowColor, Color.TRANSPARENT},
182                        new float[]{0f, shadowStart, 1f},
183                        TileMode.CLAMP);
184
185        shadowPaint.setShader(gradient);
186        dotPaint.setColor(color);
187        dotPaint.setStyle(Style.FILL);
188    }
189
190    /**
191     * Supplies the ViewPager instance, and attaches this views {@link OnPageChangeListener} to the
192     * pager.
193     *
194     * @param pager the pager for the page indicator
195     */
196    public void setPager(ViewPager pager) {
197        pager.addOnPageChangeListener(this);
198        setPagerAdapter(pager.getAdapter());
199        mAdapter = pager.getAdapter();
200        if (mAdapter != null && mAdapter.getCount() > 0) {
201            positionChanged(0);
202        }
203    }
204
205    /**
206     * Gets the center-to-center distance between page dots.
207     *
208     * @return the distance between page dots
209     */
210    public float getDotSpacing() {
211        return mDotSpacing;
212    }
213
214    /**
215     * Sets the center-to-center distance between page dots.
216     *
217     * @param spacing the distance between page dots
218     */
219    public void setDotSpacing(int spacing) {
220        if (mDotSpacing != spacing) {
221            mDotSpacing = spacing;
222            requestLayout();
223        }
224    }
225
226    /**
227     * Gets the radius of the page dots.
228     *
229     * @return the radius of the page dots
230     */
231    public float getDotRadius() {
232        return mDotRadius;
233    }
234
235    /**
236     * Sets the radius of the page dots.
237     *
238     * @param radius the radius of the page dots
239     */
240    public void setDotRadius(int radius) {
241        if (mDotRadius != radius) {
242            mDotRadius = radius;
243            updateShadows();
244            invalidate();
245        }
246    }
247
248    /**
249     * Gets the radius of the page dot for the selected page.
250     *
251     * @return the radius of the selected page dot
252     */
253    public float getDotRadiusSelected() {
254        return mDotRadiusSelected;
255    }
256
257    /**
258     * Sets the radius of the page dot for the selected page.
259     *
260     * @param radius the radius of the selected page dot
261     */
262    public void setDotRadiusSelected(int radius) {
263        if (mDotRadiusSelected != radius) {
264            mDotRadiusSelected = radius;
265            updateShadows();
266            invalidate();
267        }
268    }
269
270    /**
271     * Returns the color used for dots other than the selected page.
272     *
273     * @return color the color used for dots other than the selected page
274     */
275    public int getDotColor() {
276        return mDotColor;
277    }
278
279    /**
280     * Sets the color used for dots other than the selected page.
281     *
282     * @param color the color used for dots other than the selected page
283     */
284    public void setDotColor(int color) {
285        if (mDotColor != color) {
286            mDotColor = color;
287            invalidate();
288        }
289    }
290
291    /**
292     * Returns the color of the dot for the selected page.
293     *
294     * @return the color used for the selected page dot
295     */
296    public int getDotColorSelected() {
297        return mDotColorSelected;
298    }
299
300    /**
301     * Sets the color of the dot for the selected page.
302     *
303     * @param color the color of the dot for the selected page
304     */
305    public void setDotColorSelected(int color) {
306        if (mDotColorSelected != color) {
307            mDotColorSelected = color;
308            invalidate();
309        }
310    }
311
312    /**
313     * Indicates if the dots fade out when the pager is idle.
314     *
315     * @return whether the dots fade out when idle
316     */
317    public boolean getDotFadeWhenIdle() {
318        return mDotFadeWhenIdle;
319    }
320
321    /**
322     * Sets whether the dots fade out when the pager is idle.
323     *
324     * @param fade whether the dots fade out when idle
325     */
326    public void setDotFadeWhenIdle(boolean fade) {
327        mDotFadeWhenIdle = fade;
328        if (!fade) {
329            fadeIn();
330        }
331    }
332
333    /**
334     * Returns the duration of fade out animation, in milliseconds.
335     *
336     * @return the duration of the fade out animation, in milliseconds
337     */
338    public int getDotFadeOutDuration() {
339        return mDotFadeOutDuration;
340    }
341
342    /**
343     * Sets the duration of the fade out animation.
344     *
345     * @param duration the duration of the fade out animation
346     */
347    public void setDotFadeOutDuration(int duration, TimeUnit unit) {
348        mDotFadeOutDuration = (int) TimeUnit.MILLISECONDS.convert(duration, unit);
349    }
350
351    /**
352     * Returns the duration of the fade in duration, in milliseconds.
353     *
354     * @return the duration of the fade in duration, in milliseconds
355     */
356    public int getDotFadeInDuration() {
357        return mDotFadeInDuration;
358    }
359
360    /**
361     * Sets the duration of the fade in animation.
362     *
363     * @param duration the duration of the fade in animation
364     */
365    public void setDotFadeInDuration(int duration, TimeUnit unit) {
366        mDotFadeInDuration = (int) TimeUnit.MILLISECONDS.convert(duration, unit);
367    }
368
369    /**
370     * Sets the delay between the pager arriving at an idle state, and the fade out animation
371     * beginning, in milliseconds.
372     *
373     * @return the delay before the fade out animation begins, in milliseconds
374     */
375    public int getDotFadeOutDelay() {
376        return mDotFadeOutDelay;
377    }
378
379    /**
380     * Sets the delay between the pager arriving at an idle state, and the fade out animation
381     * beginning, in milliseconds.
382     *
383     * @param delay the delay before the fade out animation begins, in milliseconds
384     */
385    public void setDotFadeOutDelay(int delay) {
386        mDotFadeOutDelay = delay;
387    }
388
389    /**
390     * Sets the pixel radius of shadows drawn beneath the dots.
391     *
392     * @return the pixel radius of shadows rendered beneath the dots
393     */
394    public float getDotShadowRadius() {
395        return mDotShadowRadius;
396    }
397
398    /**
399     * Sets the pixel radius of shadows drawn beneath the dots.
400     *
401     * @param radius the pixel radius of shadows rendered beneath the dots
402     */
403    public void setDotShadowRadius(float radius) {
404        if (mDotShadowRadius != radius) {
405            mDotShadowRadius = radius;
406            updateShadows();
407            invalidate();
408        }
409    }
410
411    /**
412     * Returns the horizontal offset of shadows drawn beneath the dots.
413     *
414     * @return the horizontal offset of shadows drawn beneath the dots
415     */
416    public float getDotShadowDx() {
417        return mDotShadowDx;
418    }
419
420    /**
421     * Sets the horizontal offset of shadows drawn beneath the dots.
422     *
423     * @param dx the horizontal offset of shadows drawn beneath the dots
424     */
425    public void setDotShadowDx(float dx) {
426        mDotShadowDx = dx;
427        invalidate();
428    }
429
430    /**
431     * Returns the vertical offset of shadows drawn beneath the dots.
432     *
433     * @return the vertical offset of shadows drawn beneath the dots
434     */
435    public float getDotShadowDy() {
436        return mDotShadowDy;
437    }
438
439    /**
440     * Sets the vertical offset of shadows drawn beneath the dots.
441     *
442     * @param dy the vertical offset of shadows drawn beneath the dots
443     */
444    public void setDotShadowDy(float dy) {
445        mDotShadowDy = dy;
446        invalidate();
447    }
448
449    /**
450     * Returns the color of the shadows drawn beneath the dots.
451     *
452     * @return the color of the shadows drawn beneath the dots
453     */
454    public int getDotShadowColor() {
455        return mDotShadowColor;
456    }
457
458    /**
459     * Sets the color of the shadows drawn beneath the dots.
460     *
461     * @param color the color of the shadows drawn beneath the dots
462     */
463    public void setDotShadowColor(int color) {
464        mDotShadowColor = color;
465        updateShadows();
466        invalidate();
467    }
468
469    private void positionChanged(int position) {
470        mSelectedPosition = position;
471        invalidate();
472    }
473
474    private void updateNumberOfPositions() {
475        int count = mAdapter.getCount();
476        if (count != mNumberOfPositions) {
477            mNumberOfPositions = count;
478            requestLayout();
479        }
480    }
481
482    private void fadeIn() {
483        mVisible = true;
484        animate().cancel();
485        animate().alpha(1f).setStartDelay(0).setDuration(mDotFadeInDuration).start();
486    }
487
488    private void fadeOut(long delayMillis) {
489        mVisible = false;
490        animate().cancel();
491        animate().alpha(0f).setStartDelay(delayMillis).setDuration(mDotFadeOutDuration).start();
492    }
493
494    private void fadeInOut() {
495        mVisible = true;
496        animate().cancel();
497        animate()
498                .alpha(1f)
499                .setStartDelay(0)
500                .setDuration(mDotFadeInDuration)
501                .setListener(
502                        new SimpleAnimatorListener() {
503                            @Override
504                            public void onAnimationComplete(Animator animator) {
505                                mVisible = false;
506                                animate()
507                                        .alpha(0f)
508                                        .setListener(null)
509                                        .setStartDelay(mDotFadeOutDelay)
510                                        .setDuration(mDotFadeOutDuration)
511                                        .start();
512                            }
513                        })
514                .start();
515    }
516
517    @Override
518    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
519        if (mDotFadeWhenIdle) {
520            if (mCurrentViewPagerState == ViewPager.SCROLL_STATE_DRAGGING) {
521                if (positionOffset != 0) {
522                    if (!mVisible) {
523                        fadeIn();
524                    }
525                } else {
526                    if (mVisible) {
527                        fadeOut(0);
528                    }
529                }
530            }
531        }
532    }
533
534    @Override
535    public void onPageSelected(int position) {
536        if (position != mSelectedPosition) {
537            positionChanged(position);
538        }
539    }
540
541    @Override
542    public void onPageScrollStateChanged(int state) {
543        if (mCurrentViewPagerState != state) {
544            mCurrentViewPagerState = state;
545            if (mDotFadeWhenIdle) {
546                if (state == ViewPager.SCROLL_STATE_IDLE) {
547                    if (mVisible) {
548                        fadeOut(mDotFadeOutDelay);
549                    } else {
550                        fadeInOut();
551                    }
552                }
553            }
554        }
555    }
556
557    /**
558     * Sets the {@link PagerAdapter}.
559     */
560    public void setPagerAdapter(PagerAdapter adapter) {
561        mAdapter = adapter;
562        if (mAdapter != null) {
563            updateNumberOfPositions();
564            if (mDotFadeWhenIdle) {
565                fadeInOut();
566            }
567        }
568    }
569
570    @Override
571    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
572        int totalWidth;
573        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) {
574            totalWidth = MeasureSpec.getSize(widthMeasureSpec);
575        } else {
576            int contentWidth = mNumberOfPositions * mDotSpacing;
577            totalWidth = contentWidth + getPaddingLeft() + getPaddingRight();
578        }
579        int totalHeight;
580        if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
581            totalHeight = MeasureSpec.getSize(heightMeasureSpec);
582        } else {
583            float maxRadius =
584                    Math.max(mDotRadius + mDotShadowRadius, mDotRadiusSelected + mDotShadowRadius);
585            int contentHeight = (int) Math.ceil(maxRadius * 2);
586            contentHeight = (int) (contentHeight + mDotShadowDy);
587            totalHeight = contentHeight + getPaddingTop() + getPaddingBottom();
588        }
589        setMeasuredDimension(
590                resolveSizeAndState(totalWidth, widthMeasureSpec, 0),
591                resolveSizeAndState(totalHeight, heightMeasureSpec, 0));
592    }
593
594    @Override
595    protected void onDraw(Canvas canvas) {
596        super.onDraw(canvas);
597
598        if (mNumberOfPositions > 1) {
599            float dotCenterLeft = getPaddingLeft() + (mDotSpacing / 2f);
600            float dotCenterTop = getHeight() / 2f;
601            canvas.save();
602            canvas.translate(dotCenterLeft, dotCenterTop);
603            for (int i = 0; i < mNumberOfPositions; i++) {
604                if (i == mSelectedPosition) {
605                    float radius = mDotRadiusSelected + mDotShadowRadius;
606                    canvas.drawCircle(mDotShadowDx, mDotShadowDy, radius, mDotPaintShadowSelected);
607                    canvas.drawCircle(0, 0, mDotRadiusSelected, mDotPaintSelected);
608                } else {
609                    float radius = mDotRadius + mDotShadowRadius;
610                    canvas.drawCircle(mDotShadowDx, mDotShadowDy, radius, mDotPaintShadow);
611                    canvas.drawCircle(0, 0, mDotRadius, mDotPaint);
612                }
613                canvas.translate(mDotSpacing, 0);
614            }
615            canvas.restore();
616        }
617    }
618
619    /**
620     * Notifies the view that the data set has changed.
621     */
622    public void notifyDataSetChanged() {
623        if (mAdapter != null && mAdapter.getCount() > 0) {
624            updateNumberOfPositions();
625        }
626    }
627}
628