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