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 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 // This value approximately overshoots to 1.5 times the original size. 57 private static final float ENTER_ANIMATION_OVERSHOOT_TENSION = 4.9f; 58 59 private static final RectF sTempRect = new RectF(); 60 61 private static final Property<PageIndicatorDots, Float> CURRENT_POSITION 62 = new Property<PageIndicatorDots, Float>(float.class, "current_position") { 63 @Override 64 public Float get(PageIndicatorDots obj) { 65 return obj.mCurrentPosition; 66 } 67 68 @Override 69 public void set(PageIndicatorDots obj, Float pos) { 70 obj.mCurrentPosition = pos; 71 obj.invalidate(); 72 obj.invalidateOutline(); 73 } 74 }; 75 76 private final Paint mCirclePaint; 77 private final float mDotRadius; 78 private final int mActiveColor; 79 private final int mInActiveColor; 80 private final boolean mIsRtl; 81 82 private int mActivePage; 83 84 /** 85 * The current position of the active dot including the animation progress. 86 * For ex: 87 * 0.0 => Active dot is at position 0 88 * 0.33 => Active dot is at position 0 and is moving towards 1 89 * 0.50 => Active dot is at position [0, 1] 90 * 0.77 => Active dot has left position 0 and is collapsing towards position 1 91 * 1.0 => Active dot is at position 1 92 */ 93 private float mCurrentPosition; 94 private float mFinalPosition; 95 private ObjectAnimator mAnimator; 96 97 private float[] mEntryAnimationRadiusFactors; 98 99 public PageIndicatorDots(Context context) { 100 this(context, null); 101 } 102 103 public PageIndicatorDots(Context context, AttributeSet attrs) { 104 this(context, attrs, 0); 105 } 106 107 public PageIndicatorDots(Context context, AttributeSet attrs, int defStyleAttr) { 108 super(context, attrs, defStyleAttr); 109 110 mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG); 111 mCirclePaint.setStyle(Style.FILL); 112 mDotRadius = getResources().getDimension(R.dimen.page_indicator_dot_size) / 2; 113 setOutlineProvider(new MyOutlineProver()); 114 115 mActiveColor = Themes.getColorAccent(context); 116 mInActiveColor = Themes.getAttrColor(context, android.R.attr.colorControlHighlight); 117 118 mIsRtl = Utilities.isRtl(getResources()); 119 } 120 121 @Override 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 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 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 */ 174 public void prepareEntryAnimation() { 175 mEntryAnimationRadiusFactors = new float[mNumPages]; 176 invalidate(); 177 } 178 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 217 public void setActiveMarker(int activePage) { 218 if (mActivePage != activePage) { 219 mActivePage = activePage; 220 } 221 } 222 223 @Override 224 protected void onPageCountChanged() { 225 requestLayout(); 226 } 227 228 @Override 229 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 230 // Add extra spacing of mDotRadius on all sides so than entry animation could be run. 231 int width = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY ? 232 MeasureSpec.getSize(widthMeasureSpec) : (int) ((mNumPages * 3 + 2) * mDotRadius); 233 int height= MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY ? 234 MeasureSpec.getSize(heightMeasureSpec) : (int) (4 * mDotRadius); 235 setMeasuredDimension(width, height); 236 } 237 238 @Override 239 protected void onDraw(Canvas canvas) { 240 // Draw all page indicators; 241 float circleGap = 3 * mDotRadius; 242 float startX = (getWidth() - mNumPages * circleGap + mDotRadius) / 2; 243 244 float x = startX + mDotRadius; 245 float y = canvas.getHeight() / 2; 246 247 if (mEntryAnimationRadiusFactors != null) { 248 // During entry animation, only draw the circles 249 if (mIsRtl) { 250 x = getWidth() - x; 251 circleGap = -circleGap; 252 } 253 for (int i = 0; i < mEntryAnimationRadiusFactors.length; i++) { 254 mCirclePaint.setColor(i == mActivePage ? mActiveColor : mInActiveColor); 255 canvas.drawCircle(x, y, mDotRadius * mEntryAnimationRadiusFactors[i], mCirclePaint); 256 x += circleGap; 257 } 258 } else { 259 mCirclePaint.setColor(mInActiveColor); 260 for (int i = 0; i < mNumPages; i++) { 261 canvas.drawCircle(x, y, mDotRadius, mCirclePaint); 262 x += circleGap; 263 } 264 265 mCirclePaint.setColor(mActiveColor); 266 canvas.drawRoundRect(getActiveRect(), mDotRadius, mDotRadius, mCirclePaint); 267 } 268 } 269 270 private RectF getActiveRect() { 271 float startCircle = (int) mCurrentPosition; 272 float delta = mCurrentPosition - startCircle; 273 float diameter = 2 * mDotRadius; 274 float circleGap = 3 * mDotRadius; 275 float startX = (getWidth() - mNumPages * circleGap + mDotRadius) / 2; 276 277 sTempRect.top = getHeight() * 0.5f - mDotRadius; 278 sTempRect.bottom = getHeight() * 0.5f + mDotRadius; 279 sTempRect.left = startX + startCircle * circleGap; 280 sTempRect.right = sTempRect.left + diameter; 281 282 if (delta < SHIFT_PER_ANIMATION) { 283 // dot is capturing the right circle. 284 sTempRect.right += delta * circleGap * 2; 285 } else { 286 // Dot is leaving the left circle. 287 sTempRect.right += circleGap; 288 289 delta -= SHIFT_PER_ANIMATION; 290 sTempRect.left += delta * circleGap * 2; 291 } 292 293 if (mIsRtl) { 294 float rectWidth = sTempRect.width(); 295 sTempRect.right = getWidth() - sTempRect.left; 296 sTempRect.left = sTempRect.right - rectWidth; 297 } 298 return sTempRect; 299 } 300 301 private class MyOutlineProver extends ViewOutlineProvider { 302 303 @Override 304 public void getOutline(View view, Outline outline) { 305 if (mEntryAnimationRadiusFactors == null) { 306 RectF activeRect = getActiveRect(); 307 outline.setRoundRect( 308 (int) activeRect.left, 309 (int) activeRect.top, 310 (int) activeRect.right, 311 (int) activeRect.bottom, 312 mDotRadius 313 ); 314 } 315 } 316 } 317 318 /** 319 * Listener for keep running the animation until the final state is reached. 320 */ 321 private class AnimationCycleListener extends AnimatorListenerAdapter { 322 323 private boolean mCancelled = false; 324 325 @Override 326 public void onAnimationCancel(Animator animation) { 327 mCancelled = true; 328 } 329 330 @Override 331 public void onAnimationEnd(Animator animation) { 332 if (!mCancelled) { 333 mAnimator = null; 334 animateToPosition(mFinalPosition); 335 } 336 } 337 } 338 } 339