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