Skip to content

Instantly share code, notes, and snippets.

@SeniorZhai
Last active August 29, 2015 14:06
Show Gist options
  • Save SeniorZhai/ae1338d6d13c5870d913 to your computer and use it in GitHub Desktop.
Save SeniorZhai/ae1338d6d13c5870d913 to your computer and use it in GitHub Desktop.
import java.lang.ref.WeakReference;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.widget.TextView;
public final class JumpingBeans {
/**
* The default fraction of the whole animation time spent actually animating.
* The rest of the range will be spent in "resting" state.
* This the "duty cycle" of the jumping animation.
*/
public static final float DEFAULT_ANIMATION_DUTY_CYCLE = 0.5f;
/**
* The default duration of a whole jumping animation loop, in milliseconds.
*/
public static final int DEFAULT_LOOP_DURATION = 1500;
private JumpingBeansSpan[] jumpingBeans;
private WeakReference<TextView> textView;
private JumpingBeans(JumpingBeansSpan[] beans, TextView textView) {
// Clients will have to use the builder
this.jumpingBeans = beans;
this.textView = new WeakReference<TextView>(textView);
}
/**
* Stops the jumping animation and frees up the animations.
*/
public void stopJumping() {
for (JumpingBeansSpan bean : jumpingBeans) {
if (bean != null) {
bean.teardown();
}
}
TextView tv = textView.get();
if (tv != null) {
CharSequence text = tv.getText();
if (text instanceof Spanned) {
CharSequence cleanText = removeJumpingBeansSpans((Spanned) text);
tv.setText(cleanText);
}
}
}
private static CharSequence removeJumpingBeansSpans(Spanned text) {
SpannableStringBuilder sbb = new SpannableStringBuilder(text.toString());
Object[] spans = text.getSpans(0, text.length(), Object.class);
for (Object span : spans) {
if (!(span instanceof JumpingBeansSpan)) {
sbb.setSpan(span, text.getSpanStart(span),
text.getSpanEnd(span), text.getSpanFlags(span));
}
}
return sbb;
}
/**
* Builder class for {@link net.frakbot.jumpingbeans.JumpingBeans} objects.
* <p/>
* Provides a way to set the fields of a {@link JumpingBeans} and generate
* the desired jumping beans effect. With this builder you can easily append
* a Hangouts-style trio of jumping suspension points to any TextView, or
* apply the effect to any other subset of a TextView's text.
* <p/>
* <p>Example:
* <p/>
* <pre class="prettyprint">
* JumpingBeans jumpingBeans = new JumpingBeans.Builder()
* .appendJumpingDots(myTextView)
* .setLoopDuration(1500)
* .build();
* </pre>
*/
public static class Builder {
private int startPos, endPos;
private float animRange = DEFAULT_ANIMATION_DUTY_CYCLE;
private int loopDuration = DEFAULT_LOOP_DURATION;
private int waveCharDelay = -1;
private CharSequence text;
private TextView textView;
private boolean wave;
/**
* Appends three jumping dots to the end of a TextView text.
* <p/>
* This implies that the animation will by default be a wave.
* <p/>
* If the TextView has no text, the resulting TextView text will
* consist of the three dots only.
* <p/>
* The TextView text is cached to the current value at
* this time and set again in the {@link #build()} method, so any
* change to the TextView text done in the meantime will be lost.
* This means that <b>you should do all changes to the TextView text
* <i>before</i> you begin using this builder.</b>
* <p/>
* Call the {@link #build()} method once you're done to get the
* resulting {@link net.frakbot.jumpingbeans.JumpingBeans}.
*
* @param textView The TextView to append the dots to
* @see #setIsWave(boolean)
*/
public Builder appendJumpingDots(TextView textView) {
if (textView == null) {
throw new NullPointerException("The textView must not be null");
}
CharSequence text = !TextUtils.isEmpty(textView.getText()) ? textView.getText() : "";
if (text.length() > 0 && text.subSequence(text.length() - 1, text.length()).equals("…")) {
text = text.subSequence(0, text.length() - 1);
}
if (text.length() < 3 || !TextUtils.equals(text.subSequence(text.length() - 3, text.length()), "...")) {
text = new SpannableStringBuilder(text).append("..."); // Preserve spans in original text
}
this.text = text;
this.wave = true;
this.textView = textView;
this.startPos = this.text.length() - 3;
this.endPos = this.text.length();
return this;
}
/**
* Appends three jumping dots to the end of a TextView text.
* <p/>
* This implies that the animation will by default be a wave.
* <p/>
* If the TextView has no text, the resulting TextView text will
* consist of the three dots only.
* <p/>
* The TextView text is cached to the current value at
* this time and set again in the {@link #build()} method, so any
* change to the TextView text done in the meantime will be lost.
* This means that <b>you should do all changes to the TextView text
* <i>before</i> you begin using this builder.</b>
* <p/>
* Call the {@link #build()} method once you're done to get the
* resulting {@link net.frakbot.jumpingbeans.JumpingBeans}.
*
* @param textView The TextView whose text is to be animated
* @param startPos The position of the first character to animate
* @param endPos The position after the one the animated range ends at
* (just like in String#substring())
* @see #setIsWave(boolean)
*/
public Builder makeTextJump(TextView textView, int startPos, int endPos) {
if (textView == null || textView.getText() == null) {
throw new NullPointerException("The textView and its text must not be null");
}
if (endPos < startPos) {
throw new IllegalArgumentException("The start position must be smaller than the end position");
}
if (startPos < 0) {
throw new IndexOutOfBoundsException("The start position must be non-negative");
}
this.text = textView.getText();
if (endPos > text.length()) {
throw new IndexOutOfBoundsException("The end position must be smaller than the text length");
}
this.wave = true;
this.textView = textView;
this.startPos = startPos;
this.endPos = endPos;
return this;
}
/**
* Sets the fraction of the animation loop time spent actually animating.
* The rest of the time will be spent "resting".
* The default value is
* {@link net.frakbot.jumpingbeans.JumpingBeans#DEFAULT_ANIMATION_DUTY_CYCLE}.
*
* @param animatedRange The fraction of the animation loop time spent
* actually animating the characters
*/
public Builder setAnimatedDutyCycle(float animatedRange) {
if (animatedRange <= 0f || animatedRange > 1f) {
throw new IllegalArgumentException("The animated range must be in the (0, 1] range");
}
this.animRange = animatedRange;
return this;
}
/**
* Sets the jumping loop duration. The default value is
* {@link net.frakbot.jumpingbeans.JumpingBeans#DEFAULT_LOOP_DURATION}.
*
* @param loopDuration The jumping animation loop duration, in milliseconds
*/
public Builder setLoopDuration(int loopDuration) {
if (loopDuration < 1) {
throw new IllegalArgumentException("The loop duration must be bigger than zero");
}
this.loopDuration = loopDuration;
return this;
}
/**
* Sets the delay for starting the animation of every single dot over the
* start of the previous one, in milliseconds. The default value is
* the loop length divided by three times the number of character animated
* by this instance of JumpingBeans.
* <p/>
* Only has a meaning when the animation is a wave.
*
* @param waveCharOffset The start delay for the animation of every single
* character over the previous one, in milliseconds
* @see #setIsWave(boolean)
*/
public Builder setWavePerCharDelay(int waveCharOffset) {
if (waveCharOffset < 0) {
throw new IllegalArgumentException("The wave char offset must be non-negative");
}
this.waveCharDelay = waveCharOffset;
return this;
}
/**
* Sets a flag that determines if the characters will jump in a wave
* (i.e., with a delay between each other) or all at the same
* time.
*
* @param wave If true, the animation is going to be a wave; if
* false, all characters will jump ay the same time
* @see #setWavePerCharDelay(int)
*/
public Builder setIsWave(boolean wave) {
this.wave = wave;
return this;
}
/**
* Combine all of the options that have been set and return a new
* {@link net.frakbot.jumpingbeans.JumpingBeans} instance.
* <p/>
* Remember to call the {@link #stopJumping()} method once you're done
* using the JumpingBeans (that is, when you detach the TextView from
* the view tree, you hide it, or the parent Activity/Fragment goes in
* the paused status). This will allow to release the animations and
* free up memory and CPU that would be otherwise wasted.
*/
public JumpingBeans build() {
SpannableStringBuilder sbb = new SpannableStringBuilder(text);
JumpingBeansSpan[] jumpingBeans;
if (!wave) {
jumpingBeans = new JumpingBeansSpan[]{new JumpingBeansSpan(textView, loopDuration, 0, 0, animRange)};
sbb.setSpan(jumpingBeans[0], startPos, endPos, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
} else {
if (waveCharDelay == -1) {
waveCharDelay = loopDuration / (3 * (endPos - startPos));
}
jumpingBeans = new JumpingBeansSpan[endPos - startPos];
for (int pos = startPos; pos < endPos; pos++) {
JumpingBeansSpan jumpingBean =
new JumpingBeansSpan(textView, loopDuration, pos - startPos, waveCharDelay, animRange);
sbb.setSpan(jumpingBean, pos, pos + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
jumpingBeans[pos - startPos] = jumpingBean;
}
}
textView.setText(sbb);
return new JumpingBeans(jumpingBeans, textView);
}
}
}
import java.lang.ref.WeakReference;
import android.animation.TimeInterpolator;
import android.animation.ValueAnimator;
import android.os.Build;
import android.text.TextPaint;
import android.text.style.SuperscriptSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
public final class JumpingBeansSpan extends SuperscriptSpan implements ValueAnimator.AnimatorUpdateListener {
private ValueAnimator jumpAnimator;
private WeakReference<TextView> textView;
private int shift;
private int delay;
private int loopDuration;
private float animatedRange;
public JumpingBeansSpan(TextView textView, int loopDuration, int position, int waveCharOffset,
float animatedRange) {
this.textView = new WeakReference<>(textView);
this.delay = waveCharOffset * position;
this.loopDuration = loopDuration;
this.animatedRange = animatedRange;
}
@Override
public void updateMeasureState(TextPaint tp) {
initIfNecessary(tp);
tp.baselineShift = shift;
}
@Override
public void updateDrawState(TextPaint tp) {
initIfNecessary(tp);
tp.baselineShift = shift;
}
private void initIfNecessary(TextPaint tp) {
if (jumpAnimator != null) {
return;
}
shift = (int) tp.ascent() / 2;
jumpAnimator = ValueAnimator.ofInt(0, shift, 0);
jumpAnimator
.setDuration(loopDuration)
.setStartDelay(delay);
jumpAnimator.setInterpolator(new JumpInterpolator(animatedRange));
jumpAnimator.setRepeatCount(ValueAnimator.INFINITE);
jumpAnimator.setRepeatMode(ValueAnimator.RESTART);
jumpAnimator.addUpdateListener(this);
jumpAnimator.start();
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// No need for synchronization as this always run on main thread anyway
TextView v = textView.get();
if (v != null) {
if (isAttachedToHierarchy(v)) {
shift = (int) animation.getAnimatedValue();
v.invalidate();
} else {
animation.setCurrentPlayTime(0);
animation.start();
}
} else {
// The textview has been destroyed and teardown() hasn't been called
teardown();
if (BuildConfig.DEBUG) {
Log.e("JumpingBeans", "!!! Remember to call JumpingBeans.stopJumping() when appropriate !!!");
}
}
}
private boolean isAttachedToHierarchy(View v) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return v.isAttachedToWindow();
} else {
return v.getParent() != null; // Best-effort fallback
}
}
/*package*/ void teardown() {
if (jumpAnimator != null) {
jumpAnimator.cancel();
jumpAnimator.removeAllListeners();
}
if (textView.get() != null) {
textView.clear();
}
}
/**
* A tweaked {@link android.view.animation.AccelerateDecelerateInterpolator}
* that covers the full range in a fraction of its input range, and holds on
* the final value on the rest of the input range. By default, this fraction
* is half of the full range.
*
* @see net.frakbot.jumpingbeans.JumpingBeans#DEFAULT_ANIMATION_DUTY_CYCLE
*/
private class JumpInterpolator implements TimeInterpolator {
private float animRange;
public JumpInterpolator(float animatedRange) {
animRange = Math.abs(animatedRange);
}
@Override
public float getInterpolation(float input) {
if (input <= animRange) {
return (float) (Math.cos((input / animRange + 1) * Math.PI) / 2f) + 0.5f;
}
return 1.0f;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment