/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License */ package com.android.keyguard; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Typeface; import android.os.PowerManager; import android.os.SystemClock; import android.provider.Settings; import android.text.InputType; import android.util.AttributeSet; import android.view.Gravity; import android.view.View; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; import android.widget.EditText; import java.util.ArrayList; import java.util.Stack; /** * A View similar to a textView which contains password text and can animate when the text is * changed */ public class PasswordTextView extends View { private static final float DOT_OVERSHOOT_FACTOR = 1.5f; private static final long DOT_APPEAR_DURATION_OVERSHOOT = 320; private static final long APPEAR_DURATION = 160; private static final long DISAPPEAR_DURATION = 160; private static final long RESET_DELAY_PER_ELEMENT = 40; private static final long RESET_MAX_DELAY = 200; /** * The overlap between the text disappearing and the dot appearing animation */ private static final long DOT_APPEAR_TEXT_DISAPPEAR_OVERLAP_DURATION = 130; /** * The duration the text needs to stay there at least before it can morph into a dot */ private static final long TEXT_REST_DURATION_AFTER_APPEAR = 100; /** * The duration the text should be visible, starting with the appear animation */ private static final long TEXT_VISIBILITY_DURATION = 1300; /** * The position in time from [0,1] where the overshoot should be finished and the settle back * animation of the dot should start */ private static final float OVERSHOOT_TIME_POSITION = 0.5f; /** * The raw text size, will be multiplied by the scaled density when drawn */ private final int mTextHeightRaw; private final int mGravity; private ArrayList mTextChars = new ArrayList<>(); private String mText = ""; private Stack mCharPool = new Stack<>(); private int mDotSize; private PowerManager mPM; private int mCharPadding; private final Paint mDrawPaint = new Paint(); private Interpolator mAppearInterpolator; private Interpolator mDisappearInterpolator; private Interpolator mFastOutSlowInInterpolator; private boolean mShowPassword; private UserActivityListener mUserActivityListener; public interface UserActivityListener { void onUserActivity(); } public PasswordTextView(Context context) { this(context, null); } public PasswordTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); setFocusableInTouchMode(true); setFocusable(true); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.PasswordTextView); try { mTextHeightRaw = a.getInt(R.styleable.PasswordTextView_scaledTextSize, 0); mGravity = a.getInt(R.styleable.PasswordTextView_android_gravity, Gravity.CENTER); mDotSize = a.getDimensionPixelSize(R.styleable.PasswordTextView_dotSize, getContext().getResources().getDimensionPixelSize(R.dimen.password_dot_size)); mCharPadding = a.getDimensionPixelSize(R.styleable.PasswordTextView_charPadding, getContext().getResources().getDimensionPixelSize( R.dimen.password_char_padding)); } finally { a.recycle(); } mDrawPaint.setFlags(Paint.SUBPIXEL_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG); mDrawPaint.setTextAlign(Paint.Align.CENTER); mDrawPaint.setColor(0xffffffff); mDrawPaint.setTypeface(Typeface.create("sans-serif-light", 0)); mShowPassword = Settings.System.getInt(mContext.getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 1) == 1; mAppearInterpolator = AnimationUtils.loadInterpolator(mContext, android.R.interpolator.linear_out_slow_in); mDisappearInterpolator = AnimationUtils.loadInterpolator(mContext, android.R.interpolator.fast_out_linear_in); mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator(mContext, android.R.interpolator.fast_out_slow_in); mPM = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); } @Override protected void onDraw(Canvas canvas) { float totalDrawingWidth = getDrawingWidth(); float currentDrawPosition; if ((mGravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.LEFT) { if ((mGravity & Gravity.RELATIVE_LAYOUT_DIRECTION) != 0 && getLayoutDirection() == LAYOUT_DIRECTION_RTL) { currentDrawPosition = getWidth() - getPaddingRight() - totalDrawingWidth; } else { currentDrawPosition = getPaddingLeft(); } } else { currentDrawPosition = getWidth() / 2 - totalDrawingWidth / 2; } int length = mTextChars.size(); Rect bounds = getCharBounds(); int charHeight = (bounds.bottom - bounds.top); float yPosition = (getHeight() - getPaddingBottom() - getPaddingTop()) / 2 + getPaddingTop(); canvas.clipRect(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(), getHeight() - getPaddingBottom()); float charLength = bounds.right - bounds.left; for (int i = 0; i < length; i++) { CharState charState = mTextChars.get(i); float charWidth = charState.draw(canvas, currentDrawPosition, charHeight, yPosition, charLength); currentDrawPosition += charWidth; } } @Override public boolean hasOverlappingRendering() { return false; } private Rect getCharBounds() { float textHeight = mTextHeightRaw * getResources().getDisplayMetrics().scaledDensity; mDrawPaint.setTextSize(textHeight); Rect bounds = new Rect(); mDrawPaint.getTextBounds("0", 0, 1, bounds); return bounds; } private float getDrawingWidth() { int width = 0; int length = mTextChars.size(); Rect bounds = getCharBounds(); int charLength = bounds.right - bounds.left; for (int i = 0; i < length; i++) { CharState charState = mTextChars.get(i); if (i != 0) { width += mCharPadding * charState.currentWidthFactor; } width += charLength * charState.currentWidthFactor; } return width; } public void append(char c) { int visibleChars = mTextChars.size(); String textbefore = mText; mText = mText + c; int newLength = mText.length(); CharState charState; if (newLength > visibleChars) { charState = obtainCharState(c); mTextChars.add(charState); } else { charState = mTextChars.get(newLength - 1); charState.whichChar = c; } charState.startAppearAnimation(); // ensure that the previous element is being swapped if (newLength > 1) { CharState previousState = mTextChars.get(newLength - 2); if (previousState.isDotSwapPending) { previousState.swapToDotWhenAppearFinished(); } } userActivity(); sendAccessibilityEventTypeViewTextChanged(textbefore, textbefore.length(), 0, 1); } public void setUserActivityListener(UserActivityListener userActivitiListener) { mUserActivityListener = userActivitiListener; } private void userActivity() { mPM.userActivity(SystemClock.uptimeMillis(), false); if (mUserActivityListener != null) { mUserActivityListener.onUserActivity(); } } public void deleteLastChar() { int length = mText.length(); String textbefore = mText; if (length > 0) { mText = mText.substring(0, length - 1); CharState charState = mTextChars.get(length - 1); charState.startRemoveAnimation(0, 0); sendAccessibilityEventTypeViewTextChanged(textbefore, textbefore.length() - 1, 1, 0); } userActivity(); } public String getText() { return mText; } private CharState obtainCharState(char c) { CharState charState; if(mCharPool.isEmpty()) { charState = new CharState(); } else { charState = mCharPool.pop(); charState.reset(); } charState.whichChar = c; return charState; } public void reset(boolean animated, boolean announce) { String textbefore = mText; mText = ""; int length = mTextChars.size(); int middleIndex = (length - 1) / 2; long delayPerElement = RESET_DELAY_PER_ELEMENT; for (int i = 0; i < length; i++) { CharState charState = mTextChars.get(i); if (animated) { int delayIndex; if (i <= middleIndex) { delayIndex = i * 2; } else { int distToMiddle = i - middleIndex; delayIndex = (length - 1) - (distToMiddle - 1) * 2; } long startDelay = delayIndex * delayPerElement; startDelay = Math.min(startDelay, RESET_MAX_DELAY); long maxDelay = delayPerElement * (length - 1); maxDelay = Math.min(maxDelay, RESET_MAX_DELAY) + DISAPPEAR_DURATION; charState.startRemoveAnimation(startDelay, maxDelay); charState.removeDotSwapCallbacks(); } else { mCharPool.push(charState); } } if (!animated) { mTextChars.clear(); } if (announce) { sendAccessibilityEventTypeViewTextChanged(textbefore, 0, textbefore.length(), 0); } } void sendAccessibilityEventTypeViewTextChanged(String beforeText, int fromIndex, int removedCount, int addedCount) { if (AccessibilityManager.getInstance(mContext).isEnabled() && (isFocused() || isSelected() && isShown())) { AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED); event.setFromIndex(fromIndex); event.setRemovedCount(removedCount); event.setAddedCount(addedCount); event.setBeforeText(beforeText); event.setPassword(true); sendAccessibilityEventUnchecked(event); } } @Override public void onInitializeAccessibilityEvent(AccessibilityEvent event) { super.onInitializeAccessibilityEvent(event); event.setClassName(EditText.class.getName()); event.setPassword(true); } @Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(info); info.setClassName(PasswordTextView.class.getName()); info.setPassword(true); info.setEditable(true); info.setInputType(InputType.TYPE_NUMBER_VARIATION_PASSWORD); } private class CharState { char whichChar; ValueAnimator textAnimator; boolean textAnimationIsGrowing; Animator dotAnimator; boolean dotAnimationIsGrowing; ValueAnimator widthAnimator; boolean widthAnimationIsGrowing; float currentTextSizeFactor; float currentDotSizeFactor; float currentWidthFactor; boolean isDotSwapPending; float currentTextTranslationY = 1.0f; ValueAnimator textTranslateAnimator; Animator.AnimatorListener removeEndListener = new AnimatorListenerAdapter() { private boolean mCancelled; @Override public void onAnimationCancel(Animator animation) { mCancelled = true; } @Override public void onAnimationEnd(Animator animation) { if (!mCancelled) { mTextChars.remove(CharState.this); mCharPool.push(CharState.this); reset(); cancelAnimator(textTranslateAnimator); textTranslateAnimator = null; } } @Override public void onAnimationStart(Animator animation) { mCancelled = false; } }; Animator.AnimatorListener dotFinishListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { dotAnimator = null; } }; Animator.AnimatorListener textFinishListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { textAnimator = null; } }; Animator.AnimatorListener textTranslateFinishListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { textTranslateAnimator = null; } }; Animator.AnimatorListener widthFinishListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { widthAnimator = null; } }; private ValueAnimator.AnimatorUpdateListener dotSizeUpdater = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { currentDotSizeFactor = (float) animation.getAnimatedValue(); invalidate(); } }; private ValueAnimator.AnimatorUpdateListener textSizeUpdater = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { currentTextSizeFactor = (float) animation.getAnimatedValue(); invalidate(); } }; private ValueAnimator.AnimatorUpdateListener textTranslationUpdater = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { currentTextTranslationY = (float) animation.getAnimatedValue(); invalidate(); } }; private ValueAnimator.AnimatorUpdateListener widthUpdater = new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { currentWidthFactor = (float) animation.getAnimatedValue(); invalidate(); } }; private Runnable dotSwapperRunnable = new Runnable() { @Override public void run() { performSwap(); isDotSwapPending = false; } }; void reset() { whichChar = 0; currentTextSizeFactor = 0.0f; currentDotSizeFactor = 0.0f; currentWidthFactor = 0.0f; cancelAnimator(textAnimator); textAnimator = null; cancelAnimator(dotAnimator); dotAnimator = null; cancelAnimator(widthAnimator); widthAnimator = null; currentTextTranslationY = 1.0f; removeDotSwapCallbacks(); } void startRemoveAnimation(long startDelay, long widthDelay) { boolean dotNeedsAnimation = (currentDotSizeFactor > 0.0f && dotAnimator == null) || (dotAnimator != null && dotAnimationIsGrowing); boolean textNeedsAnimation = (currentTextSizeFactor > 0.0f && textAnimator == null) || (textAnimator != null && textAnimationIsGrowing); boolean widthNeedsAnimation = (currentWidthFactor > 0.0f && widthAnimator == null) || (widthAnimator != null && widthAnimationIsGrowing); if (dotNeedsAnimation) { startDotDisappearAnimation(startDelay); } if (textNeedsAnimation) { startTextDisappearAnimation(startDelay); } if (widthNeedsAnimation) { startWidthDisappearAnimation(widthDelay); } } void startAppearAnimation() { boolean dotNeedsAnimation = !mShowPassword && (dotAnimator == null || !dotAnimationIsGrowing); boolean textNeedsAnimation = mShowPassword && (textAnimator == null || !textAnimationIsGrowing); boolean widthNeedsAnimation = (widthAnimator == null || !widthAnimationIsGrowing); if (dotNeedsAnimation) { startDotAppearAnimation(0); } if (textNeedsAnimation) { startTextAppearAnimation(); } if (widthNeedsAnimation) { startWidthAppearAnimation(); } if (mShowPassword) { postDotSwap(TEXT_VISIBILITY_DURATION); } } /** * Posts a runnable which ensures that the text will be replaced by a dot after {@link * com.android.keyguard.PasswordTextView#TEXT_VISIBILITY_DURATION}. */ private void postDotSwap(long delay) { removeDotSwapCallbacks(); postDelayed(dotSwapperRunnable, delay); isDotSwapPending = true; } private void removeDotSwapCallbacks() { removeCallbacks(dotSwapperRunnable); isDotSwapPending = false; } void swapToDotWhenAppearFinished() { removeDotSwapCallbacks(); if (textAnimator != null) { long remainingDuration = textAnimator.getDuration() - textAnimator.getCurrentPlayTime(); postDotSwap(remainingDuration + TEXT_REST_DURATION_AFTER_APPEAR); } else { performSwap(); } } private void performSwap() { startTextDisappearAnimation(0); startDotAppearAnimation(DISAPPEAR_DURATION - DOT_APPEAR_TEXT_DISAPPEAR_OVERLAP_DURATION); } private void startWidthDisappearAnimation(long widthDelay) { cancelAnimator(widthAnimator); widthAnimator = ValueAnimator.ofFloat(currentWidthFactor, 0.0f); widthAnimator.addUpdateListener(widthUpdater); widthAnimator.addListener(widthFinishListener); widthAnimator.addListener(removeEndListener); widthAnimator.setDuration((long) (DISAPPEAR_DURATION * currentWidthFactor)); widthAnimator.setStartDelay(widthDelay); widthAnimator.start(); widthAnimationIsGrowing = false; } private void startTextDisappearAnimation(long startDelay) { cancelAnimator(textAnimator); textAnimator = ValueAnimator.ofFloat(currentTextSizeFactor, 0.0f); textAnimator.addUpdateListener(textSizeUpdater); textAnimator.addListener(textFinishListener); textAnimator.setInterpolator(mDisappearInterpolator); textAnimator.setDuration((long) (DISAPPEAR_DURATION * currentTextSizeFactor)); textAnimator.setStartDelay(startDelay); textAnimator.start(); textAnimationIsGrowing = false; } private void startDotDisappearAnimation(long startDelay) { cancelAnimator(dotAnimator); ValueAnimator animator = ValueAnimator.ofFloat(currentDotSizeFactor, 0.0f); animator.addUpdateListener(dotSizeUpdater); animator.addListener(dotFinishListener); animator.setInterpolator(mDisappearInterpolator); long duration = (long) (DISAPPEAR_DURATION * Math.min(currentDotSizeFactor, 1.0f)); animator.setDuration(duration); animator.setStartDelay(startDelay); animator.start(); dotAnimator = animator; dotAnimationIsGrowing = false; } private void startWidthAppearAnimation() { cancelAnimator(widthAnimator); widthAnimator = ValueAnimator.ofFloat(currentWidthFactor, 1.0f); widthAnimator.addUpdateListener(widthUpdater); widthAnimator.addListener(widthFinishListener); widthAnimator.setDuration((long) (APPEAR_DURATION * (1f - currentWidthFactor))); widthAnimator.start(); widthAnimationIsGrowing = true; } private void startTextAppearAnimation() { cancelAnimator(textAnimator); textAnimator = ValueAnimator.ofFloat(currentTextSizeFactor, 1.0f); textAnimator.addUpdateListener(textSizeUpdater); textAnimator.addListener(textFinishListener); textAnimator.setInterpolator(mAppearInterpolator); textAnimator.setDuration((long) (APPEAR_DURATION * (1f - currentTextSizeFactor))); textAnimator.start(); textAnimationIsGrowing = true; // handle translation if (textTranslateAnimator == null) { textTranslateAnimator = ValueAnimator.ofFloat(1.0f, 0.0f); textTranslateAnimator.addUpdateListener(textTranslationUpdater); textTranslateAnimator.addListener(textTranslateFinishListener); textTranslateAnimator.setInterpolator(mAppearInterpolator); textTranslateAnimator.setDuration(APPEAR_DURATION); textTranslateAnimator.start(); } } private void startDotAppearAnimation(long delay) { cancelAnimator(dotAnimator); if (!mShowPassword) { // We perform an overshoot animation ValueAnimator overShootAnimator = ValueAnimator.ofFloat(currentDotSizeFactor, DOT_OVERSHOOT_FACTOR); overShootAnimator.addUpdateListener(dotSizeUpdater); overShootAnimator.setInterpolator(mAppearInterpolator); long overShootDuration = (long) (DOT_APPEAR_DURATION_OVERSHOOT * OVERSHOOT_TIME_POSITION); overShootAnimator.setDuration(overShootDuration); ValueAnimator settleBackAnimator = ValueAnimator.ofFloat(DOT_OVERSHOOT_FACTOR, 1.0f); settleBackAnimator.addUpdateListener(dotSizeUpdater); settleBackAnimator.setDuration(DOT_APPEAR_DURATION_OVERSHOOT - overShootDuration); settleBackAnimator.addListener(dotFinishListener); AnimatorSet animatorSet = new AnimatorSet(); animatorSet.playSequentially(overShootAnimator, settleBackAnimator); animatorSet.setStartDelay(delay); animatorSet.start(); dotAnimator = animatorSet; } else { ValueAnimator growAnimator = ValueAnimator.ofFloat(currentDotSizeFactor, 1.0f); growAnimator.addUpdateListener(dotSizeUpdater); growAnimator.setDuration((long) (APPEAR_DURATION * (1.0f - currentDotSizeFactor))); growAnimator.addListener(dotFinishListener); growAnimator.setStartDelay(delay); growAnimator.start(); dotAnimator = growAnimator; } dotAnimationIsGrowing = true; } private void cancelAnimator(Animator animator) { if (animator != null) { animator.cancel(); } } /** * Draw this char to the canvas. * * @return The width this character contributes, including padding. */ public float draw(Canvas canvas, float currentDrawPosition, int charHeight, float yPosition, float charLength) { boolean textVisible = currentTextSizeFactor > 0; boolean dotVisible = currentDotSizeFactor > 0; float charWidth = charLength * currentWidthFactor; if (textVisible) { float currYPosition = yPosition + charHeight / 2.0f * currentTextSizeFactor + charHeight * currentTextTranslationY * 0.8f; canvas.save(); float centerX = currentDrawPosition + charWidth / 2; canvas.translate(centerX, currYPosition); canvas.scale(currentTextSizeFactor, currentTextSizeFactor); canvas.drawText(Character.toString(whichChar), 0, 0, mDrawPaint); canvas.restore(); } if (dotVisible) { canvas.save(); float centerX = currentDrawPosition + charWidth / 2; canvas.translate(centerX, yPosition); canvas.drawCircle(0, 0, mDotSize / 2 * currentDotSizeFactor, mDrawPaint); canvas.restore(); } return charWidth + mCharPadding * currentWidthFactor; } } }