1 /* 2 * Copyright (C) 2016 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 com.android.launcher3.pageindicators; 18 19 import static com.android.launcher3.config.FeatureFlags.FOLDABLE_SINGLE_PAGE; 20 21 import android.animation.Animator; 22 import android.animation.AnimatorListenerAdapter; 23 import android.animation.AnimatorSet; 24 import android.animation.ObjectAnimator; 25 import android.animation.ValueAnimator; 26 import android.animation.ValueAnimator.AnimatorUpdateListener; 27 import android.content.Context; 28 import android.graphics.Canvas; 29 import android.graphics.Outline; 30 import android.graphics.Paint; 31 import android.graphics.Paint.Style; 32 import android.graphics.Rect; 33 import android.graphics.RectF; 34 import android.os.Handler; 35 import android.os.Looper; 36 import android.util.AttributeSet; 37 import android.util.FloatProperty; 38 import android.util.IntProperty; 39 import android.view.View; 40 import android.view.ViewConfiguration; 41 import android.view.ViewOutlineProvider; 42 import android.view.animation.Interpolator; 43 import android.view.animation.OvershootInterpolator; 44 45 import androidx.annotation.Nullable; 46 47 import com.android.launcher3.Insettable; 48 import com.android.launcher3.R; 49 import com.android.launcher3.Utilities; 50 import com.android.launcher3.util.Themes; 51 52 /** 53 * {@link PageIndicator} which shows dots per page. The active page is shown with the current 54 * accent color. 55 */ 56 public class PageIndicatorDots extends View implements Insettable, PageIndicator { 57 58 private static final float SHIFT_PER_ANIMATION = 0.5f; 59 private static final float SHIFT_THRESHOLD = 0.1f; 60 private static final long ANIMATION_DURATION = 150; 61 private static final int PAGINATION_FADE_DELAY = ViewConfiguration.getScrollDefaultDelay(); 62 private static final int PAGINATION_FADE_IN_DURATION = 83; 63 private static final int PAGINATION_FADE_OUT_DURATION = 167; 64 65 private static final int ENTER_ANIMATION_START_DELAY = 300; 66 private static final int ENTER_ANIMATION_STAGGERED_DELAY = 150; 67 private static final int ENTER_ANIMATION_DURATION = 400; 68 69 private static final int PAGE_INDICATOR_ALPHA = 255; 70 private static final int DOT_ALPHA = 128; 71 private static final float DOT_ALPHA_FRACTION = 0.5f; 72 private static final int DOT_GAP_FACTOR = 4; 73 private static final int VISIBLE_ALPHA = 255; 74 private static final int INVISIBLE_ALPHA = 0; 75 private Paint mPaginationPaint; 76 77 // This value approximately overshoots to 1.5 times the original size. 78 private static final float ENTER_ANIMATION_OVERSHOOT_TENSION = 4.9f; 79 80 private static final RectF sTempRect = new RectF(); 81 82 private static final FloatProperty<PageIndicatorDots> CURRENT_POSITION = 83 new FloatProperty<PageIndicatorDots>("current_position") { 84 @Override 85 public Float get(PageIndicatorDots obj) { 86 return obj.mCurrentPosition; 87 } 88 89 @Override 90 public void setValue(PageIndicatorDots obj, float pos) { 91 obj.mCurrentPosition = pos; 92 obj.invalidate(); 93 obj.invalidateOutline(); 94 } 95 }; 96 97 private static final IntProperty<PageIndicatorDots> PAGINATION_ALPHA = 98 new IntProperty<PageIndicatorDots>("pagination_alpha") { 99 @Override 100 public Integer get(PageIndicatorDots obj) { 101 return obj.mPaginationPaint.getAlpha(); 102 } 103 104 @Override 105 public void setValue(PageIndicatorDots obj, int alpha) { 106 obj.mPaginationPaint.setAlpha(alpha); 107 obj.invalidate(); 108 } 109 }; 110 111 private final Handler mDelayedPaginationFadeHandler = new Handler(Looper.getMainLooper()); 112 private final float mDotRadius; 113 private final float mCircleGap; 114 private final boolean mIsRtl; 115 116 private int mNumPages; 117 private int mActivePage; 118 private int mTotalScroll; 119 private boolean mShouldAutoHide; 120 private int mToAlpha; 121 122 /** 123 * The current position of the active dot including the animation progress. 124 * For ex: 125 * 0.0 => Active dot is at position 0 126 * 0.33 => Active dot is at position 0 and is moving towards 1 127 * 0.50 => Active dot is at position [0, 1] 128 * 0.77 => Active dot has left position 0 and is collapsing towards position 1 129 * 1.0 => Active dot is at position 1 130 */ 131 private float mCurrentPosition; 132 private float mFinalPosition; 133 private boolean mIsScrollPaused; 134 private boolean mIsTwoPanels; 135 private ObjectAnimator mAnimator; 136 private @Nullable ObjectAnimator mAlphaAnimator; 137 138 private float[] mEntryAnimationRadiusFactors; 139 140 private final Runnable mHidePaginationRunnable = 141 () -> animatePaginationToAlpha(INVISIBLE_ALPHA); 142 PageIndicatorDots(Context context)143 public PageIndicatorDots(Context context) { 144 this(context, null); 145 } 146 PageIndicatorDots(Context context, AttributeSet attrs)147 public PageIndicatorDots(Context context, AttributeSet attrs) { 148 this(context, attrs, 0); 149 } 150 PageIndicatorDots(Context context, AttributeSet attrs, int defStyleAttr)151 public PageIndicatorDots(Context context, AttributeSet attrs, int defStyleAttr) { 152 super(context, attrs, defStyleAttr); 153 154 mPaginationPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 155 mPaginationPaint.setStyle(Style.FILL); 156 mPaginationPaint.setColor(Themes.getAttrColor(context, R.attr.pageIndicatorDotColor)); 157 mDotRadius = getResources().getDimension(R.dimen.page_indicator_dot_size) / 2; 158 mCircleGap = DOT_GAP_FACTOR * mDotRadius; 159 setOutlineProvider(new MyOutlineProver()); 160 mIsRtl = Utilities.isRtl(getResources()); 161 } 162 163 @Override setScroll(int currentScroll, int totalScroll)164 public void setScroll(int currentScroll, int totalScroll) { 165 if (currentScroll == 0 && totalScroll == 0) { 166 CURRENT_POSITION.set(this, (float) mActivePage); 167 return; 168 } 169 170 if (mNumPages <= 1) { 171 return; 172 } 173 174 // Skip scroll update during binding. We will update it when binding completes. 175 if (mIsScrollPaused) { 176 return; 177 } 178 179 if (mShouldAutoHide) { 180 animatePaginationToAlpha(VISIBLE_ALPHA); 181 } 182 183 if (mIsRtl) { 184 currentScroll = totalScroll - currentScroll; 185 } 186 187 mTotalScroll = totalScroll; 188 189 int scrollPerPage = totalScroll / (mNumPages - 1); 190 int pageToLeft = scrollPerPage == 0 ? 0 : currentScroll / scrollPerPage; 191 int pageToLeftScroll = pageToLeft * scrollPerPage; 192 int pageToRightScroll = pageToLeftScroll + scrollPerPage; 193 194 float scrollThreshold = SHIFT_THRESHOLD * scrollPerPage; 195 if (currentScroll < pageToLeftScroll + scrollThreshold) { 196 // scroll is within the left page's threshold 197 animateToPosition(pageToLeft); 198 if (mShouldAutoHide) { 199 hideAfterDelay(); 200 } 201 } else if (currentScroll > pageToRightScroll - scrollThreshold) { 202 // scroll is far enough from left page to go to the right page 203 animateToPosition(pageToLeft + 1); 204 if (mShouldAutoHide) { 205 hideAfterDelay(); 206 } 207 } else { 208 // scroll is between left and right page 209 animateToPosition(pageToLeft + SHIFT_PER_ANIMATION); 210 if (mShouldAutoHide) { 211 mDelayedPaginationFadeHandler.removeCallbacksAndMessages(null); 212 } 213 } 214 } 215 216 @Override setShouldAutoHide(boolean shouldAutoHide)217 public void setShouldAutoHide(boolean shouldAutoHide) { 218 mShouldAutoHide = shouldAutoHide; 219 if (shouldAutoHide && mPaginationPaint.getAlpha() > INVISIBLE_ALPHA) { 220 hideAfterDelay(); 221 } else if (!shouldAutoHide) { 222 mDelayedPaginationFadeHandler.removeCallbacksAndMessages(null); 223 } 224 } 225 226 @Override setPaintColor(int color)227 public void setPaintColor(int color) { 228 mPaginationPaint.setColor(color); 229 } 230 hideAfterDelay()231 private void hideAfterDelay() { 232 mDelayedPaginationFadeHandler.removeCallbacksAndMessages(null); 233 mDelayedPaginationFadeHandler.postDelayed(mHidePaginationRunnable, PAGINATION_FADE_DELAY); 234 } 235 animatePaginationToAlpha(int alpha)236 private void animatePaginationToAlpha(int alpha) { 237 if (alpha == mToAlpha) { 238 // Ignore the new animation if it is going to the same alpha as the current animation. 239 return; 240 } 241 242 if (mAlphaAnimator != null) { 243 mAlphaAnimator.cancel(); 244 } 245 mAlphaAnimator = ObjectAnimator.ofInt(this, PAGINATION_ALPHA, 246 alpha); 247 // If we are animating to decrease the alpha, then it's a fade out animation 248 // whereas if we are animating to increase the alpha, it's a fade in animation. 249 mAlphaAnimator.setDuration(alpha < mToAlpha 250 ? PAGINATION_FADE_OUT_DURATION 251 : PAGINATION_FADE_IN_DURATION); 252 mAlphaAnimator.addListener(new AnimatorListenerAdapter() { 253 @Override 254 public void onAnimationEnd(Animator animation) { 255 mAlphaAnimator = null; 256 } 257 }); 258 mAlphaAnimator.start(); 259 mToAlpha = alpha; 260 } 261 262 /** 263 * Pauses all currently running animations. 264 */ 265 @Override 266 public void pauseAnimations() { 267 if (mAlphaAnimator != null) { 268 mAlphaAnimator.pause(); 269 } 270 } 271 272 /** 273 * Force-ends all currently running or paused animations. 274 */ 275 @Override 276 public void skipAnimationsToEnd() { 277 if (mAlphaAnimator != null) { 278 mAlphaAnimator.end(); 279 } 280 } 281 282 private void animateToPosition(float position) { 283 mFinalPosition = position; 284 if (Math.abs(mCurrentPosition - mFinalPosition) < SHIFT_THRESHOLD) { 285 mCurrentPosition = mFinalPosition; 286 } 287 if (mAnimator == null && Float.compare(mCurrentPosition, mFinalPosition) != 0) { 288 float positionForThisAnim = mCurrentPosition > mFinalPosition ? 289 mCurrentPosition - SHIFT_PER_ANIMATION : mCurrentPosition + SHIFT_PER_ANIMATION; 290 mAnimator = ObjectAnimator.ofFloat(this, CURRENT_POSITION, positionForThisAnim); 291 mAnimator.addListener(new AnimationCycleListener()); 292 mAnimator.setDuration(ANIMATION_DURATION); 293 mAnimator.start(); 294 } 295 } 296 297 public void stopAllAnimations() { 298 if (mAnimator != null) { 299 mAnimator.cancel(); 300 mAnimator = null; 301 } 302 mFinalPosition = mActivePage; 303 CURRENT_POSITION.set(this, mFinalPosition); 304 } 305 306 /** 307 * Sets up up the page indicator to play the entry animation. 308 * {@link #playEntryAnimation()} must be called after this. 309 */ 310 public void prepareEntryAnimation() { 311 mEntryAnimationRadiusFactors = new float[mNumPages]; 312 invalidate(); 313 } 314 315 public void playEntryAnimation() { 316 int count = mEntryAnimationRadiusFactors.length; 317 if (count == 0) { 318 mEntryAnimationRadiusFactors = null; 319 invalidate(); 320 return; 321 } 322 323 Interpolator interpolator = new OvershootInterpolator(ENTER_ANIMATION_OVERSHOOT_TENSION); 324 AnimatorSet animSet = new AnimatorSet(); 325 for (int i = 0; i < count; i++) { 326 ValueAnimator anim = ValueAnimator.ofFloat(0, 1).setDuration(ENTER_ANIMATION_DURATION); 327 final int index = i; 328 anim.addUpdateListener(new AnimatorUpdateListener() { 329 @Override 330 public void onAnimationUpdate(ValueAnimator animation) { 331 mEntryAnimationRadiusFactors[index] = (Float) animation.getAnimatedValue(); 332 invalidate(); 333 } 334 }); 335 anim.setInterpolator(interpolator); 336 anim.setStartDelay(ENTER_ANIMATION_START_DELAY + ENTER_ANIMATION_STAGGERED_DELAY * i); 337 animSet.play(anim); 338 } 339 340 animSet.addListener(new AnimatorListenerAdapter() { 341 342 @Override 343 public void onAnimationEnd(Animator animation) { 344 mEntryAnimationRadiusFactors = null; 345 invalidateOutline(); 346 invalidate(); 347 } 348 }); 349 animSet.start(); 350 } 351 352 @Override 353 public void setActiveMarker(int activePage) { 354 // In unfolded foldables, every page has two CellLayouts, so we need to halve the active 355 // page for it to be accurate. 356 if (mIsTwoPanels && !FOLDABLE_SINGLE_PAGE.get()) { 357 activePage = activePage / 2; 358 } 359 360 if (mActivePage != activePage) { 361 mActivePage = activePage; 362 } 363 } 364 365 @Override 366 public void setMarkersCount(int numMarkers) { 367 mNumPages = numMarkers; 368 369 // If the last page gets removed we want to go to the previous page. 370 if (mNumPages > 0 && mNumPages == mActivePage) { 371 mActivePage--; 372 CURRENT_POSITION.set(this, (float) mActivePage); 373 } 374 375 requestLayout(); 376 } 377 378 @Override 379 public void setPauseScroll(boolean pause, boolean isTwoPanels) { 380 mIsTwoPanels = isTwoPanels; 381 382 // Reapply correct current position which was skipped during setScroll. 383 if (mIsScrollPaused && !pause) { 384 CURRENT_POSITION.set(this, (float) mActivePage); 385 } 386 387 mIsScrollPaused = pause; 388 } 389 390 @Override 391 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 392 // Add extra spacing of mDotRadius on all sides so than entry animation could be run. 393 int width = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY ? 394 MeasureSpec.getSize(widthMeasureSpec) : (int) ((mNumPages * 3 + 2) * mDotRadius); 395 int height = MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY 396 ? MeasureSpec.getSize(heightMeasureSpec) : (int) (4 * mDotRadius); 397 setMeasuredDimension(width, height); 398 } 399 400 @Override 401 protected void onDraw(Canvas canvas) { 402 if (mNumPages < 2) { 403 return; 404 } 405 406 if (mShouldAutoHide && mTotalScroll == 0) { 407 mPaginationPaint.setAlpha(INVISIBLE_ALPHA); 408 return; 409 } 410 411 // Draw all page indicators; 412 float circleGap = mCircleGap; 413 float startX = ((float) getWidth() / 2) 414 - (mCircleGap * (((float) mNumPages - 1) / 2)) 415 - mDotRadius; 416 417 float x = startX + mDotRadius; 418 float y = getHeight() / 2; 419 420 if (mEntryAnimationRadiusFactors != null) { 421 // During entry animation, only draw the circles 422 if (mIsRtl) { 423 x = getWidth() - x; 424 circleGap = -circleGap; 425 } 426 for (int i = 0; i < mEntryAnimationRadiusFactors.length; i++) { 427 mPaginationPaint.setAlpha(i == mActivePage ? PAGE_INDICATOR_ALPHA : DOT_ALPHA); 428 canvas.drawCircle(x, y, mDotRadius * mEntryAnimationRadiusFactors[i], 429 mPaginationPaint); 430 x += circleGap; 431 } 432 } else { 433 int alpha = mPaginationPaint.getAlpha(); 434 435 // Here we draw the dots 436 mPaginationPaint.setAlpha((int) (alpha * DOT_ALPHA_FRACTION)); 437 for (int i = 0; i < mNumPages; i++) { 438 canvas.drawCircle(x, y, mDotRadius, mPaginationPaint); 439 x += circleGap; 440 } 441 442 // Here we draw the current page indicator 443 mPaginationPaint.setAlpha(alpha); 444 canvas.drawRoundRect(getActiveRect(), mDotRadius, mDotRadius, mPaginationPaint); 445 } 446 } 447 448 private RectF getActiveRect() { 449 float startCircle = (int) mCurrentPosition; 450 float delta = mCurrentPosition - startCircle; 451 float diameter = 2 * mDotRadius; 452 float startX = ((float) getWidth() / 2) 453 - (mCircleGap * (((float) mNumPages - 1) / 2)) 454 - mDotRadius; 455 sTempRect.top = (getHeight() * 0.5f) - mDotRadius; 456 sTempRect.bottom = (getHeight() * 0.5f) + mDotRadius; 457 sTempRect.left = startX + (startCircle * mCircleGap); 458 sTempRect.right = sTempRect.left + diameter; 459 460 if (delta < SHIFT_PER_ANIMATION) { 461 // dot is capturing the right circle. 462 sTempRect.right += delta * mCircleGap * 2; 463 } else { 464 // Dot is leaving the left circle. 465 sTempRect.right += mCircleGap; 466 467 delta -= SHIFT_PER_ANIMATION; 468 sTempRect.left += delta * mCircleGap * 2; 469 } 470 471 if (mIsRtl) { 472 float rectWidth = sTempRect.width(); 473 sTempRect.right = getWidth() - sTempRect.left; 474 sTempRect.left = sTempRect.right - rectWidth; 475 } 476 477 return sTempRect; 478 } 479 480 private class MyOutlineProver extends ViewOutlineProvider { 481 482 @Override 483 public void getOutline(View view, Outline outline) { 484 if (mEntryAnimationRadiusFactors == null) { 485 RectF activeRect = getActiveRect(); 486 outline.setRoundRect( 487 (int) activeRect.left, 488 (int) activeRect.top, 489 (int) activeRect.right, 490 (int) activeRect.bottom, 491 mDotRadius 492 ); 493 } 494 } 495 } 496 497 /** 498 * Listener for keep running the animation until the final state is reached. 499 */ 500 private class AnimationCycleListener extends AnimatorListenerAdapter { 501 502 private boolean mCancelled = false; 503 504 @Override 505 public void onAnimationCancel(Animator animation) { 506 mCancelled = true; 507 } 508 509 @Override 510 public void onAnimationEnd(Animator animation) { 511 if (!mCancelled) { 512 if (mShouldAutoHide) { 513 hideAfterDelay(); 514 } 515 mAnimator = null; 516 animateToPosition(mFinalPosition); 517 } 518 } 519 } 520 521 /** 522 * We need to override setInsets to prevent InsettableFrameLayout from applying different 523 * margins on the pagination. 524 */ 525 @Override 526 public void setInsets(Rect insets) { 527 } 528 } 529