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