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 android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.AnimatorSet; 22 import android.animation.ObjectAnimator; 23 import android.animation.ValueAnimator; 24 import android.animation.ValueAnimator.AnimatorUpdateListener; 25 import android.content.Context; 26 import android.graphics.Canvas; 27 import android.graphics.Outline; 28 import android.graphics.Paint; 29 import android.graphics.Paint.Style; 30 import android.graphics.RectF; 31 import android.util.AttributeSet; 32 import android.util.Property; 33 import android.view.View; 34 import android.view.ViewOutlineProvider; 35 import android.view.animation.Interpolator; 36 import android.view.animation.OvershootInterpolator; 37 38 import com.android.launcher3.R; 39 import com.android.launcher3.Utilities; 40 import com.android.launcher3.util.Themes; 41 42 /** 43 * {@link PageIndicator} which shows dots per page. The active page is shown with the current 44 * accent color. 45 */ 46 public class PageIndicatorDots extends View implements PageIndicator { 47 48 private static final float SHIFT_PER_ANIMATION = 0.5f; 49 private static final float SHIFT_THRESHOLD = 0.1f; 50 private static final long ANIMATION_DURATION = 150; 51 52 private static final int ENTER_ANIMATION_START_DELAY = 300; 53 private static final int ENTER_ANIMATION_STAGGERED_DELAY = 150; 54 private static final int ENTER_ANIMATION_DURATION = 400; 55 56 private static final int DOT_ACTIVE_ALPHA = 255; 57 private static final int DOT_INACTIVE_ALPHA = 128; 58 59 // This value approximately overshoots to 1.5 times the original size. 60 private static final float ENTER_ANIMATION_OVERSHOOT_TENSION = 4.9f; 61 62 private static final RectF sTempRect = new RectF(); 63 64 private static final Property<PageIndicatorDots, Float> CURRENT_POSITION 65 = new Property<PageIndicatorDots, Float>(float.class, "current_position") { 66 @Override 67 public Float get(PageIndicatorDots obj) { 68 return obj.mCurrentPosition; 69 } 70 71 @Override 72 public void set(PageIndicatorDots obj, Float pos) { 73 obj.mCurrentPosition = pos; 74 obj.invalidate(); 75 obj.invalidateOutline(); 76 } 77 }; 78 79 private final Paint mCirclePaint; 80 private final float mDotRadius; 81 private final boolean mIsRtl; 82 83 private int mNumPages; 84 private int mActivePage; 85 86 /** 87 * The current position of the active dot including the animation progress. 88 * For ex: 89 * 0.0 => Active dot is at position 0 90 * 0.33 => Active dot is at position 0 and is moving towards 1 91 * 0.50 => Active dot is at position [0, 1] 92 * 0.77 => Active dot has left position 0 and is collapsing towards position 1 93 * 1.0 => Active dot is at position 1 94 */ 95 private float mCurrentPosition; 96 private float mFinalPosition; 97 private ObjectAnimator mAnimator; 98 99 private float[] mEntryAnimationRadiusFactors; 100 PageIndicatorDots(Context context)101 public PageIndicatorDots(Context context) { 102 this(context, null); 103 } 104 PageIndicatorDots(Context context, AttributeSet attrs)105 public PageIndicatorDots(Context context, AttributeSet attrs) { 106 this(context, attrs, 0); 107 } 108 PageIndicatorDots(Context context, AttributeSet attrs, int defStyleAttr)109 public PageIndicatorDots(Context context, AttributeSet attrs, int defStyleAttr) { 110 super(context, attrs, defStyleAttr); 111 112 mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG); 113 mCirclePaint.setStyle(Style.FILL); 114 mCirclePaint.setColor(Themes.getAttrColor(context, R.attr.folderPaginationColor)); 115 mDotRadius = getResources().getDimension(R.dimen.page_indicator_dot_size) / 2; 116 setOutlineProvider(new MyOutlineProver()); 117 118 mIsRtl = Utilities.isRtl(getResources()); 119 } 120 121 @Override setScroll(int currentScroll, int totalScroll)122 public void setScroll(int currentScroll, int totalScroll) { 123 if (mNumPages > 1) { 124 if (mIsRtl) { 125 currentScroll = totalScroll - currentScroll; 126 } 127 int scrollPerPage = totalScroll / (mNumPages - 1); 128 int pageToLeft = currentScroll / scrollPerPage; 129 int pageToLeftScroll = pageToLeft * scrollPerPage; 130 int pageToRightScroll = pageToLeftScroll + scrollPerPage; 131 132 float scrollThreshold = SHIFT_THRESHOLD * scrollPerPage; 133 if (currentScroll < pageToLeftScroll + scrollThreshold) { 134 // scroll is within the left page's threshold 135 animateToPosition(pageToLeft); 136 } else if (currentScroll > pageToRightScroll - scrollThreshold) { 137 // scroll is far enough from left page to go to the right page 138 animateToPosition(pageToLeft + 1); 139 } else { 140 // scroll is between left and right page 141 animateToPosition(pageToLeft + SHIFT_PER_ANIMATION); 142 } 143 } 144 } 145 animateToPosition(float position)146 private void animateToPosition(float position) { 147 mFinalPosition = position; 148 if (Math.abs(mCurrentPosition - mFinalPosition) < SHIFT_THRESHOLD) { 149 mCurrentPosition = mFinalPosition; 150 } 151 if (mAnimator == null && Float.compare(mCurrentPosition, mFinalPosition) != 0) { 152 float positionForThisAnim = mCurrentPosition > mFinalPosition ? 153 mCurrentPosition - SHIFT_PER_ANIMATION : mCurrentPosition + SHIFT_PER_ANIMATION; 154 mAnimator = ObjectAnimator.ofFloat(this, CURRENT_POSITION, positionForThisAnim); 155 mAnimator.addListener(new AnimationCycleListener()); 156 mAnimator.setDuration(ANIMATION_DURATION); 157 mAnimator.start(); 158 } 159 } 160 stopAllAnimations()161 public void stopAllAnimations() { 162 if (mAnimator != null) { 163 mAnimator.cancel(); 164 mAnimator = null; 165 } 166 mFinalPosition = mActivePage; 167 CURRENT_POSITION.set(this, mFinalPosition); 168 } 169 170 /** 171 * Sets up up the page indicator to play the entry animation. 172 * {@link #playEntryAnimation()} must be called after this. 173 */ prepareEntryAnimation()174 public void prepareEntryAnimation() { 175 mEntryAnimationRadiusFactors = new float[mNumPages]; 176 invalidate(); 177 } 178 playEntryAnimation()179 public void playEntryAnimation() { 180 int count = mEntryAnimationRadiusFactors.length; 181 if (count == 0) { 182 mEntryAnimationRadiusFactors = null; 183 invalidate(); 184 return; 185 } 186 187 Interpolator interpolator = new OvershootInterpolator(ENTER_ANIMATION_OVERSHOOT_TENSION); 188 AnimatorSet animSet = new AnimatorSet(); 189 for (int i = 0; i < count; i++) { 190 ValueAnimator anim = ValueAnimator.ofFloat(0, 1).setDuration(ENTER_ANIMATION_DURATION); 191 final int index = i; 192 anim.addUpdateListener(new AnimatorUpdateListener() { 193 @Override 194 public void onAnimationUpdate(ValueAnimator animation) { 195 mEntryAnimationRadiusFactors[index] = (Float) animation.getAnimatedValue(); 196 invalidate(); 197 } 198 }); 199 anim.setInterpolator(interpolator); 200 anim.setStartDelay(ENTER_ANIMATION_START_DELAY + ENTER_ANIMATION_STAGGERED_DELAY * i); 201 animSet.play(anim); 202 } 203 204 animSet.addListener(new AnimatorListenerAdapter() { 205 206 @Override 207 public void onAnimationEnd(Animator animation) { 208 mEntryAnimationRadiusFactors = null; 209 invalidateOutline(); 210 invalidate(); 211 } 212 }); 213 animSet.start(); 214 } 215 216 @Override setActiveMarker(int activePage)217 public void setActiveMarker(int activePage) { 218 if (mActivePage != activePage) { 219 mActivePage = activePage; 220 } 221 } 222 223 @Override setMarkersCount(int numMarkers)224 public void setMarkersCount(int numMarkers) { 225 mNumPages = numMarkers; 226 requestLayout(); 227 } 228 229 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)230 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 231 // Add extra spacing of mDotRadius on all sides so than entry animation could be run. 232 int width = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY ? 233 MeasureSpec.getSize(widthMeasureSpec) : (int) ((mNumPages * 3 + 2) * mDotRadius); 234 int height= MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY ? 235 MeasureSpec.getSize(heightMeasureSpec) : (int) (4 * mDotRadius); 236 setMeasuredDimension(width, height); 237 } 238 239 @Override onDraw(Canvas canvas)240 protected void onDraw(Canvas canvas) { 241 // Draw all page indicators; 242 float circleGap = 3 * mDotRadius; 243 float startX = (getWidth() - mNumPages * circleGap + mDotRadius) / 2; 244 245 float x = startX + mDotRadius; 246 float y = getHeight() / 2; 247 248 if (mEntryAnimationRadiusFactors != null) { 249 // During entry animation, only draw the circles 250 if (mIsRtl) { 251 x = getWidth() - x; 252 circleGap = -circleGap; 253 } 254 for (int i = 0; i < mEntryAnimationRadiusFactors.length; i++) { 255 mCirclePaint.setAlpha(i == mActivePage ? DOT_ACTIVE_ALPHA : DOT_INACTIVE_ALPHA); 256 canvas.drawCircle(x, y, mDotRadius * mEntryAnimationRadiusFactors[i], mCirclePaint); 257 x += circleGap; 258 } 259 } else { 260 mCirclePaint.setAlpha(DOT_INACTIVE_ALPHA); 261 for (int i = 0; i < mNumPages; i++) { 262 canvas.drawCircle(x, y, mDotRadius, mCirclePaint); 263 x += circleGap; 264 } 265 266 mCirclePaint.setAlpha(DOT_ACTIVE_ALPHA); 267 canvas.drawRoundRect(getActiveRect(), mDotRadius, mDotRadius, mCirclePaint); 268 } 269 } 270 getActiveRect()271 private RectF getActiveRect() { 272 float startCircle = (int) mCurrentPosition; 273 float delta = mCurrentPosition - startCircle; 274 float diameter = 2 * mDotRadius; 275 float circleGap = 3 * mDotRadius; 276 float startX = (getWidth() - mNumPages * circleGap + mDotRadius) / 2; 277 278 sTempRect.top = getHeight() * 0.5f - mDotRadius; 279 sTempRect.bottom = getHeight() * 0.5f + mDotRadius; 280 sTempRect.left = startX + startCircle * circleGap; 281 sTempRect.right = sTempRect.left + diameter; 282 283 if (delta < SHIFT_PER_ANIMATION) { 284 // dot is capturing the right circle. 285 sTempRect.right += delta * circleGap * 2; 286 } else { 287 // Dot is leaving the left circle. 288 sTempRect.right += circleGap; 289 290 delta -= SHIFT_PER_ANIMATION; 291 sTempRect.left += delta * circleGap * 2; 292 } 293 294 if (mIsRtl) { 295 float rectWidth = sTempRect.width(); 296 sTempRect.right = getWidth() - sTempRect.left; 297 sTempRect.left = sTempRect.right - rectWidth; 298 } 299 return sTempRect; 300 } 301 302 private class MyOutlineProver extends ViewOutlineProvider { 303 304 @Override getOutline(View view, Outline outline)305 public void getOutline(View view, Outline outline) { 306 if (mEntryAnimationRadiusFactors == null) { 307 RectF activeRect = getActiveRect(); 308 outline.setRoundRect( 309 (int) activeRect.left, 310 (int) activeRect.top, 311 (int) activeRect.right, 312 (int) activeRect.bottom, 313 mDotRadius 314 ); 315 } 316 } 317 } 318 319 /** 320 * Listener for keep running the animation until the final state is reached. 321 */ 322 private class AnimationCycleListener extends AnimatorListenerAdapter { 323 324 private boolean mCancelled = false; 325 326 @Override onAnimationCancel(Animator animation)327 public void onAnimationCancel(Animator animation) { 328 mCancelled = true; 329 } 330 331 @Override onAnimationEnd(Animator animation)332 public void onAnimationEnd(Animator animation) { 333 if (!mCancelled) { 334 mAnimator = null; 335 animateToPosition(mFinalPosition); 336 } 337 } 338 } 339 } 340