blob: 7879cd2af7af3c8677de242b28302bd52ccf5d8e [file] [log] [blame]
package com.airbnb.lottie;
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Matrix;
import android.graphics.PixelFormat;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.support.annotation.FloatRange;
import android.support.annotation.IntRange;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;
import android.view.View;
import android.view.animation.LinearInterpolator;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
/**
* This can be used to show an lottie animation in any place that would normally take a drawable.
* If there are masks or mattes, then you MUST call {@link #recycleBitmaps()} when you are done
* or else you will leak bitmaps.
* <p>
* It is preferable to use {@link com.airbnb.lottie.LottieAnimationView} when possible because it
* handles bitmap recycling and asynchronous loading
* of compositions.
*/
@SuppressWarnings({"WeakerAccess", "unused"}) public class LottieDrawable extends Drawable implements Drawable.Callback {
private static final String TAG = LottieDrawable.class.getSimpleName();
private interface LazyCompositionTask {
void run(LottieComposition composition);
}
private final Matrix matrix = new Matrix();
private LottieComposition composition;
private final LottieValueAnimator animator = new LottieValueAnimator();
private float speed = 1f;
private float progress = 0f;
private float scale = 1f;
private final Set<ColorFilterData> colorFilterData = new HashSet<>();
private final ArrayList<LazyCompositionTask> lazyCompositionTasks = new ArrayList<>();
@Nullable private ImageAssetManager imageAssetManager;
@Nullable private String imageAssetsFolder;
@Nullable private ImageAssetDelegate imageAssetDelegate;
@Nullable private FontAssetManager fontAssetManager;
@Nullable FontAssetDelegate fontAssetDelegate;
@Nullable TextDelegate textDelegate;
private boolean systemAnimationsAreDisabled;
private boolean enableMergePaths;
@Nullable private CompositionLayer compositionLayer;
private int alpha = 255;
private boolean performanceTrackingEnabled;
public LottieDrawable() {
animator.setRepeatCount(0);
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override public void onAnimationUpdate(ValueAnimator animation) {
if (systemAnimationsAreDisabled) {
animator.cancel();
setProgress(1f);
} else {
setProgress((float) animation.getAnimatedValue());
}
}
});
}
/**
* Returns whether or not any layers in this composition has masks.
*/
public boolean hasMasks() {
return compositionLayer != null && compositionLayer.hasMasks();
}
/**
* Returns whether or not any layers in this composition has a matte layer.
*/
public boolean hasMatte() {
return compositionLayer != null && compositionLayer.hasMatte();
}
boolean enableMergePathsForKitKatAndAbove() {
return enableMergePaths;
}
/**
* Enable this to get merge path support for devices running KitKat (19) and above.
*
* Merge paths currently don't work if the the operand shape is entirely contained within the
* first shape. If you need to cut out one shape from another shape, use an even-odd fill type
* instead of using merge paths.
*/
public void enableMergePathsForKitKatAndAbove(boolean enable) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
Log.w(TAG, "Merge paths are not supported pre-Kit Kat.");
return;
}
enableMergePaths = enable;
if (composition != null) {
buildCompositionLayer();
}
}
/**
* If you use image assets, you must explicitly specify the folder in assets/ in which they are
* located because bodymovin uses the name filenames across all compositions (img_#).
* Do NOT rename the images themselves.
*
* If your images are located in src/main/assets/airbnb_loader/ then call
* `setImageAssetsFolder("airbnb_loader/");`.
*
*
* If you use LottieDrawable directly, you MUST call {@link #recycleBitmaps()} when you
* are done. Calling {@link #recycleBitmaps()} doesn't have to be final and {@link LottieDrawable}
* will recreate the bitmaps if needed but they will leak if you don't recycle them.
*/
public void setImagesAssetsFolder(@Nullable String imageAssetsFolder) {
this.imageAssetsFolder = imageAssetsFolder;
}
@Nullable public String getImageAssetsFolder() {
return imageAssetsFolder;
}
/**
* If you have image assets and use {@link LottieDrawable} directly, you must call this yourself.
*
* Calling recycleBitmaps() doesn't have to be final and {@link LottieDrawable}
* will recreate the bitmaps if needed but they will leak if you don't recycle them.
*
*/
public void recycleBitmaps() {
if (imageAssetManager != null) {
imageAssetManager.recycleBitmaps();
}
}
/**
* @return True if the composition is different from the previously set composition, false otherwise.
*/
public boolean setComposition(LottieComposition composition) {
if (this.composition == composition) {
return false;
}
clearComposition();
this.composition = composition;
setSpeed(speed);
updateBounds();
buildCompositionLayer();
applyColorFilters();
setProgress(progress);
Iterator<LazyCompositionTask> it = lazyCompositionTasks.iterator();
while (it.hasNext()) {
LazyCompositionTask t = it.next();
t.run(composition);
it.remove();
}
composition.setPerformanceTrackingEnabled(performanceTrackingEnabled);
return true;
}
public void setPerformanceTrackingEnabled(boolean enabled) {
performanceTrackingEnabled = enabled;
if (composition != null) {
composition.setPerformanceTrackingEnabled(enabled);
}
}
@Nullable
public PerformanceTracker getPerformanceTracker() {
if (composition != null) {
return composition.getPerformanceTracker();
}
return null;
}
private void buildCompositionLayer() {
compositionLayer = new CompositionLayer(
this, Layer.Factory.newInstance(composition), composition.getLayers(), composition);
}
private void applyColorFilters() {
if (compositionLayer == null) {
return;
}
for (ColorFilterData data : colorFilterData) {
compositionLayer.addColorFilter(data.layerName, data.contentName, data.colorFilter);
}
}
private void clearComposition() {
recycleBitmaps();
compositionLayer = null;
imageAssetManager = null;
invalidateSelf();
}
@Override public void invalidateSelf() {
final Callback callback = getCallback();
if (callback != null) {
callback.invalidateDrawable(this);
}
}
@Override public void setAlpha(@IntRange(from = 0, to = 255) int alpha) {
this.alpha = alpha;
}
@Override public int getAlpha() {
return alpha;
}
@Override public void setColorFilter(@Nullable ColorFilter colorFilter) {
// Do nothing.
}
/**
* Add a color filter to specific content on a specific layer.
* @param layerName name of the layer where the supplied content name lives
* @param contentName name of the specific content that the color filter is to be applied
* @param colorFilter the color filter, null to clear the color filter
*/
public void addColorFilterToContent(String layerName, String contentName,
@Nullable ColorFilter colorFilter) {
addColorFilterInternal(layerName, contentName, colorFilter);
}
/**
* Add a color filter to a whole layer
* @param layerName name of the layer that the color filter is to be applied
* @param colorFilter the color filter, null to clear the color filter
*/
public void addColorFilterToLayer(String layerName, @Nullable ColorFilter colorFilter) {
addColorFilterInternal(layerName, null, colorFilter);
}
/**
* Add a color filter to all layers
* @param colorFilter the color filter, null to clear all color filters
*/
public void addColorFilter(ColorFilter colorFilter) {
addColorFilterInternal(null, null, colorFilter);
}
/**
* Clear all color filters on all layers and all content in the layers
*/
public void clearColorFilters() {
colorFilterData.clear();
addColorFilterInternal(null, null, null);
}
/**
* Private method to capture all color filter additions.
* There are 3 different behaviors here.
* 1. layerName is null. All layers supporting color filters will apply the passed in color filter
* 2. layerName is not null, contentName is null. This will apply the passed in color filter
* to the whole layer
* 3. layerName is not null, contentName is not null. This will apply the pass in color filter
* to a specific composition content.
*/
private void addColorFilterInternal(@Nullable String layerName, @Nullable String contentName,
@Nullable ColorFilter colorFilter) {
final ColorFilterData data = new ColorFilterData(layerName, contentName, colorFilter);
if (colorFilter == null && colorFilterData.contains(data)) {
colorFilterData.remove(data);
} else {
colorFilterData.add(new ColorFilterData(layerName, contentName, colorFilter));
}
if (compositionLayer == null) {
return;
}
compositionLayer.addColorFilter(layerName, contentName, colorFilter);
}
@Override public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}
@Override public void draw(@NonNull Canvas canvas) {
L.beginSection("Drawable#draw");
if (compositionLayer == null) {
return;
}
float scale = this.scale;
float extraScale = 1f;
boolean hasExtraScale = false;
float maxScale = getMaxScale(canvas);
if (compositionLayer.hasMatte() || compositionLayer.hasMasks()) {
// Since we can only scale up the animation so much before masks and mattes get clipped, we
// may have to scale the canvas to fake the rest. This isn't a problem for software rendering
// but hardware accelerated scaling is rasterized so it will appear pixelated.
extraScale = scale / maxScale;
scale = Math.min(scale, maxScale);
// This check fixes some floating point rounding issues.
hasExtraScale = extraScale > 1.001f;
}
if (hasExtraScale) {
canvas.save();
// This is extraScale ^2 because what happens is when the scale increases, the intrinsic size
// of the view increases. That causes the drawable to keep growing even though we are only
// rendering to the size of the view in the top left quarter, leaving the rest blank.
// The first scale by extraScale scales up the canvas so that we are back at the original
// size. The second extraScale is what actually has the scaling effect.
float extraScaleSquared = extraScale * extraScale;
int px = (int) ((composition.getBounds().width() * scale / 2f));
int py = (int) ((composition.getBounds().height() * scale / 2f));
canvas.scale(extraScaleSquared, extraScaleSquared, px, py);
}
matrix.reset();
matrix.preScale(scale, scale);
compositionLayer.draw(canvas, matrix, alpha);
if (hasExtraScale) {
canvas.restore();
}
L.endSection("Drawable#draw");
}
void systemAnimationsAreDisabled() {
systemAnimationsAreDisabled = true;
}
public void loop(boolean loop) {
animator.setRepeatCount(loop ? ValueAnimator.INFINITE : 0);
}
public boolean isLooping() {
return animator.getRepeatCount() == ValueAnimator.INFINITE;
}
public boolean isAnimating() {
return animator.isRunning();
}
public void playAnimation() {
playAnimation((progress > 0.0 && progress < 1.0));
}
public void resumeAnimation() {
playAnimation(true);
}
private void playAnimation(boolean setStartTime) {
if (compositionLayer == null) {
lazyCompositionTasks.add(new LazyCompositionTask() {
@Override public void run(LottieComposition composition) {
playAnimation();
}
});
return;
}
long playTime = setStartTime ? (long) (progress * animator.getDuration()) : 0;
animator.start();
if (setStartTime) {
animator.setCurrentPlayTime(playTime);
}
}
public void playAnimation(final int startFrame, final int endFrame) {
if (composition == null) {
lazyCompositionTasks.add(new LazyCompositionTask() {
@Override public void run(LottieComposition composition) {
playAnimation(startFrame / composition.getDurationFrames(),
endFrame / composition.getDurationFrames());
}
});
return;
}
playAnimation(startFrame / composition.getDurationFrames(),
endFrame / composition.getDurationFrames());
}
public void playAnimation(@FloatRange(from = 0f, to = 1f) float startProgress,
@FloatRange(from = 0f, to = 1f) float endProgress) {
animator.updateValues(startProgress, endProgress);
animator.setCurrentPlayTime(0);
setProgress(startProgress);
playAnimation(false);
}
public void resumeReverseAnimation() {
reverseAnimation(true);
}
public void reverseAnimation() {
reverseAnimation((progress > 0.0 && progress < 1.0));
}
private void reverseAnimation(boolean setStartTime) {
if (compositionLayer == null) {
lazyCompositionTasks.add(new LazyCompositionTask() {
@Override public void run(LottieComposition composition) {
reverseAnimation();
}
});
return;
}
if (setStartTime) {
animator.setCurrentPlayTime((long) (progress * animator.getDuration()));
}
animator.reverse();
}
public void setMinFrame(final int startFrame) {
if (composition == null) {
lazyCompositionTasks.add(new LazyCompositionTask() {
@Override public void run(LottieComposition composition) {
setMinProgress(startFrame / composition.getDurationFrames());
}
});
return;
}
setMinProgress(startFrame / composition.getDurationFrames());
}
public void setMinProgress(float startProgress) {
animator.setStartProgress(startProgress);
}
public void setMaxFrame(final int endFrame) {
if (composition == null) {
lazyCompositionTasks.add(new LazyCompositionTask() {
@Override public void run(LottieComposition composition) {
setMaxProgress(endFrame / composition.getDurationFrames());
}
});
return;
}
setMaxProgress(endFrame / composition.getDurationFrames());
}
public void setMaxProgress(float endProgress) {
animator.setEndProgress(endProgress);
}
public void setMinAndMaxFrame(int minFrame, int maxFrame) {
setMinFrame(minFrame);
setMaxFrame(maxFrame);
}
public void setMinAndMaxProgress(float minProgress, float maxProgress) {
setMinProgress(minProgress);
setMaxProgress(maxProgress);
}
public void setSpeed(float speed) {
this.speed = speed;
animator.setIsReversed(speed < 0);
if (composition != null) {
animator.setDuration((long) (composition.getDuration() / Math.abs(speed)));
}
}
public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
this.progress = progress;
if (compositionLayer != null) {
compositionLayer.setProgress(progress);
}
}
public float getProgress() {
return progress;
}
/**
* Set the scale on the current composition. The only cost of this function is re-rendering the
* current frame so you may call it frequent to scale something up or down.
*
* The smaller the animation is, the better the performance will be. You may find that scaling an
* animation down then rendering it in a larger ImageView and letting ImageView scale it back up
* with a scaleType such as centerInside will yield better performance with little perceivable
* quality loss.
*/
public void setScale(float scale) {
this.scale = scale;
updateBounds();
}
/**
* Use this if you can't bundle images with your app. This may be useful if you download the
* animations from the network or have the images saved to an SD Card. In that case, Lottie
* will defer the loading of the bitmap to this delegate.
*/
public void setImageAssetDelegate(
@SuppressWarnings("NullableProblems") ImageAssetDelegate assetDelegate) {
this.imageAssetDelegate = assetDelegate;
if (imageAssetManager != null) {
imageAssetManager.setDelegate(assetDelegate);
}
}
/**
* Use this to manually set fonts.
*/
public void setFontAssetDelegate(
@SuppressWarnings("NullableProblems") FontAssetDelegate assetDelegate) {
this.fontAssetDelegate = assetDelegate;
if (fontAssetManager != null) {
fontAssetManager.setDelegate(assetDelegate);
}
}
public void setTextDelegate(@SuppressWarnings("NullableProblems") TextDelegate textDelegate) {
this.textDelegate = textDelegate;
}
@Nullable TextDelegate getTextDelegate() {
return textDelegate;
}
boolean useTextGlyphs() {
return textDelegate == null && composition.getCharacters().size() > 0;
}
public float getScale() {
return scale;
}
public LottieComposition getComposition() {
return composition;
}
private void updateBounds() {
if (composition == null) {
return;
}
float scale = getScale();
setBounds(0, 0, (int) (composition.getBounds().width() * scale),
(int) (composition.getBounds().height() * scale));
}
public void cancelAnimation() {
lazyCompositionTasks.clear();
animator.cancel();
}
public void addAnimatorUpdateListener(ValueAnimator.AnimatorUpdateListener updateListener) {
animator.addUpdateListener(updateListener);
}
public void removeAnimatorUpdateListener(ValueAnimator.AnimatorUpdateListener updateListener) {
animator.removeUpdateListener(updateListener);
}
public void addAnimatorListener(Animator.AnimatorListener listener) {
animator.addListener(listener);
}
public void removeAnimatorListener(Animator.AnimatorListener listener) {
animator.removeListener(listener);
}
@Override public int getIntrinsicWidth() {
return composition == null ? -1 : (int) (composition.getBounds().width() * scale);
}
@Override public int getIntrinsicHeight() {
return composition == null ? -1 : (int) (composition.getBounds().height() * scale);
}
/**
* Allows you to modify or clear a bitmap that was loaded for an image either automatically
* through {@link #setImagesAssetsFolder(String)} or with an {@link ImageAssetDelegate}.
*
* @return the previous Bitmap or null.
*/
@Nullable
public Bitmap updateBitmap(String id, @Nullable Bitmap bitmap) {
ImageAssetManager bm = getImageAssetManager();
if (bm == null) {
Log.w(L.TAG, "Cannot update bitmap. Most likely the drawable is not added to a View " +
"which prevents Lottie from getting a Context.");
return null;
}
Bitmap ret = bm.updateBitmap(id, bitmap);
invalidateSelf();
return ret;
}
@Nullable
Bitmap getImageAsset(String id) {
ImageAssetManager bm = getImageAssetManager();
if (bm != null) {
return bm.bitmapForId(id);
}
return null;
}
private ImageAssetManager getImageAssetManager() {
if (getCallback() == null) {
// We can't get a bitmap since we can't get a Context from the callback.
return null;
}
if (imageAssetManager != null && !imageAssetManager.hasSameContext(getContext())) {
imageAssetManager.recycleBitmaps();
imageAssetManager = null;
}
if (imageAssetManager == null) {
imageAssetManager = new ImageAssetManager(getCallback(),
imageAssetsFolder, imageAssetDelegate, composition.getImages());
}
return imageAssetManager;
}
@Nullable
Typeface getTypeface(String fontFamily, String style) {
FontAssetManager assetManager = getFontAssetManager();
if (assetManager != null) {
return assetManager.getTypeface(fontFamily, style);
}
return null;
}
private FontAssetManager getFontAssetManager() {
if (getCallback() == null) {
// We can't get a bitmap since we can't get a Context from the callback.
return null;
}
if (fontAssetManager == null) {
fontAssetManager = new FontAssetManager(getCallback(), fontAssetDelegate);
}
return fontAssetManager;
}
private @Nullable Context getContext() {
Callback callback = getCallback();
if (callback == null) {
return null;
}
if (callback instanceof View) {
return ((View) callback).getContext();
}
return null;
}
/**
* If there are masks or mattes, we can't scale the animation larger than the canvas or else
* the off screen rendering for masks and mattes after saveLayer calls will get clipped.
*/
private float getMaxScale(@NonNull Canvas canvas) {
float maxScaleX = canvas.getWidth() / (float) composition.getBounds().width();
float maxScaleY = canvas.getHeight() / (float) composition.getBounds().height();
return Math.min(maxScaleX, maxScaleY);
}
/**
* These Drawable.Callback methods proxy the calls so that this is the drawable that is
* actually invalidated, not a child one which will not pass the view's validateDrawable check.
*/
@Override public void invalidateDrawable(@NonNull Drawable who) {
Callback callback = getCallback();
if (callback == null) {
return;
}
callback.invalidateDrawable(this);
}
@Override public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) {
Callback callback = getCallback();
if (callback == null) {
return;
}
callback.scheduleDrawable(this, what, when);
}
@Override public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) {
Callback callback = getCallback();
if (callback == null) {
return;
}
callback.unscheduleDrawable(this, what);
}
private static class ColorFilterData {
final String layerName;
@Nullable final String contentName;
@Nullable final ColorFilter colorFilter;
ColorFilterData(@Nullable String layerName, @Nullable String contentName,
@Nullable ColorFilter colorFilter) {
this.layerName = layerName;
this.contentName = contentName;
this.colorFilter = colorFilter;
}
@Override public int hashCode() {
int hashCode = 17;
if (layerName != null) {
hashCode = hashCode * 31 * layerName.hashCode();
}
if (contentName != null) {
hashCode = hashCode * 31 * contentName.hashCode();
}
return hashCode;
}
@Override public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof ColorFilterData)) {
return false;
}
final ColorFilterData other = (ColorFilterData) obj;
return hashCode() == other.hashCode() && colorFilter == other.colorFilter;
}
}
}