Skip to content

Commit

Permalink
Animated masked/visible password entry switch.
Browse files Browse the repository at this point in the history
  • Loading branch information
nickbutcher committed Aug 19, 2016
1 parent 21c79ea commit a12a1d6
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 8 deletions.
293 changes: 293 additions & 0 deletions app/src/main/java/io/plaidapp/ui/widget/PasswordEntry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
/*
* 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.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
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.util.Property;
import android.view.animation.Interpolator;

import io.plaidapp.util.AnimUtils;
import io.plaidapp.util.ColorUtils;
import io.plaidapp.util.ViewUtils;

/**
* A password entry widget which animates switching between masked and visible text.
*/
public class PasswordEntry extends TextInputEditText {

private boolean passwordMasked = false;
private MaskMorphDrawable maskDrawable;

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);
}
}

private void passwordVisibilityToggled(boolean isMasked) {
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), getInsetStart());
maskDrawable.setBounds(getPaddingLeft(), getPaddingTop(), 0,
getHeight() - getPaddingTop() - getPaddingBottom());
getOverlay().add(maskDrawable);
}
maskDrawable.setDotCount(getText().length());

// also animate the text color to cross fade
final ColorStateList textColors = getTextColors();
int currentColor = getCurrentTextColor();
int fadedOut = ColorUtils.modifyAlpha(currentColor, 0);
Animator morph;
if (isMasked) {
// text has already changed to dots so can't cross fade, just hide it
morph = maskDrawable.createShowMaskAnimator();
setTextColor(fadedOut);
} else {
Animator mask = maskDrawable.createHideMaskAnimator();
setTextColor(fadedOut); // set immediately because of start delay
Animator fadeText =
ObjectAnimator.ofArgb(this, ViewUtils.TEXT_COLOR, fadedOut, currentColor);
fadeText.setInterpolator(AnimUtils.getLinearOutSlowInInterpolator(getContext()));
fadeText.setStartDelay(120L);
fadeText.setDuration(180L);
morph = new AnimatorSet();
((AnimatorSet) morph).playTogether(mask, fadeText);
}
morph.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// restore the proper text color & hide the drawable
setTextColor(textColors);
maskDrawable.setAlpha(0);
}
});
morph.start();
}

private int getInsetStart() {
int insetStart = 0;
if (getBackground() instanceof InsetDrawable) {
InsetDrawable back = (InsetDrawable) getBackground();
Rect padding = new Rect();
back.getPadding(padding);
insetStart = padding.left;
}
return insetStart;
}

static class MaskMorphDrawable extends Drawable {

private static final char[] PASSWORD_MASK = { '•' };

private final Paint paint;
private final float charWidth;
private final float maskCharRadius;
private final float unmaskedRadius;
private final float maskCenterY;
private final float insetStart;
private final float maxOffsetY;
private final Interpolator linearOutSlowInInterpolator;
private final Interpolator fastOutLinearInInterpolator;

private int dotCount;
private float dotRadius;
private float dotOffsetY;

MaskMorphDrawable(Context context, TextPaint textPaint,
int baseline, float charWidth, int insetStart) {
this.insetStart = insetStart;
this.charWidth = charWidth;
unmaskedRadius = charWidth / 2f;
Rect rect = new Rect();
textPaint.getTextBounds(PASSWORD_MASK, 0, 1, rect);
maskCharRadius = rect.height() / 2f;
maskCenterY = (baseline + rect.top + baseline + rect.bottom) / 2f;
maxOffsetY = charWidth / 5f;
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(textPaint.getColor());
fastOutLinearInInterpolator = AnimUtils.getFastOutLinearInInterpolator(context);
linearOutSlowInInterpolator = AnimUtils.getLinearOutSlowInInterpolator(context);
}

float getDotRadius() {
return dotRadius;
}

void setDotRadius(float dotRadius) {
if (this.dotRadius != dotRadius) {
this.dotRadius = dotRadius;
invalidateSelf();
}
}

public float getDotOffsetY() {
return dotOffsetY;
}

public void setDotOffsetY(float dotOffsetY) {
if (this.dotOffsetY != dotOffsetY) {
this.dotOffsetY = dotOffsetY;
invalidateSelf();
}
}

Animator createShowMaskAnimator() {
return animateMask(unmaskedRadius, maskCharRadius, 0, 255, maxOffsetY, 0f, 120L);
}

Animator createHideMaskAnimator() {
return animateMask(maskCharRadius, unmaskedRadius, 192, 0, 0f, maxOffsetY, 200L);
}

void setDotCount(int dotCount) {
if (dotCount != this.dotCount) {
this.dotCount = dotCount;
Rect bounds = getBounds();
setBounds(
bounds.left,
bounds.top,
bounds.left + (int) Math.ceil(dotCount * charWidth),
bounds.bottom);
invalidateSelf();
}
}

@Override
public void draw(Canvas canvas) {
float x = insetStart + (charWidth / 2f);
float y = maskCenterY + dotOffsetY;
for (int i = 0; i < dotCount; i++) {
canvas.drawCircle(x, y, dotRadius, paint);
x += charWidth;
}
}

@Override
public void setAlpha(int alpha) {
if (alpha != getAlpha()) {
paint.setAlpha(alpha);
invalidateSelf();
}
}

@Override
public void setColorFilter(ColorFilter colorFilter) {
paint.setColorFilter(colorFilter);
}

@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}

private Animator animateMask(
float startRadius, float endRadius,
int startAlpha, int endAlpha,
float startOffsetY, float endOffsetY,
long duration) {
PropertyValuesHolder radius =
PropertyValuesHolder.ofFloat(RADIUS, startRadius, endRadius);
PropertyValuesHolder alpha =
PropertyValuesHolder.ofInt(ViewUtils.DRAWABLE_ALPHA, startAlpha, endAlpha);
// animate the y offset slightly as the vertical center of the password mask dot
// is higher than the text x-height so this smooths the change
PropertyValuesHolder offset =
PropertyValuesHolder.ofFloat(OFFSET_Y, startOffsetY, endOffsetY);
ObjectAnimator anim =
ObjectAnimator.ofPropertyValuesHolder(this, radius, alpha, offset);
anim.setDuration(duration);
if (startRadius > endRadius) {
anim.setInterpolator(linearOutSlowInInterpolator);
} else {
anim.setInterpolator(fastOutLinearInInterpolator);
}
return anim;
}

public static final Property<MaskMorphDrawable, Float> RADIUS
= new AnimUtils.FloatProperty<MaskMorphDrawable>("dotRadius") {

@Override
public void setValue(MaskMorphDrawable drawable, float radius) {
drawable.setDotRadius(radius);
}

@Override
public Float get(MaskMorphDrawable drawable) {
return drawable.getDotRadius();
}
};

public static final Property<MaskMorphDrawable, Float> OFFSET_Y
= new AnimUtils.FloatProperty<MaskMorphDrawable>("dotOffsetY") {

@Override
public void setValue(MaskMorphDrawable drawable, float offset) {
drawable.setDotOffsetY(offset);
}

@Override
public Float get(MaskMorphDrawable drawable) {
return drawable.getDotOffsetY();
}
};

}
}
29 changes: 29 additions & 0 deletions app/src/main/java/io/plaidapp/util/ViewUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import android.view.View;
import android.view.ViewOutlineProvider;
import android.widget.ImageView;
import android.widget.TextView;

/**
* Utility methods for working with Views.
Expand Down Expand Up @@ -173,6 +174,34 @@ public Integer get(View view) {
}
};

public static final Property<TextView, Integer> TEXT_COLOR
= new AnimUtils.IntProperty<TextView>("textColor") {

@Override
public void setValue(TextView textView, int color) {
textView.setTextColor(color);
}

@Override
public Integer get(TextView textView) {
return textView.getCurrentTextColor();
}
};

public static final Property<Drawable, Integer> DRAWABLE_ALPHA
= new AnimUtils.IntProperty<Drawable>("alpha") {

@Override
public void setValue(Drawable drawable, int alpha) {
drawable.setAlpha(alpha);
}

@Override
public Integer get(Drawable drawable) {
return drawable.getAlpha();
}
};

public static final Property<ImageView, Integer> IMAGE_ALPHA
= new AnimUtils.IntProperty<ImageView>("imageAlpha") {

Expand Down
4 changes: 2 additions & 2 deletions app/src/main/res/drawable/avd_hide_password.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
android:propertyName="pathData"
android:valueFrom="@string/path_password_eye_mask_visible"
android:valueTo="@string/path_password_eye_mask_strike_through"
android:duration="@integer/password_strike"
android:duration="320"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:valueType="pathType" />

Expand All @@ -76,7 +76,7 @@
android:propertyName="trimPathEnd"
android:valueFrom="0"
android:valueTo="1"
android:duration="@integer/password_strike"
android:duration="320"
android:interpolator="@android:interpolator/fast_out_slow_in" />

</aapt:attr>
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/res/drawable/avd_show_password.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@
android:propertyName="pathData"
android:valueFrom="@string/path_password_eye_mask_strike_through"
android:valueTo="@string/path_password_eye_mask_visible"
android:duration="@integer/password_strike"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:duration="260"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:valueType="pathType" />

</aapt:attr>
Expand All @@ -75,8 +75,8 @@
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"
android:duration="@integer/password_strike"
android:interpolator="@android:interpolator/fast_out_slow_in" />
android:duration="260"
android:interpolator="@android:interpolator/fast_out_linear_in" />

</aapt:attr>

Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/layout/activity_designer_news_login.xml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
app:passwordToggleDrawable="@drawable/asl_password_visibility"
app:passwordToggleTint="?colorControlNormal">

<android.support.design.widget.TextInputEditText
<io.plaidapp.ui.widget.PasswordEntry
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down
1 change: 0 additions & 1 deletion app/src/main/res/values/password_visibility.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@
<string name="path_password_eye_mask_strike_through">M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z</string>
<string name="path_password_eye">M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z</string>
<string name="path_password_strike_through">M3.27,4.27 L19.74,20.74</string>
<integer name="password_strike">320</integer>

</resources>

0 comments on commit a12a1d6

Please sign in to comment.