/* * Copyright 2016 Google Inc. * * 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 io.plaidapp.ui.widget; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.ColorFilter; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; import android.support.design.widget.TextInputEditText; import android.text.TextPaint; import android.text.method.PasswordTransformationMethod; import android.util.AttributeSet; import android.view.animation.Interpolator; import io.plaidapp.R; import io.plaidapp.util.AnimUtils; import static io.plaidapp.util.AnimUtils.lerp; /** * A password entry widget which animates switching between masked and visible text. */ public class PasswordEntry extends TextInputEditText { static final char[] PASSWORD_MASK = { '\u2022' }; // PasswordTransformationMethod#DOT private boolean passwordMasked = false; private MaskMorphDrawable maskDrawable; private ColorStateList textColor; public PasswordEntry(Context context) { super(context); passwordMasked = getTransformationMethod() instanceof PasswordTransformationMethod; } public PasswordEntry(Context context, AttributeSet attrs) { super(context, attrs); passwordMasked = getTransformationMethod() instanceof PasswordTransformationMethod; } public PasswordEntry(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); passwordMasked = getTransformationMethod() instanceof PasswordTransformationMethod; } /** * Want to monitor when password mode is set but #setTransformationMethod is final :( Instead * override #setText (which it calls through to) & check the transformation method. */ @Override public void setText(CharSequence text, BufferType type) { super.setText(text, type); boolean isMasked = getTransformationMethod() instanceof PasswordTransformationMethod; if (isMasked != passwordMasked) { passwordMasked = isMasked; passwordVisibilityToggled(isMasked, text); } } @Override public void setTextColor(ColorStateList colors) { super.setTextColor(colors); textColor = colors; } private void passwordVisibilityToggled(boolean isMasked, CharSequence password) { if (maskDrawable == null) { // lazily create the drawable that morphs the dots if (!isLaidOut() || getText().length() < 1) return; maskDrawable = new MaskMorphDrawable(getContext(), getPaint(), getBaseline(), getLayout().getPrimaryHorizontal(1), getTextLeft()); maskDrawable.setBounds(getPaddingLeft(), getPaddingTop(), getPaddingLeft(), getHeight() - getPaddingBottom()); getOverlay().add(maskDrawable); } // hide the text during the animation setTextColor(Color.TRANSPARENT); Animator maskMorph = isMasked ? maskDrawable.createShowMaskAnimator(password) : maskDrawable.createHideMaskAnimator(password); maskMorph.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setTextColor(textColor); // restore the proper text color } }); maskMorph.start(); } private int getTextLeft() { int left = 0; if (getBackground() instanceof InsetDrawable) { InsetDrawable back = (InsetDrawable) getBackground(); Rect padding = new Rect(); back.getPadding(padding); left = padding.left; } return left; } /** * A drawable for animating the switch between a masked and visible password field. */ static class MaskMorphDrawable extends Drawable { private static final float NO_PROGRESS = -1f; private static final float PROGRESS_CHARACTER = 0f; private static final float PROGRESS_MASK = 1f; private final TextPaint paint; private final float charWidth; private final float maskDiameter; private final float maskCenterY; private final float insetStart; private final int baseline; private final long showPasswordDuration; private final long hidePasswordDuration; private final Interpolator fastOutSlowIn; private CharSequence password; private PasswordCharacter[] characters; private float morphProgress; MaskMorphDrawable(Context context, TextPaint textPaint, int baseline, float charWidth, int insetStart) { this.insetStart = insetStart; this.baseline = baseline; this.charWidth = charWidth; paint = new TextPaint(textPaint); Rect maskBounds = new Rect(); paint.getTextBounds(PASSWORD_MASK, 0, 1, maskBounds); maskDiameter = maskBounds.height(); maskCenterY = (maskBounds.top + maskBounds.bottom) / 2f; showPasswordDuration = context.getResources().getInteger(R.integer.show_password_duration); hidePasswordDuration = context.getResources().getInteger(R.integer.hide_password_duration); fastOutSlowIn = AnimUtils.getFastOutSlowInInterpolator(context); } Animator createShowMaskAnimator(CharSequence password) { return morphPassword(password, PROGRESS_CHARACTER, PROGRESS_MASK, hidePasswordDuration); } Animator createHideMaskAnimator(CharSequence password) { return morphPassword(password, PROGRESS_MASK, PROGRESS_CHARACTER, showPasswordDuration); } @Override public void draw(Canvas canvas) { if (characters != null && morphProgress != NO_PROGRESS) { final int saveCount = canvas.save(); canvas.translate(insetStart, baseline); for (int i = 0; i < characters.length; i++) { characters[i].draw(canvas, paint, password, i, charWidth, morphProgress); } canvas.restoreToCount(saveCount); } } @Override public void setAlpha(int alpha) { if (alpha != paint.getAlpha()) { paint.setAlpha(alpha); invalidateSelf(); } } @Override public void setColorFilter(ColorFilter colorFilter) { paint.setColorFilter(colorFilter); } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } private Animator morphPassword( CharSequence pw, float fromProgress, float toProgress, long duration) { password = pw; updateBounds(); characters = new PasswordCharacter[pw.length()]; String passStr = pw.toString(); for (int i = 0; i < pw.length(); i++) { characters[i] = new PasswordCharacter(passStr, i, paint, maskDiameter, maskCenterY); } ValueAnimator anim = ValueAnimator.ofFloat(fromProgress, toProgress); anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { morphProgress = (float) valueAnimator.getAnimatedValue(); invalidateSelf(); } }); anim.setDuration(duration); anim.setInterpolator(fastOutSlowIn); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { characters = null; morphProgress = NO_PROGRESS; password = null; updateBounds(); invalidateSelf(); } }); return anim; } private void updateBounds() { Rect oldBounds = getBounds(); if (password != null) { setBounds( oldBounds.left, oldBounds.top, oldBounds.left + (int) Math.ceil(password.length() * charWidth), oldBounds.bottom); } else { setBounds(oldBounds.left, oldBounds.top, oldBounds.left, oldBounds.bottom); } } } /** * Models a character in a password, holding info about it's drawing bounds and how it should * move/scale to morph to/from the password mask. */ static class PasswordCharacter { private final Rect bounds = new Rect(); private final float textToMaskScale; private final float maskToTextScale; private final float textOffsetY; PasswordCharacter(String password, int index, TextPaint paint, float maskCharDiameter, float maskCenterY) { paint.getTextBounds(password, index, index + 1, bounds); // scale the mask from the character width, down to it's own width maskToTextScale = Math.max(1f, bounds.width() / maskCharDiameter); // scale text from it's height down to the mask character height textToMaskScale = Math.min(0f, 1f / (bounds.height() / maskCharDiameter)); // difference between mask & character center textOffsetY = maskCenterY - bounds.exactCenterY(); } /** * Progress through the morph: 0 = character, 1 = • */ void draw(Canvas canvas, TextPaint paint, CharSequence password, int index, float charWidth, float progress) { int alpha = paint.getAlpha(); float x = charWidth * index; // draw the character canvas.save(); float textScale = lerp(1f, textToMaskScale, progress); // scale character: shrinks to/grows from the mask's height canvas.scale(textScale, textScale, x + bounds.exactCenterX(), bounds.exactCenterY()); // cross fade between the character/mask paint.setAlpha((int) lerp(alpha, 0, progress)); // vertically move the character center toward/from the mask center canvas.drawText(password, index, index + 1, x, lerp(0f, textOffsetY, progress) / textScale, paint); canvas.restore(); // draw the mask canvas.save(); float maskScale = lerp(maskToTextScale, 1f, progress); // scale the mask: down from/up to the character width canvas.scale(maskScale, maskScale, x + bounds.exactCenterX(), bounds.exactCenterY()); // cross fade between the mask/character paint.setAlpha((int) AnimUtils.lerp(0, alpha, progress)); // vertically move the mask center from/toward the character center canvas.drawText(PASSWORD_MASK, 0, 1, x, -lerp(textOffsetY, 0f, progress), paint); canvas.restore(); // restore the paint to how we found it paint.setAlpha(alpha); } } }