Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pulsing spin animation #148

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.joanzapata.iconify.internal;

public enum Animation {
SPIN("spin"), PULSE("pulse"), NONE(null);

private final String token;

Animation(String token) {
this.token = token;
}

String getToken() {
return token;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.os.SystemClock;
import android.text.style.ReplacementSpan;
import com.joanzapata.iconify.Icon;

public class CustomTypefaceSpan extends ReplacementSpan {
private static final int ROTATION_DURATION = 2000;
// Font Awesome uses 8-step rotation for pulse, and
// it seems to have the only pulsing spinner. If
// spinners with different pulses are introduced at
// some point, then a pulse property can be
// implemented for the icons.
static final int ROTATION_PULSES = 8;
private static final int ROTATION_PULSE_DURATION = ROTATION_DURATION / ROTATION_PULSES;
private static final Rect TEXT_BOUNDS = new Rect();
private static final Paint LOCAL_PAINT = new Paint();
private static final float BASELINE_RATIO = 1 / 7f;
Expand All @@ -18,21 +26,21 @@ public class CustomTypefaceSpan extends ReplacementSpan {
private final float iconSizePx;
private final float iconSizeRatio;
private final int iconColor;
private final boolean rotate;
private final Animation animation;
private final boolean baselineAligned;
private final long rotationStartTime;

public CustomTypefaceSpan(Icon icon, Typeface type,
float iconSizePx, float iconSizeRatio, int iconColor,
boolean rotate, boolean baselineAligned) {
this.rotate = rotate;
Animation animation, boolean baselineAligned) {
this.animation = animation != null ? animation : Animation.NONE;
this.baselineAligned = baselineAligned;
this.icon = String.valueOf(icon.character());
this.type = type;
this.iconSizePx = iconSizePx;
this.iconSizeRatio = iconSizeRatio;
this.iconColor = iconColor;
this.rotationStartTime = System.currentTimeMillis();
this.rotationStartTime = SystemClock.uptimeMillis();
}

@Override
Expand All @@ -59,8 +67,20 @@ public void draw(Canvas canvas, CharSequence text,
paint.getTextBounds(icon, 0, 1, TEXT_BOUNDS);
canvas.save();
float baselineRatio = baselineAligned ? 0f : BASELINE_RATIO;
if (rotate) {
float rotation = (System.currentTimeMillis() - rotationStartTime) / (float) ROTATION_DURATION * 360f;
if (animation != Animation.NONE) {
long timeElapsed = SystemClock.uptimeMillis() - rotationStartTime;
float rotation;
switch (animation) {
case PULSE:
rotation = ((int) Math.floor(timeElapsed / (float) ROTATION_PULSE_DURATION))
* 360f / ROTATION_PULSES;
break;
case SPIN:
rotation = timeElapsed / (float) ROTATION_DURATION * 360f;
break;
default:
throw new IllegalStateException();
}
float centerX = x + TEXT_BOUNDS.width() / 2f;
float centerY = y - TEXT_BOUNDS.height() / 2f + TEXT_BOUNDS.height() * baselineRatio;
canvas.rotate(rotation, centerX, centerY);
Expand All @@ -72,15 +92,15 @@ public void draw(Canvas canvas, CharSequence text,
canvas.restore();
}

public boolean isAnimated() {
return rotate;
public Animation getAnimation() {
return animation;
}

private void applyCustomTypeFace(Paint paint, Typeface tf) {
paint.setFakeBoldText(false);
paint.setTextSkewX(0f);
paint.setTypeface(tf);
if (rotate) paint.clearShadowLayer();
if (animation != Animation.NONE) paint.clearShadowLayer();
if (iconSizeRatio > 0) paint.setTextSize(paint.getTextSize() * iconSizeRatio);
else if (iconSizePx > 0) paint.setTextSize(iconSizePx);
if (iconColor < Integer.MAX_VALUE) paint.setColor(iconColor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ public static CharSequence parse(
recursivePrepareSpannableIndexes(context,
text.toString(), spannableBuilder,
iconFontDescriptors, 0);
boolean isAnimated = hasAnimatedSpans(spannableBuilder);
final Animation animation = getSpansAnimation(spannableBuilder);

// If animated, periodically invalidate the TextView so that the
// CustomTypefaceSpan can redraw itself
if (isAnimated) {
if (animation != Animation.NONE) {
if (target == null)
throw new IllegalArgumentException("You can't use \"spin\" without providing the target TextView.");
throw new IllegalArgumentException("You can't use \"" + animation.getToken() +
"\" without providing the target TextView.");
if (!(target instanceof HasOnViewAttachListener))
throw new IllegalArgumentException(target.getClass().getSimpleName() + " does not implement " +
"HasOnViewAttachListener. Please use IconTextView, IconButton or IconToggleButton.");
Expand All @@ -55,7 +56,14 @@ public void onAttach() {
public void run() {
if (isAttached) {
target.invalidate();
ViewCompat.postOnAnimation(target, this);
switch (animation) {
case SPIN:
ViewCompat.postOnAnimation(target, this);
break;
case PULSE:
ViewCompat.postOnAnimationDelayed(target, this, CustomTypefaceSpan.ROTATION_PULSES);
break;
}
}
}
});
Expand All @@ -74,13 +82,20 @@ public void onDetach() {
return spannableBuilder;
}

private static boolean hasAnimatedSpans(SpannableStringBuilder spannableBuilder) {
private static Animation getSpansAnimation(SpannableStringBuilder spannableBuilder) {
Animation animation = Animation.NONE;
CustomTypefaceSpan[] spans = spannableBuilder.getSpans(0, spannableBuilder.length(), CustomTypefaceSpan.class);
for (CustomTypefaceSpan span : spans) {
if (span.isAnimated())
return true;
Animation spanAnimation = span.getAnimation();
// Return the animation with the highest refresh rate
if (spanAnimation != Animation.NONE) {
animation = spanAnimation;
if (animation == Animation.SPIN) {
break;
}
}
}
return false;
return animation;
}

private static void recursivePrepareSpannableIndexes(
Expand Down Expand Up @@ -120,14 +135,19 @@ private static void recursivePrepareSpannableIndexes(
float iconSizePx = -1;
int iconColor = Integer.MAX_VALUE;
float iconSizeRatio = -1;
boolean spin = false;
Animation animation = Animation.NONE;
boolean baselineAligned = false;
for (int i = 1; i < strokes.length; i++) {
String stroke = strokes[i];

// Look for "spin"
if (stroke.equalsIgnoreCase("spin")) {
spin = true;
animation = Animation.SPIN;
}

// Look for "pulse"
else if (stroke.equalsIgnoreCase("pulse")) {
animation = Animation.PULSE;
}

// Look for "baseline"
Expand Down Expand Up @@ -174,7 +194,7 @@ else if (stroke.matches("#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})")) {
text = text.replace(startIndex, endIndex, "" + icon.character());
text.setSpan(new CustomTypefaceSpan(icon,
iconFontDescriptor.getTypeface(context),
iconSizePx, iconSizeRatio, iconColor, spin, baselineAligned),
iconSizePx, iconSizeRatio, iconColor, animation, baselineAligned),
startIndex, startIndex + 1,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
recursivePrepareSpannableIndexes(context, fullText, text, iconFontDescriptors, startIndex);
Expand Down