From f64e4b9204dd7960f38d9bf0fa4418696de09b30 Mon Sep 17 00:00:00 2001 From: Ibraheem Zaman Date: Sun, 6 Dec 2015 00:28:04 +0500 Subject: [PATCH] Add pulsing spin animation The Font Awesome CSS framework has a pulse animation that rotates an icon in 8 steps, which matches the structure of it's pulsing spinner. This has been implemented with the "pulse" token here. Since Font Awesome seems to have the only dotted pulsing spinner out of the current font set, this seems a reasonable generic implementation at the moment. If at some point spinners with different pulses need to be handled, then a pulse property can be introduced for the icons, with 8 as the default count. --- .../iconify/internal/Animation.java | 15 +++++++ .../iconify/internal/CustomTypefaceSpan.java | 38 +++++++++++++---- .../iconify/internal/ParsingUtil.java | 42 ++++++++++++++----- 3 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 android-iconify/src/main/java/com/joanzapata/iconify/internal/Animation.java diff --git a/android-iconify/src/main/java/com/joanzapata/iconify/internal/Animation.java b/android-iconify/src/main/java/com/joanzapata/iconify/internal/Animation.java new file mode 100644 index 0000000..a38f62e --- /dev/null +++ b/android-iconify/src/main/java/com/joanzapata/iconify/internal/Animation.java @@ -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; + } +} diff --git a/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java b/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java index a07bb49..aadf06e 100644 --- a/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java +++ b/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java @@ -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; @@ -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 @@ -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); @@ -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); diff --git a/android-iconify/src/main/java/com/joanzapata/iconify/internal/ParsingUtil.java b/android-iconify/src/main/java/com/joanzapata/iconify/internal/ParsingUtil.java index 8cdb080..c49f1f8 100644 --- a/android-iconify/src/main/java/com/joanzapata/iconify/internal/ParsingUtil.java +++ b/android-iconify/src/main/java/com/joanzapata/iconify/internal/ParsingUtil.java @@ -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."); @@ -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; + } } } }); @@ -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( @@ -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" @@ -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);