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