/* * Copyright 2014 Frakbot (Sebastiano Poggi and Francesco Pontillo) * * 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 com.marshalchen.common.uimodule.jumpingbeans; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.widget.TextView; import java.lang.ref.WeakReference; /** * Provides "jumping beans" functionality for a TextView. * <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. * <p/> * Please note that you: * <ul> * <li><b>Must not</b> try to change a jumping beans text in a textview before calling * {@link #stopJumping()} as to avoid unnecessary invalidation calls; * the JumpingBeans class cannot know when this happens and will keep * animating the textview (well, try to, anyway), wasting resources</li> * <li><b>Must not</b> try to use a jumping beans text in another view; it will not * animate. Just create another jumping beans animation for each new * view</li> * <li><b>Must not</b> use more than one JumpingBeans instance on a single TextView, as * the first cleanup operation called on any of these JumpingBeans will also cleanup * all other JumpingBeans' stuff. This is most likely not what you want to happen in * some cases.</li> * <li><b>Should not</b> use JumpingBeans on large chunks of text. Ideally this should * be done on small views with just a few words. We've strived to make it as inexpensive * as possible to use JumpingBeans but invalidating and possibly relayouting a large * TextView can be pretty expensive.</li> * </ul> */ 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 com.marshalchen.common.uimodule.jumpingbeans.JumpingBeans} objects. * <p/> * Provides a way to set the fields of a {@link com.marshalchen.common.uimodule.jumpingbeans.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 com.marshalchen.common.uimodule.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 com.marshalchen.common.uimodule.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 com.marshalchen.common.uimodule.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 com.marshalchen.common.uimodule.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 com.marshalchen.common.uimodule.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); } } }