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