/**
* Copyright (c) 2016-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.keyframes;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.LinearGradient;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.Region;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import android.util.SparseArray;
import com.facebook.keyframes.model.KFAnimationGroup;
import com.facebook.keyframes.model.KFFeature;
import com.facebook.keyframes.model.KFGradient;
import com.facebook.keyframes.model.KFImage;
import com.facebook.keyframes.model.keyframedmodels.KeyFramedGradient;
import com.facebook.keyframes.model.keyframedmodels.KeyFramedOpacity;
import com.facebook.keyframes.model.keyframedmodels.KeyFramedPath;
import com.facebook.keyframes.model.keyframedmodels.KeyFramedStrokeWidth;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* This drawable will render a KFImage model by painting paths to the supplied canvas in
* {@link #draw(Canvas)}. There are methods to begin and end animation playback here, which need to
* be managed carefully so as not to leave animation callbacks running indefinitely. At each
* animation callback, the next frame's matrices and paths are calculated and the drawable is then
* invalidated.
*/
public class KeyframesDrawable extends Drawable
implements KeyframesDrawableAnimationCallback.FrameListener, KeyframesDirectionallyScalingDrawable {
private static final float GRADIENT_PRECISION_PER_SECOND = 30;
/**
* The KFImage object to render.
*/
private final KFImage mKFImage;
/**
* A recyclable {@link Paint} object used to draw all of the features.
*/
private final Paint mDrawingPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
/**
* The list of all {@link FeatureState}s, containing all information needed to render a feature
* for the current progress of animation.
*/
private final List<FeatureState> mFeatureStateList;
/**
* The current state of animation layer matrices for this animation, keyed by animation group id.
*/
private final SparseArray<Matrix> mAnimationGroupMatrices;
/**
* The animation callback object used to start and stop the animation.
*/
private final KeyframesDrawableAnimationCallback mKeyframesDrawableAnimationCallback;
/**
* A recyclable matrix that can be reused.
*/
private final Matrix mRecyclableTransformMatrix;
/**
* The scale matrix to be applied for the final size of this drawable.
*/
private final Matrix mScaleMatrix;
private final Matrix mInverseScaleMatrix;
/**
* The currently set width and height of this drawable.
*/
private int mSetWidth;
private int mSetHeight;
/**
* The X and Y scales to be used, calculated from the set dimensions compared with the exported
* canvas size of the image.
*/
private float mScale;
private float mScaleFromCenter;
private float mScaleFromEnd;
private final Map<String, Bitmap> mBitmaps;
private boolean mClipToAECanvas;
private boolean mHasInitialized = false;
/**
* Create a new KeyframesDrawable with the supplied values from the builder.
* @param builder
*/
KeyframesDrawable(KeyframesDrawableBuilder builder) {
mKFImage = builder.getImage();
mBitmaps = builder.getExperimentalFeatures().getBitmaps() == null ?
null :
Collections.unmodifiableMap(builder.getExperimentalFeatures().getBitmaps());
mRecyclableTransformMatrix = new Matrix();
mScaleMatrix = new Matrix();
mInverseScaleMatrix = new Matrix();
mKeyframesDrawableAnimationCallback = KeyframesDrawableAnimationCallback.create(this, mKFImage);
mDrawingPaint.setStrokeCap(Paint.Cap.ROUND);
// Setup feature state list
List<FeatureState> featureStateList = new ArrayList<>();
for (int i = 0, len = mKFImage.getFeatures().size(); i < len; i++) {
featureStateList.add(new FeatureState(mKFImage.getFeatures().get(i)));
}
mFeatureStateList = Collections.unmodifiableList(featureStateList);
// Setup animation layers
mAnimationGroupMatrices = new SparseArray<>();
List<KFAnimationGroup> animationGroups = mKFImage.getAnimationGroups();
for (int i = 0, len = animationGroups.size(); i < len; i++) {
mAnimationGroupMatrices.put(animationGroups.get(i).getGroupId(), new Matrix());
}
setMaxFrameRate(builder.getMaxFrameRate());
mClipToAECanvas = builder.getExperimentalFeatures().getClipToAECanvas();
}
/**
* Sets the bounds of this drawable. Here, we calculate values needed to scale the image from the
* size it was when exported to a size to be drawn on the Android canvas.
*/
@Override
public void setBounds(int left, int top, int right, int bottom) {
super.setBounds(left, top, right, bottom);
mSetWidth = right - left;
mSetHeight = bottom - top;
float idealXScale = (float) mSetWidth / mKFImage.getCanvasSize()[0];
float idealYScale = (float) mSetHeight / mKFImage.getCanvasSize()[1];
mScale = Math.min(idealXScale, idealYScale);
calculateScaleMatrix(1, 1, ScaleDirection.UP);
if (!mHasInitialized) {
// Call this at least once or else nothing will render. But if this is called this every time
// setBounds is called then the animation will reset when resizing.
setFrameProgress(0);
}
}
@Override
public void setDirectionalScale(
float scaleFromCenter,
float scaleFromEnd,
ScaleDirection direction) {
calculateScaleMatrix(scaleFromCenter, scaleFromEnd, direction);
}
/**
* Iterates over the current state of mPathsForDrawing and draws each path, applying properties
* of the feature to a recycled Paint object.
*/
@Override
public void draw(Canvas canvas) {
Rect currBounds = getBounds();
canvas.translate(currBounds.left, currBounds.top);
if (mClipToAECanvas) {
canvas.clipRect(
0,
0,
mKFImage.getCanvasSize()[0] * mScale * mScaleFromEnd * mScaleFromCenter,
mKFImage.getCanvasSize()[1] * mScale * mScaleFromEnd * mScaleFromCenter);
}
KFPath pathToDraw;
FeatureState featureState;
for (int i = 0, len = mFeatureStateList.size(); i < len; i++) {
featureState = mFeatureStateList.get(i);
if (!featureState.isVisible()) {
continue;
}
final Bitmap backedImage = featureState.getBackedImageBitmap();
final Matrix uniqueFeatureMatrix = featureState.getUniqueFeatureMatrix();
if (backedImage != null && uniqueFeatureMatrix != null) {
// This block is for the experimental bitmap supporting
canvas.save();
canvas.concat(mScaleMatrix);
canvas.drawBitmap(backedImage, uniqueFeatureMatrix, null);
canvas.restore();
continue;
}
pathToDraw = featureState.getCurrentPathForDrawing();
if (pathToDraw == null || pathToDraw.isEmpty()) {
continue;
}
if (featureState.getCurrentMaskPath() != null) {
canvas.save();
applyScaleAndClipCanvas(canvas, featureState.getCurrentMaskPath(), Region.Op.INTERSECT);
}
mDrawingPaint.setShader(null);
mDrawingPaint.setStrokeCap(featureState.getStrokeLineCap());
if (featureState.getFillColor() != Color.TRANSPARENT) {
mDrawingPaint.setStyle(Paint.Style.FILL);
if (featureState.getCurrentShader() == null) {
mDrawingPaint.setColor(featureState.getFillColor());
mDrawingPaint.setAlpha(featureState.getAlpha());
applyScaleAndDrawPath(canvas, pathToDraw, mDrawingPaint);
} else {
mDrawingPaint.setShader(featureState.getCurrentShader());
applyScaleToCanvasAndDrawPath(canvas, pathToDraw, mDrawingPaint);
}
}
if (featureState.getStrokeColor() != Color.TRANSPARENT && featureState.getStrokeWidth() > 0) {
mDrawingPaint.setColor(featureState.getStrokeColor());
mDrawingPaint.setAlpha(featureState.getAlpha());
mDrawingPaint.setStyle(Paint.Style.STROKE);
mDrawingPaint.setStrokeWidth(
featureState.getStrokeWidth() * mScale * mScaleFromCenter * mScaleFromEnd);
applyScaleAndDrawPath(canvas, pathToDraw, mDrawingPaint);
}
if (featureState.getCurrentMaskPath() != null) {
canvas.restore();
}
}
canvas.translate(-currBounds.left, -currBounds.top);
}
private void applyScaleAndClipCanvas(Canvas canvas, KFPath path, Region.Op op) {
path.transform(mScaleMatrix);
canvas.clipPath(path.getPath(), op);
path.transform(mInverseScaleMatrix);
}
private void applyScaleAndDrawPath(Canvas canvas, KFPath path, Paint paint) {
path.transform(mScaleMatrix);
canvas.drawPath(path.getPath(), paint);
path.transform(mInverseScaleMatrix);
}
/**
* Note: This method is only necessary because of cached gradient shaders with a fixed size. We
* need to scale the canvas in this case rather than scaling the path.
*/
private void applyScaleToCanvasAndDrawPath(Canvas canvas, KFPath path, Paint paint) {
canvas.concat(mScaleMatrix);
canvas.drawPath(path.getPath(), paint);
canvas.concat(mInverseScaleMatrix);
}
/**
* Unsupported for now
*/
@Override
public void setAlpha(int alpha) {
}
/**
* Unsupported for now
*/
@Override
public void setColorFilter(ColorFilter cf) {
}
/**
* Unsupported for now
*/
@Override
public int getOpacity() {
return PixelFormat.OPAQUE;
}
/**
* Starts the animation callbacks for this drawable. A corresponding call to
* {@link #stopAnimationAtLoopEnd()}, {@link #stopAnimation() or {@link #pauseAnimation()}
* needs to be called eventually, or the callback will continue to post callbacks
* for this drawable indefinitely.
*/
public void startAnimation() {
mKeyframesDrawableAnimationCallback.start();
}
/**
* Starts the animation and plays it once
*/
public void playOnce() {
mKeyframesDrawableAnimationCallback.playOnce();
}
/**
* Stops the animation callbacks for this drawable immediately.
*/
public void stopAnimation() {
mKeyframesDrawableAnimationCallback.stop();
}
/**
* Pauses the animation callbacks for this drawable immediately.
*/
public void pauseAnimation() {
mKeyframesDrawableAnimationCallback.pause();
}
/**
* Resumes the animation callbacks for this drawable. A corresponding call to
* {@link #stopAnimationAtLoopEnd()}, {@link #stopAnimation() or {@link #pauseAnimation()}
* needs to be called eventually, or the callback will continue to post callbacks
* for this drawable indefinitely.
*/
public void resumeAnimation() {
mKeyframesDrawableAnimationCallback.resume();
}
/**
* Finishes the current playthrough of the animation and stops animating this drawable afterwards.
*/
public void stopAnimationAtLoopEnd() {
mKeyframesDrawableAnimationCallback.stopAtLoopEnd();
}
/**
* Given a progress in terms of frames, calculates each of the paths needed to be drawn in
* {@link #draw(Canvas)}.
*/
public void setFrameProgress(float frameProgress) {
mHasInitialized = true;
mKFImage.setAnimationMatrices(mAnimationGroupMatrices, frameProgress);
for (int i = 0, len = mFeatureStateList.size(); i < len; i++) {
mFeatureStateList.get(i).setupFeatureStateForProgress(frameProgress);
}
}
public void seekToProgress(float progress) {
stopAnimation();
onProgressUpdate(progress * mKFImage.getFrameCount());
}
/**
* The callback used to update the frame progress of this drawable. This leads to a recalculation
* of the paths that need to be drawn before the Drawable invalidates itself.
*/
@Override
public void onProgressUpdate(float frameProgress) {
setFrameProgress(frameProgress);
invalidateSelf();
}
@Override
public void onStop() {
if (mOnAnimationEnd == null) {
return;
}
final OnAnimationEnd onAnimationEnd = mOnAnimationEnd.get();
if (onAnimationEnd == null) {
return;
}
onAnimationEnd.onAnimationEnd();
mOnAnimationEnd.clear();
}
private WeakReference<OnAnimationEnd> mOnAnimationEnd;
public void setAnimationListener(OnAnimationEnd listener) {
mOnAnimationEnd = new WeakReference<>(listener);
}
private void calculateScaleMatrix(
float scaleFromCenter,
float scaleFromEnd,
ScaleDirection scaleDirection) {
if (mScaleFromCenter == scaleFromCenter &&
mScaleFromEnd == scaleFromEnd) {
return;
}
mScaleMatrix.setScale(mScale, mScale);
if (scaleFromCenter == 1 && scaleFromEnd == 1) {
mScaleFromCenter = 1;
mScaleFromEnd = 1;
mScaleMatrix.invert(mInverseScaleMatrix);
return;
}
float scaleYPoint = scaleDirection == ScaleDirection.UP ? mSetHeight : 0;
mScaleMatrix.postScale(scaleFromCenter, scaleFromCenter, mSetWidth / 2, mSetHeight / 2);
mScaleMatrix.postScale(scaleFromEnd, scaleFromEnd, mSetWidth / 2, scaleYPoint);
mScaleFromCenter = scaleFromCenter;
mScaleFromEnd = scaleFromEnd;
mScaleMatrix.invert(mInverseScaleMatrix);
}
/**
* Cap the frame rate to a specific FPS. Consider using this for low end devices.
* Calls {@link KeyframesDrawableAnimationCallback#setMaxFrameRate}
* @param maxFrameRate
*/
public void setMaxFrameRate(int maxFrameRate) {
mKeyframesDrawableAnimationCallback.setMaxFrameRate(maxFrameRate);
}
private class FeatureState {
private final KFFeature mFeature;
// Reuseable modifiable objects for drawing
private final KFPath mPath;
private final KFPath mFeatureMaskPath;
private final KeyFramedStrokeWidth.StrokeWidth mStrokeWidth;
private final KeyFramedOpacity.Opacity mOpacity;
private final Matrix mFeatureMatrix;
private final float[] mMatrixValueRecyclableArray = new float[9];
private final Matrix mFeatureMaskMatrix;
private boolean mIsVisible;
public Matrix getUniqueFeatureMatrix() {
if (mFeatureMatrix == mRecyclableTransformMatrix) {
// Don't return a matrix unless it's known to be unique for this feature
return null;
}
return mFeatureMatrix;
}
// Cached shader vars
private Shader[] mCachedShaders;
private Shader mCurrentShader;
public FeatureState(KFFeature feature) {
mFeature = feature;
if (hasCustomDrawable()) {
mPath = null;
mStrokeWidth = null;
// Bitmap features use the matrix later in draw()
// so there's no way to reuse a globally cached matrix
mFeatureMatrix = new Matrix();
} else {
mPath = new KFPath();
mStrokeWidth = new KeyFramedStrokeWidth.StrokeWidth();
// Path features use the matrix immediately
// so there's no need to waste memory with a unique copy
mFeatureMatrix = mRecyclableTransformMatrix;
}
mOpacity = new KeyFramedOpacity.Opacity();
if (mFeature.getFeatureMask() != null) {
mFeatureMaskPath = new KFPath();
mFeatureMaskMatrix = new Matrix();
} else {
mFeatureMaskPath = null;
mFeatureMaskMatrix = null;
}
assert mFeatureMatrix != null;
}
public void setupFeatureStateForProgress(float frameProgress) {
if (frameProgress < mFeature.getFromFrame() || frameProgress > mFeature.getToFrame()) {
mIsVisible = false;
return;
}
mIsVisible = true;
mFeature.setAnimationMatrix(mFeatureMatrix, frameProgress);
Matrix layerTransformMatrix = mAnimationGroupMatrices.get(mFeature.getAnimationGroup());
if (layerTransformMatrix != null && !layerTransformMatrix.isIdentity()) {
mFeatureMatrix.postConcat(layerTransformMatrix);
}
KeyFramedPath path = mFeature.getPath();
if (hasCustomDrawable() || path == null) {
return; // skip all the path stuff
}
mPath.reset();
path.apply(frameProgress, mPath);
mPath.transform(mFeatureMatrix);
mFeature.setStrokeWidth(mStrokeWidth, frameProgress);
mStrokeWidth.adjustScale(extractScaleFromMatrix(mFeatureMatrix));
mFeature.setOpacity(mOpacity, frameProgress);
if (mFeature.getEffect() != null) {
prepareShadersForFeature(mFeature);
}
mCurrentShader = getNearestShaderForFeature(frameProgress);
if (mFeature.getFeatureMask() != null) {
mFeature.getFeatureMask().setAnimationMatrix(mFeatureMaskMatrix, frameProgress);
mFeatureMaskPath.reset();
mFeature.getFeatureMask().getPath().apply(frameProgress, mFeatureMaskPath);
mFeatureMaskPath.transform(mFeatureMaskMatrix);
}
}
public KFPath getCurrentPathForDrawing() {
return mPath;
}
public KFPath getCurrentMaskPath() {
return mFeatureMaskPath;
}
public float getStrokeWidth() {
return mStrokeWidth != null ? mStrokeWidth.getStrokeWidth() : 0;
}
public float getOpacity() {
return mOpacity.getOpacity() / 100;
}
public int getAlpha() {
return Math.round(0xFF * getOpacity());
}
public Shader getCurrentShader() {
return mCurrentShader;
}
public int getStrokeColor() {
return mFeature.getStrokeColor();
}
public int getFillColor() {
return mFeature.getFillColor();
}
public Paint.Cap getStrokeLineCap() {
return mFeature.getStrokeLineCap();
}
public boolean isVisible() {
return mIsVisible;
}
private void prepareShadersForFeature(KFFeature feature) {
if (mCachedShaders != null) {
return;
}
int frameRate = mKFImage.getFrameRate();
int numFrames = mKFImage.getFrameCount();
int precision = Math.round(GRADIENT_PRECISION_PER_SECOND * numFrames / frameRate);
mCachedShaders = new LinearGradient[precision + 1];
float progress;
KeyFramedGradient.GradientColorPair colorPair = new KeyFramedGradient.GradientColorPair();
KFGradient gradient = feature.getEffect().getGradient();
for (int i = 0; i < precision; i++) {
progress = i / (float) (precision) * numFrames;
gradient.getStartGradient().apply(progress, colorPair);
gradient.getEndGradient().apply(progress, colorPair);
mCachedShaders[i] = new LinearGradient(
0,
0,
0,
mKFImage.getCanvasSize()[1],
colorPair.getStartColor(),
colorPair.getEndColor(),
Shader.TileMode.CLAMP);
}
}
public Shader getNearestShaderForFeature(float frameProgress) {
if (mCachedShaders == null) {
return null;
}
int shaderIndex =
(int) ((frameProgress / mKFImage.getFrameCount()) * (mCachedShaders.length - 1));
return mCachedShaders[shaderIndex];
}
public final Bitmap getBackedImageBitmap() {
if (mBitmaps == null) return null;
return mBitmaps.get(mFeature.getBackedImageName());
}
private boolean hasCustomDrawable() {
return getBackedImageBitmap() != null;
}
private float extractScaleFromMatrix(Matrix matrix) {
matrix.getValues(mMatrixValueRecyclableArray);
return (Math.abs(mMatrixValueRecyclableArray[0]) +
Math.abs(mMatrixValueRecyclableArray[4])) / 2f;
}
}
public interface OnAnimationEnd {
void onAnimationEnd();
}
}