1 /*
2  * Copyright (C) 2023 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.keyguard;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.AnimatorSet;
22 import android.animation.ValueAnimator;
23 import android.content.Context;
24 import android.content.res.Configuration;
25 import android.content.res.TypedArray;
26 import android.graphics.Canvas;
27 import android.graphics.Color;
28 import android.graphics.Paint;
29 import android.graphics.Rect;
30 import android.graphics.Typeface;
31 import android.os.PowerManager;
32 import android.os.SystemClock;
33 import android.util.AttributeSet;
34 import android.view.Gravity;
35 import android.view.LayoutInflater;
36 import android.view.animation.AnimationUtils;
37 import android.view.animation.Interpolator;
38 
39 import com.android.settingslib.Utils;
40 import com.android.systemui.res.R;
41 
42 import java.util.ArrayList;
43 
44 /**
45  * A View similar to a textView which contains password text and can animate when the text is
46  * changed
47  */
48 public class PasswordTextView extends BasePasswordTextView {
49     public static final long APPEAR_DURATION = 160;
50     public static final long DISAPPEAR_DURATION = 160;
51     private static final float DOT_OVERSHOOT_FACTOR = 1.5f;
52     private static final long DOT_APPEAR_DURATION_OVERSHOOT = 320;
53     private static final long RESET_DELAY_PER_ELEMENT = 40;
54     private static final long RESET_MAX_DELAY = 200;
55 
56     /**
57      * The overlap between the text disappearing and the dot appearing animation
58      */
59     private static final long DOT_APPEAR_TEXT_DISAPPEAR_OVERLAP_DURATION = 130;
60 
61     /**
62      * The duration the text needs to stay there at least before it can morph into a dot
63      */
64     private static final long TEXT_REST_DURATION_AFTER_APPEAR = 100;
65 
66     /**
67      * The duration the text should be visible, starting with the appear animation
68      */
69     private static final long TEXT_VISIBILITY_DURATION = 1300;
70 
71     /**
72      * The position in time from [0,1] where the overshoot should be finished and the settle back
73      * animation of the dot should start
74      */
75     private static final float OVERSHOOT_TIME_POSITION = 0.5f;
76 
77     /**
78      * The raw text size, will be multiplied by the scaled density when drawn
79      */
80     private int mTextHeightRaw;
81     private final int mGravity;
82     private ArrayList<CharState> mTextChars = new ArrayList<>();
83     private int mDotSize;
84     private PowerManager mPM;
85     private int mCharPadding;
86     private final Paint mDrawPaint = new Paint();
87     private int mDrawColor;
88     private Interpolator mAppearInterpolator;
89     private Interpolator mDisappearInterpolator;
90     private Interpolator mFastOutSlowInInterpolator;
91 
PasswordTextView(Context context)92     public PasswordTextView(Context context) {
93         this(context, null);
94     }
95 
PasswordTextView(Context context, AttributeSet attrs)96     public PasswordTextView(Context context, AttributeSet attrs) {
97         this(context, attrs, 0);
98     }
99 
PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr)100     public PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr) {
101         this(context, attrs, defStyleAttr, 0);
102     }
103 
PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)104     public PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr,
105             int defStyleRes) {
106         super(context, attrs, defStyleAttr, defStyleRes);
107         TypedArray a = context.obtainStyledAttributes(attrs, android.R.styleable.View);
108         try {
109             // If defined, use the provided values. If not, set them to true by default.
110             boolean isFocusable = a.getBoolean(android.R.styleable.View_focusable,
111                     /* defValue= */ true);
112             boolean isFocusableInTouchMode = a.getBoolean(
113                     android.R.styleable.View_focusableInTouchMode, /* defValue= */ true);
114             setFocusable(isFocusable);
115             setFocusableInTouchMode(isFocusableInTouchMode);
116         } finally {
117             a.recycle();
118         }
119         a = context.obtainStyledAttributes(attrs, R.styleable.PasswordTextView);
120         try {
121             mTextHeightRaw = a.getInt(R.styleable.PasswordTextView_scaledTextSize, 0);
122             mGravity = a.getInt(R.styleable.PasswordTextView_android_gravity, Gravity.CENTER);
123             mDotSize = a.getDimensionPixelSize(R.styleable.PasswordTextView_dotSize,
124                     getContext().getResources().getDimensionPixelSize(R.dimen.password_dot_size));
125             mCharPadding = a.getDimensionPixelSize(R.styleable.PasswordTextView_charPadding,
126                     getContext().getResources().getDimensionPixelSize(
127                             R.dimen.password_char_padding));
128             mDrawColor = a.getColor(R.styleable.PasswordTextView_android_textColor, Color.WHITE);
129             mDrawPaint.setColor(mDrawColor);
130 
131         } finally {
132             a.recycle();
133         }
134 
135         mDrawPaint.setFlags(Paint.SUBPIXEL_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG);
136         mDrawPaint.setTextAlign(Paint.Align.CENTER);
137         mDrawPaint.setTypeface(Typeface.create(
138                 context.getString(com.android.internal.R.string.config_headlineFontFamily), 0));
139         mAppearInterpolator = AnimationUtils.loadInterpolator(mContext,
140                 android.R.interpolator.linear_out_slow_in);
141         mDisappearInterpolator = AnimationUtils.loadInterpolator(mContext,
142                 android.R.interpolator.fast_out_linear_in);
143         mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator(mContext,
144                 android.R.interpolator.fast_out_slow_in);
145         mPM = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
146         setWillNotDraw(false);
147     }
148 
149     @Override
inflatePinShapeInput(boolean isPinHinting)150     protected PinShapeInput inflatePinShapeInput(boolean isPinHinting) {
151         if (isPinHinting) {
152             return (PinShapeInput) LayoutInflater.from(mContext).inflate(
153                     R.layout.keyguard_pin_shape_hinting_view, null);
154         } else {
155             return (PinShapeInput) LayoutInflater.from(mContext).inflate(
156                     R.layout.keyguard_pin_shape_non_hinting_view, null);
157         }
158     }
159 
160     @Override
shouldSendAccessibilityEvent()161     protected boolean shouldSendAccessibilityEvent() {
162         return isFocused() || isSelected() && isShown();
163     }
164 
165     @Override
onDraw(Canvas canvas)166     protected void onDraw(Canvas canvas) {
167         // Do not use legacy draw animations for pin shapes.
168         if (mUsePinShapes) {
169             super.onDraw(canvas);
170             return;
171         }
172 
173         float totalDrawingWidth = getDrawingWidth();
174         float currentDrawPosition;
175         if ((mGravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.LEFT) {
176             if ((mGravity & Gravity.RELATIVE_LAYOUT_DIRECTION) != 0
177                     && getLayoutDirection() == LAYOUT_DIRECTION_RTL) {
178                 currentDrawPosition = getWidth() - getPaddingRight() - totalDrawingWidth;
179             } else {
180                 currentDrawPosition = getPaddingLeft();
181             }
182         } else {
183             float maxRight = getWidth() - getPaddingRight() - totalDrawingWidth;
184             float center = getWidth() / 2f - totalDrawingWidth / 2f;
185             currentDrawPosition = center > 0 ? center : maxRight;
186         }
187         int length = mTextChars.size();
188         Rect bounds = getCharBounds();
189         int charHeight = (bounds.bottom - bounds.top);
190         float yPosition =
191                 (getHeight() - getPaddingBottom() - getPaddingTop()) / 2 + getPaddingTop();
192         canvas.clipRect(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(),
193                 getHeight() - getPaddingBottom());
194         float charLength = bounds.right - bounds.left;
195         for (int i = 0; i < length; i++) {
196             CharState charState = mTextChars.get(i);
197             float charWidth = charState.draw(canvas, currentDrawPosition, charHeight, yPosition,
198                     charLength);
199             currentDrawPosition += charWidth;
200         }
201     }
202 
203     @Override
onAppend(char c, int newLength)204     protected void onAppend(char c, int newLength) {
205         int visibleChars = mTextChars.size();
206         CharState charState;
207         if (newLength > visibleChars) {
208             charState = obtainCharState(c);
209             mTextChars.add(charState);
210         } else {
211             charState = mTextChars.get(newLength - 1);
212             charState.whichChar = c;
213         }
214         charState.startAppearAnimation();
215 
216         // ensure that the previous element is being swapped
217         if (newLength > 1) {
218             CharState previousState = mTextChars.get(newLength - 2);
219             if (previousState.isDotSwapPending) {
220                 previousState.swapToDotWhenAppearFinished();
221             }
222         }
223     }
224 
225     @Override
onDelete(int index)226     protected void onDelete(int index) {
227         CharState charState = mTextChars.get(index);
228         charState.startRemoveAnimation(0, 0);
229     }
230 
231     @Override
onReset(boolean animated)232     protected void onReset(boolean animated) {
233         if (animated) {
234             int length = mTextChars.size();
235             int middleIndex = (length - 1) / 2;
236             long delayPerElement = RESET_DELAY_PER_ELEMENT;
237             for (int i = 0; i < length; i++) {
238                 CharState charState = mTextChars.get(i);
239                 int delayIndex;
240                 if (i <= middleIndex) {
241                     delayIndex = i * 2;
242                 } else {
243                     int distToMiddle = i - middleIndex;
244                     delayIndex = (length - 1) - (distToMiddle - 1) * 2;
245                 }
246                 long startDelay = delayIndex * delayPerElement;
247                 startDelay = Math.min(startDelay, RESET_MAX_DELAY);
248                 long maxDelay = delayPerElement * (length - 1);
249                 maxDelay = Math.min(maxDelay, RESET_MAX_DELAY) + DISAPPEAR_DURATION;
250                 charState.startRemoveAnimation(startDelay, maxDelay);
251                 charState.removeDotSwapCallbacks();
252             }
253         } else {
254             mTextChars.clear();
255         }
256     }
257 
258     @Override
onUserActivity()259     protected void onUserActivity() {
260         mPM.userActivity(SystemClock.uptimeMillis(), false);
261         super.onUserActivity();
262     }
263 
264     /**
265      * Reload colors from resources.
266      **/
reloadColors()267     public void reloadColors() {
268         mDrawColor = Utils.getColorAttr(getContext(),
269                 android.R.attr.textColorPrimary).getDefaultColor();
270         mDrawPaint.setColor(mDrawColor);
271         if (mPinShapeInput != null) {
272             mPinShapeInput.setDrawColor(mDrawColor);
273         }
274     }
275 
276     @Override
onConfigurationChanged(Configuration newConfig)277     protected void onConfigurationChanged(Configuration newConfig) {
278         mTextHeightRaw = getContext().getResources().getInteger(
279                 R.integer.scaled_password_text_size);
280     }
281 
getCharBounds()282     private Rect getCharBounds() {
283         float textHeight = mTextHeightRaw * getResources().getDisplayMetrics().scaledDensity;
284         mDrawPaint.setTextSize(textHeight);
285         Rect bounds = new Rect();
286         mDrawPaint.getTextBounds("0", 0, 1, bounds);
287         return bounds;
288     }
289 
getDrawingWidth()290     private float getDrawingWidth() {
291         int width = 0;
292         int length = mTextChars.size();
293         Rect bounds = getCharBounds();
294         int charLength = bounds.right - bounds.left;
295         for (int i = 0; i < length; i++) {
296             CharState charState = mTextChars.get(i);
297             if (i != 0) {
298                 width += mCharPadding * charState.currentWidthFactor;
299             }
300             width += charLength * charState.currentWidthFactor;
301         }
302         return width;
303     }
304 
obtainCharState(char c)305     private CharState obtainCharState(char c) {
306         CharState charState = new CharState();
307         charState.whichChar = c;
308         return charState;
309     }
310 
311     @Override
getTransformedText()312     protected CharSequence getTransformedText() {
313         int textLength = mTextChars.size();
314         StringBuilder stringBuilder = new StringBuilder(textLength);
315         for (int i = 0; i < textLength; i++) {
316             CharState charState = mTextChars.get(i);
317             // If the dot is disappearing, the character is disappearing entirely. Consider
318             // it gone.
319             if (charState.dotAnimator != null && !charState.dotAnimationIsGrowing) {
320                 continue;
321             }
322             stringBuilder.append(charState.isCharVisibleForA11y() ? charState.whichChar : DOT);
323         }
324         return stringBuilder;
325     }
326 
327     private class CharState {
328         char whichChar;
329         ValueAnimator textAnimator;
330         boolean textAnimationIsGrowing;
331         Animator dotAnimator;
332         boolean dotAnimationIsGrowing;
333         ValueAnimator widthAnimator;
334         boolean widthAnimationIsGrowing;
335         float currentTextSizeFactor;
336         float currentDotSizeFactor;
337         float currentWidthFactor;
338         boolean isDotSwapPending;
339         float currentTextTranslationY = 1.0f;
340         ValueAnimator textTranslateAnimator;
341 
342         Animator.AnimatorListener removeEndListener = new AnimatorListenerAdapter() {
343             private boolean mCancelled;
344 
345             @Override
346             public void onAnimationCancel(Animator animation) {
347                 mCancelled = true;
348             }
349 
350             @Override
351             public void onAnimationEnd(Animator animation) {
352                 if (!mCancelled) {
353                     mTextChars.remove(CharState.this);
354                     cancelAnimator(textTranslateAnimator);
355                     textTranslateAnimator = null;
356                 }
357             }
358 
359             @Override
360             public void onAnimationStart(Animator animation) {
361                 mCancelled = false;
362             }
363         };
364 
365         Animator.AnimatorListener dotFinishListener = new AnimatorListenerAdapter() {
366             @Override
367             public void onAnimationEnd(Animator animation) {
368                 dotAnimator = null;
369             }
370         };
371 
372         Animator.AnimatorListener textFinishListener = new AnimatorListenerAdapter() {
373             @Override
374             public void onAnimationEnd(Animator animation) {
375                 textAnimator = null;
376             }
377         };
378 
379         Animator.AnimatorListener textTranslateFinishListener = new AnimatorListenerAdapter() {
380             @Override
381             public void onAnimationEnd(Animator animation) {
382                 textTranslateAnimator = null;
383             }
384         };
385 
386         Animator.AnimatorListener widthFinishListener = new AnimatorListenerAdapter() {
387             @Override
388             public void onAnimationEnd(Animator animation) {
389                 widthAnimator = null;
390             }
391         };
392 
393         private ValueAnimator.AnimatorUpdateListener mDotSizeUpdater =
394                 new ValueAnimator.AnimatorUpdateListener() {
395                     @Override
396                     public void onAnimationUpdate(ValueAnimator animation) {
397                         currentDotSizeFactor = (float) animation.getAnimatedValue();
398                         invalidate();
399                     }
400                 };
401 
402         private ValueAnimator.AnimatorUpdateListener mTextSizeUpdater =
403                 new ValueAnimator.AnimatorUpdateListener() {
404                     @Override
405                     public void onAnimationUpdate(ValueAnimator animation) {
406                         boolean textVisibleBefore = isCharVisibleForA11y();
407                         float beforeTextSizeFactor = currentTextSizeFactor;
408                         currentTextSizeFactor = (float) animation.getAnimatedValue();
409                         if (textVisibleBefore != isCharVisibleForA11y()) {
410                             currentTextSizeFactor = beforeTextSizeFactor;
411                             CharSequence beforeText = getTransformedText();
412                             currentTextSizeFactor = (float) animation.getAnimatedValue();
413                             int indexOfThisChar = mTextChars.indexOf(CharState.this);
414                             if (indexOfThisChar >= 0) {
415                                 sendAccessibilityEventTypeViewTextChanged(beforeText,
416                                         indexOfThisChar, 1, 1);
417                             }
418                         }
419                         invalidate();
420                     }
421                 };
422 
423         private ValueAnimator.AnimatorUpdateListener mTextTranslationUpdater =
424                 new ValueAnimator.AnimatorUpdateListener() {
425                     @Override
426                     public void onAnimationUpdate(ValueAnimator animation) {
427                         currentTextTranslationY = (float) animation.getAnimatedValue();
428                         invalidate();
429                     }
430                 };
431 
432         private ValueAnimator.AnimatorUpdateListener mWidthUpdater =
433                 new ValueAnimator.AnimatorUpdateListener() {
434                     @Override
435                     public void onAnimationUpdate(ValueAnimator animation) {
436                         currentWidthFactor = (float) animation.getAnimatedValue();
437                         invalidate();
438                     }
439                 };
440 
441         private Runnable dotSwapperRunnable = new Runnable() {
442             @Override
443             public void run() {
444                 performSwap();
445                 isDotSwapPending = false;
446             }
447         };
448 
startRemoveAnimation(long startDelay, long widthDelay)449         void startRemoveAnimation(long startDelay, long widthDelay) {
450             boolean dotNeedsAnimation =
451                     (currentDotSizeFactor > 0.0f && dotAnimator == null) || (dotAnimator != null
452                             && dotAnimationIsGrowing);
453             boolean textNeedsAnimation =
454                     (currentTextSizeFactor > 0.0f && textAnimator == null) || (textAnimator != null
455                             && textAnimationIsGrowing);
456             boolean widthNeedsAnimation =
457                     (currentWidthFactor > 0.0f && widthAnimator == null) || (widthAnimator != null
458                             && widthAnimationIsGrowing);
459             if (dotNeedsAnimation) {
460                 startDotDisappearAnimation(startDelay);
461             }
462             if (textNeedsAnimation) {
463                 startTextDisappearAnimation(startDelay);
464             }
465             if (widthNeedsAnimation) {
466                 startWidthDisappearAnimation(widthDelay);
467             }
468         }
469 
startAppearAnimation()470         void startAppearAnimation() {
471             boolean dotNeedsAnimation =
472                     !mShowPassword && (dotAnimator == null || !dotAnimationIsGrowing);
473             boolean textNeedsAnimation =
474                     mShowPassword && (textAnimator == null || !textAnimationIsGrowing);
475             boolean widthNeedsAnimation = (widthAnimator == null || !widthAnimationIsGrowing);
476             if (dotNeedsAnimation) {
477                 startDotAppearAnimation(0);
478             }
479             if (textNeedsAnimation) {
480                 startTextAppearAnimation();
481             }
482             if (widthNeedsAnimation) {
483                 startWidthAppearAnimation();
484             }
485             if (mShowPassword) {
486                 postDotSwap(TEXT_VISIBILITY_DURATION);
487             }
488         }
489 
490         /**
491          * Posts a runnable which ensures that the text will be replaced by a dot after {@link
492          * com.android.keyguard.PasswordTextView#TEXT_VISIBILITY_DURATION}.
493          */
postDotSwap(long delay)494         private void postDotSwap(long delay) {
495             removeDotSwapCallbacks();
496             postDelayed(dotSwapperRunnable, delay);
497             isDotSwapPending = true;
498         }
499 
removeDotSwapCallbacks()500         private void removeDotSwapCallbacks() {
501             removeCallbacks(dotSwapperRunnable);
502             isDotSwapPending = false;
503         }
504 
swapToDotWhenAppearFinished()505         void swapToDotWhenAppearFinished() {
506             removeDotSwapCallbacks();
507             if (textAnimator != null) {
508                 long remainingDuration =
509                         textAnimator.getDuration() - textAnimator.getCurrentPlayTime();
510                 postDotSwap(remainingDuration + TEXT_REST_DURATION_AFTER_APPEAR);
511             } else {
512                 performSwap();
513             }
514         }
515 
performSwap()516         private void performSwap() {
517             startTextDisappearAnimation(0);
518             startDotAppearAnimation(
519                     DISAPPEAR_DURATION - DOT_APPEAR_TEXT_DISAPPEAR_OVERLAP_DURATION);
520         }
521 
startWidthDisappearAnimation(long widthDelay)522         private void startWidthDisappearAnimation(long widthDelay) {
523             cancelAnimator(widthAnimator);
524             widthAnimator = ValueAnimator.ofFloat(currentWidthFactor, 0.0f);
525             widthAnimator.addUpdateListener(mWidthUpdater);
526             widthAnimator.addListener(widthFinishListener);
527             widthAnimator.addListener(removeEndListener);
528             widthAnimator.setDuration((long) (DISAPPEAR_DURATION * currentWidthFactor));
529             widthAnimator.setStartDelay(widthDelay);
530             widthAnimator.start();
531             widthAnimationIsGrowing = false;
532         }
533 
startTextDisappearAnimation(long startDelay)534         private void startTextDisappearAnimation(long startDelay) {
535             cancelAnimator(textAnimator);
536             textAnimator = ValueAnimator.ofFloat(currentTextSizeFactor, 0.0f);
537             textAnimator.addUpdateListener(mTextSizeUpdater);
538             textAnimator.addListener(textFinishListener);
539             textAnimator.setInterpolator(mDisappearInterpolator);
540             textAnimator.setDuration((long) (DISAPPEAR_DURATION * currentTextSizeFactor));
541             textAnimator.setStartDelay(startDelay);
542             textAnimator.start();
543             textAnimationIsGrowing = false;
544         }
545 
startDotDisappearAnimation(long startDelay)546         private void startDotDisappearAnimation(long startDelay) {
547             cancelAnimator(dotAnimator);
548             ValueAnimator animator = ValueAnimator.ofFloat(currentDotSizeFactor, 0.0f);
549             animator.addUpdateListener(mDotSizeUpdater);
550             animator.addListener(dotFinishListener);
551             animator.setInterpolator(mDisappearInterpolator);
552             long duration = (long) (DISAPPEAR_DURATION * Math.min(currentDotSizeFactor, 1.0f));
553             animator.setDuration(duration);
554             animator.setStartDelay(startDelay);
555             animator.start();
556             dotAnimator = animator;
557             dotAnimationIsGrowing = false;
558         }
559 
startWidthAppearAnimation()560         private void startWidthAppearAnimation() {
561             cancelAnimator(widthAnimator);
562             widthAnimator = ValueAnimator.ofFloat(currentWidthFactor, 1.0f);
563             widthAnimator.addUpdateListener(mWidthUpdater);
564             widthAnimator.addListener(widthFinishListener);
565             widthAnimator.setDuration((long) (APPEAR_DURATION * (1f - currentWidthFactor)));
566             widthAnimator.start();
567             widthAnimationIsGrowing = true;
568         }
569 
startTextAppearAnimation()570         private void startTextAppearAnimation() {
571             cancelAnimator(textAnimator);
572             textAnimator = ValueAnimator.ofFloat(currentTextSizeFactor, 1.0f);
573             textAnimator.addUpdateListener(mTextSizeUpdater);
574             textAnimator.addListener(textFinishListener);
575             textAnimator.setInterpolator(mAppearInterpolator);
576             textAnimator.setDuration((long) (APPEAR_DURATION * (1f - currentTextSizeFactor)));
577             textAnimator.start();
578             textAnimationIsGrowing = true;
579 
580             // handle translation
581             if (textTranslateAnimator == null) {
582                 textTranslateAnimator = ValueAnimator.ofFloat(1.0f, 0.0f);
583                 textTranslateAnimator.addUpdateListener(mTextTranslationUpdater);
584                 textTranslateAnimator.addListener(textTranslateFinishListener);
585                 textTranslateAnimator.setInterpolator(mAppearInterpolator);
586                 textTranslateAnimator.setDuration(APPEAR_DURATION);
587                 textTranslateAnimator.start();
588             }
589         }
590 
startDotAppearAnimation(long delay)591         private void startDotAppearAnimation(long delay) {
592             cancelAnimator(dotAnimator);
593             if (!mShowPassword) {
594                 // We perform an overshoot animation
595                 ValueAnimator overShootAnimator = ValueAnimator.ofFloat(currentDotSizeFactor,
596                         DOT_OVERSHOOT_FACTOR);
597                 overShootAnimator.addUpdateListener(mDotSizeUpdater);
598                 overShootAnimator.setInterpolator(mAppearInterpolator);
599                 long overShootDuration =
600                         (long) (DOT_APPEAR_DURATION_OVERSHOOT * OVERSHOOT_TIME_POSITION);
601                 overShootAnimator.setDuration(overShootDuration);
602                 ValueAnimator settleBackAnimator = ValueAnimator.ofFloat(DOT_OVERSHOOT_FACTOR,
603                         1.0f);
604                 settleBackAnimator.addUpdateListener(mDotSizeUpdater);
605                 settleBackAnimator.setDuration(DOT_APPEAR_DURATION_OVERSHOOT - overShootDuration);
606                 settleBackAnimator.addListener(dotFinishListener);
607                 AnimatorSet animatorSet = new AnimatorSet();
608                 animatorSet.playSequentially(overShootAnimator, settleBackAnimator);
609                 animatorSet.setStartDelay(delay);
610                 animatorSet.start();
611                 dotAnimator = animatorSet;
612             } else {
613                 ValueAnimator growAnimator = ValueAnimator.ofFloat(currentDotSizeFactor, 1.0f);
614                 growAnimator.addUpdateListener(mDotSizeUpdater);
615                 growAnimator.setDuration((long) (APPEAR_DURATION * (1.0f - currentDotSizeFactor)));
616                 growAnimator.addListener(dotFinishListener);
617                 growAnimator.setStartDelay(delay);
618                 growAnimator.start();
619                 dotAnimator = growAnimator;
620             }
621             dotAnimationIsGrowing = true;
622         }
623 
cancelAnimator(Animator animator)624         private void cancelAnimator(Animator animator) {
625             if (animator != null) {
626                 animator.cancel();
627             }
628         }
629 
630         /**
631          * Draw this char to the canvas.
632          *
633          * @return The width this character contributes, including padding.
634          */
draw(Canvas canvas, float currentDrawPosition, int charHeight, float yPosition, float charLength)635         public float draw(Canvas canvas, float currentDrawPosition, int charHeight, float yPosition,
636                 float charLength) {
637             boolean textVisible = currentTextSizeFactor > 0;
638             boolean dotVisible = currentDotSizeFactor > 0;
639             float charWidth = charLength * currentWidthFactor;
640             if (textVisible) {
641                 float currYPosition = yPosition + charHeight / 2.0f * currentTextSizeFactor
642                         + charHeight * currentTextTranslationY * 0.8f;
643                 canvas.save();
644                 float centerX = currentDrawPosition + charWidth / 2;
645                 canvas.translate(centerX, currYPosition);
646                 canvas.scale(currentTextSizeFactor, currentTextSizeFactor);
647                 canvas.drawText(Character.toString(whichChar), 0, 0, mDrawPaint);
648                 canvas.restore();
649             }
650             if (dotVisible) {
651                 canvas.save();
652                 float centerX = currentDrawPosition + charWidth / 2;
653                 canvas.translate(centerX, yPosition);
654                 canvas.drawCircle(0, 0, mDotSize / 2 * currentDotSizeFactor, mDrawPaint);
655                 canvas.restore();
656             }
657             return charWidth + mCharPadding * currentWidthFactor;
658         }
659 
isCharVisibleForA11y()660         public boolean isCharVisibleForA11y() {
661             // The text has size 0 when it is first added, but we want to count it as visible if
662             // it will become visible presently. Count text as visible if an animator
663             // is configured to make it grow.
664             boolean textIsGrowing = textAnimator != null && textAnimationIsGrowing;
665             return (currentTextSizeFactor > 0) || textIsGrowing;
666         }
667     }
668 }
669