/*
* Copyright (c) 2015-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.fresco.animation.drawable;
import javax.annotation.Nullable;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import com.facebook.common.logging.FLog;
import com.facebook.drawable.base.DrawableWithCaches;
import com.facebook.drawee.drawable.DrawableProperties;
import com.facebook.fresco.animation.backend.AnimationBackend;
import com.facebook.fresco.animation.backend.AnimationInformation;
import com.facebook.fresco.animation.frame.DropFramesFrameScheduler;
import com.facebook.fresco.animation.frame.FrameScheduler;
/**
* Experimental new animated drawable that uses a supplied
* {@link AnimationBackend} for drawing frames.
*/
public class AnimatedDrawable2 extends Drawable implements Animatable, DrawableWithCaches {
/**
* {@link #draw(Canvas)} listener that is notified for each draw call. Can be used for debugging.
*/
public interface DrawListener {
void onDraw(
AnimatedDrawable2 animatedDrawable,
FrameScheduler frameScheduler,
int frameNumberToDraw,
boolean frameDrawn,
boolean isAnimationRunning,
long animationStartTimeMs,
long animationTimeMs,
long lastFrameAnimationTimeMs,
long actualRenderTimeStartMs,
long actualRenderTimeEndMs,
long startRenderTimeForNextFrameMs,
long scheduledRenderTimeForNextFrameMs);
}
private static final Class<?> TAG = AnimatedDrawable2.class;
private static final AnimationListener NO_OP_LISTENER = new BaseAnimationListener();
private static final int DEFAULT_FRAME_SCHEDULING_DELAY_MS = 8;
private static final int DEFAULT_FRAME_SCHEDULING_OFFSET_MS = 0;
@Nullable
private AnimationBackend mAnimationBackend;
@Nullable
private FrameScheduler mFrameScheduler;
// Animation parameters
private volatile boolean mIsRunning;
private long mStartTimeMs;
private long mLastFrameAnimationTimeMs;
private long mFrameSchedulingDelayMs = DEFAULT_FRAME_SCHEDULING_DELAY_MS;
private long mFrameSchedulingOffsetMs = DEFAULT_FRAME_SCHEDULING_OFFSET_MS;
// Animation statistics
private int mDroppedFrames;
// Listeners
private volatile AnimationListener mAnimationListener = NO_OP_LISTENER;
@Nullable
private volatile DrawListener mDrawListener = null;
// Holder for drawable properties like alpha to be able to re-apply if the backend changes.
// The instance is created lazily only if needed.
@Nullable
private DrawableProperties mDrawableProperties;
/**
* Runnable that invalidates the drawable that will be scheduled according to the next
* target frame.
*/
private final Runnable mInvalidateRunnable = new Runnable() {
@Override
public void run() {
// Remove all potential other scheduled runnables
// (e.g. if the view has been invalidated a lot)
unscheduleSelf(mInvalidateRunnable);
// Draw the next frame
invalidateSelf();
}
};
public AnimatedDrawable2() {
this(null);
}
public AnimatedDrawable2(
@Nullable AnimationBackend animationBackend) {
mAnimationBackend = animationBackend;
mFrameScheduler = createSchedulerForBackendAndDelayMethod(mAnimationBackend);
}
@Override
public int getIntrinsicWidth() {
if (mAnimationBackend == null) {
return super.getIntrinsicWidth();
}
return mAnimationBackend.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
if (mAnimationBackend == null) {
return super.getIntrinsicHeight();
}
return mAnimationBackend.getIntrinsicHeight();
}
/**
* Start the animation.
*/
@Override
public void start() {
if (mIsRunning || mAnimationBackend == null || mAnimationBackend.getFrameCount() <= 1) {
return;
}
mIsRunning = true;
mStartTimeMs = now();
mLastFrameAnimationTimeMs = -1;
invalidateSelf();
mAnimationListener.onAnimationStart(this);
}
/**
* Stop the animation at the current frame. It can be resumed by calling {@link #start()} again.
*/
@Override
public void stop() {
if (!mIsRunning) {
return;
}
mIsRunning = false;
mStartTimeMs = 0;
mLastFrameAnimationTimeMs = -1;
unscheduleSelf(mInvalidateRunnable);
mAnimationListener.onAnimationStop(this);
}
/**
* Check whether the animation is running.
*
* @return true if the animation is currently running
*/
@Override
public boolean isRunning() {
return mIsRunning;
}
@Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
if (mAnimationBackend != null) {
mAnimationBackend.setBounds(bounds);
}
}
@Override
public void draw(Canvas canvas) {
if (mAnimationBackend == null || mFrameScheduler == null) {
return;
}
long actualRenderTimeStartMs = now();
long animationTimeMs = mIsRunning
? actualRenderTimeStartMs - mStartTimeMs + mFrameSchedulingOffsetMs
: Math.max(mLastFrameAnimationTimeMs, 0);
// What frame should be drawn?
int frameNumberToDraw = mFrameScheduler.getFrameNumberToRender(
animationTimeMs,
mLastFrameAnimationTimeMs);
// Check if the animation is finished and draw last frame if so
if (frameNumberToDraw == FrameScheduler.FRAME_NUMBER_DONE) {
frameNumberToDraw = mAnimationBackend.getFrameCount() - 1;
mAnimationListener.onAnimationStop(this);
mIsRunning = false;
} else if (frameNumberToDraw == 0) {
mAnimationListener.onAnimationRepeat(this);
}
// Notify listeners that we're about to draw a new frame and
// that the animation might be repeated
mAnimationListener.onAnimationFrame(this, frameNumberToDraw);
// Draw the frame
boolean frameDrawn = mAnimationBackend.drawFrame(this, canvas, frameNumberToDraw);
// Log potential dropped frames
if (!frameDrawn) {
onFrameDropped();
}
long targetRenderTimeForNextFrameMs = FrameScheduler.NO_NEXT_TARGET_RENDER_TIME;
long scheduledRenderTimeForNextFrameMs = -1;
long actualRenderTimeEnd = now();
if (mIsRunning) {
// Schedule the next frame if needed.
targetRenderTimeForNextFrameMs =
mFrameScheduler.getTargetRenderTimeForNextFrameMs(actualRenderTimeEnd - mStartTimeMs);
if (targetRenderTimeForNextFrameMs != FrameScheduler.NO_NEXT_TARGET_RENDER_TIME) {
scheduledRenderTimeForNextFrameMs =
targetRenderTimeForNextFrameMs + mFrameSchedulingDelayMs;
scheduleNextFrame(scheduledRenderTimeForNextFrameMs);
}
}
if (mDrawListener != null) {
mDrawListener.onDraw(
this,
mFrameScheduler,
frameNumberToDraw,
frameDrawn,
mIsRunning,
mStartTimeMs,
animationTimeMs,
mLastFrameAnimationTimeMs,
actualRenderTimeStartMs,
actualRenderTimeEnd,
targetRenderTimeForNextFrameMs,
scheduledRenderTimeForNextFrameMs);
}
mLastFrameAnimationTimeMs = animationTimeMs;
}
@Override
public void setAlpha(int alpha) {
if (mDrawableProperties == null) {
mDrawableProperties = new DrawableProperties();
}
mDrawableProperties.setAlpha(alpha);
if (mAnimationBackend != null) {
mAnimationBackend.setAlpha(alpha);
}
}
@Override
public void setColorFilter(ColorFilter colorFilter) {
if (mDrawableProperties == null) {
mDrawableProperties = new DrawableProperties();
}
mDrawableProperties.setColorFilter(colorFilter);
if (mAnimationBackend != null) {
mAnimationBackend.setColorFilter(colorFilter);
}
}
@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}
/**
* Update the animation backend to be used for the animation.
* This will also stop the animation.
* In order to remove the current animation backend, call this method with null.
*
* @param animationBackend the animation backend to be used or null
*/
public void setAnimationBackend(@Nullable AnimationBackend animationBackend) {
mAnimationBackend = animationBackend;
if (mAnimationBackend != null) {
mFrameScheduler = new DropFramesFrameScheduler(mAnimationBackend);
mAnimationBackend.setBounds(getBounds());
if (mDrawableProperties != null) {
// re-apply to the same drawable so that the animation backend is updated.
mDrawableProperties.applyTo(this);
}
}
mFrameScheduler = createSchedulerForBackendAndDelayMethod(mAnimationBackend);
stop();
}
@Nullable
public AnimationBackend getAnimationBackend() {
return mAnimationBackend;
}
public long getDroppedFrames() {
return mDroppedFrames;
}
public long getStartTimeMs() {
return mStartTimeMs;
}
public boolean isInfiniteAnimation() {
return mFrameScheduler != null && mFrameScheduler.isInfiniteAnimation();
}
/**
* Jump immediately to the given frame number. The animation will not be paused if
* it is running. If the animation is not running, the animation will not be started.
*
* @param targetFrameNumber the frame number to jump to
*/
public void jumpToFrame(int targetFrameNumber) {
if (mAnimationBackend == null || mFrameScheduler == null) {
return;
}
// In order to jump to a given frame, we have to compute the correct start time
mLastFrameAnimationTimeMs = mFrameScheduler.getTargetRenderTimeMs(targetFrameNumber);
mStartTimeMs = now() - mLastFrameAnimationTimeMs;
invalidateSelf();
}
/**
* Get the animation duration for 1 loop by summing all frame durations.
*
* @return the duration of 1 animation loop in ms
*/
public long getLoopDurationMs() {
if (mAnimationBackend == null) {
return 0;
}
if (mFrameScheduler != null) {
return mFrameScheduler.getLoopDurationMs();
}
int loopDurationMs = 0;
for (int i = 0; i < mAnimationBackend.getFrameCount(); i++) {
loopDurationMs += mAnimationBackend.getFrameDurationMs(i);
}
return loopDurationMs;
}
/**
* Get the number of frames for the animation.
* If no animation backend is set, 0 will be returned.
*
* @return the number of frames of the animation
*/
public int getFrameCount() {
return mAnimationBackend == null ? 0 : mAnimationBackend.getFrameCount();
}
/**
* Get the loop count of the animation.
* The returned value is either {@link AnimationInformation#LOOP_COUNT_INFINITE} if the animation
* is repeated infinitely or a positive integer that corresponds to the number of loops.
* If no animation backend is set, {@link AnimationInformation#LOOP_COUNT_INFINITE}
* will be returned.
*
* @return the loop count of the animation or {@link AnimationInformation#LOOP_COUNT_INFINITE}
*/
public int getLoopCount() {
return mAnimationBackend == null
? 0
: mAnimationBackend.getLoopCount();
}
/**
* Frame scheduling delay to shift the target render time for a frame within the frame's
* visible window. If the value is set to 0, the frame will be scheduled right at the beginning
* of the frame's visible window.
*
* @param frameSchedulingDelayMs the delay to use in ms
*/
public void setFrameSchedulingDelayMs(long frameSchedulingDelayMs) {
mFrameSchedulingDelayMs = frameSchedulingDelayMs;
}
/**
* Frame scheduling offset to shift the animation time by the given offset.
* This is similar to {@link #mFrameSchedulingDelayMs} but instead of delaying the invalidation,
* this offsets the animation time by the given value.
*
* @param frameSchedulingOffsetMs the offset to use in ms
*/
public void setFrameSchedulingOffsetMs(long frameSchedulingOffsetMs) {
mFrameSchedulingOffsetMs = frameSchedulingOffsetMs;
}
/**
* Set an animation listener that is notified for various animation events.
*
* @param animationListener the listener to use
*/
public void setAnimationListener(@Nullable AnimationListener animationListener) {
mAnimationListener = animationListener != null
? animationListener
: NO_OP_LISTENER;
}
/**
* Set a draw listener that is notified for each {@link #draw(Canvas)} call.
*
* @param drawListener the listener to use
*/
public void setDrawListener(@Nullable DrawListener drawListener) {
mDrawListener = drawListener;
}
/**
* Schedule the next frame to be rendered after the given delay.
*
* @param targetAnimationTimeMs the time in ms to update the frame
*/
private void scheduleNextFrame(long targetAnimationTimeMs) {
scheduleSelf(mInvalidateRunnable, mStartTimeMs + targetAnimationTimeMs);
}
private void onFrameDropped() {
mDroppedFrames++;
// we need to drop frames
if (FLog.isLoggable(FLog.VERBOSE)) {
FLog.v(TAG, "Dropped a frame. Count: %s", mDroppedFrames);
}
}
/**
* @return the current uptime in ms
*/
private long now() {
// This call has to return {@link SystemClock#uptimeMillis()} in order to preserve correct
// frame scheduling.
return SystemClock.uptimeMillis();
}
@Nullable
private static FrameScheduler createSchedulerForBackendAndDelayMethod(
@Nullable AnimationBackend animationBackend) {
if (animationBackend == null) {
return null;
}
return new DropFramesFrameScheduler(animationBackend);
}
/**
* Set the animation to the given level. The level represents the animation time in ms.
* If the animation time is greater than the last frame time for the last loop, the last
* frame will be displayed.
*
* If the animation is running (e.g. if {@link #start()} has been called, the level change
* will be ignored. In this case, {@link #stop()} the animation first.
*
* @param level the animation time in ms
* @return true if the level change could be performed
*/
@Override
protected boolean onLevelChange(int level) {
if (mIsRunning) {
// If the client called start on us, they expect us to run the animation. In that case,
// we ignore level changes.
return false;
}
if (mLastFrameAnimationTimeMs != level) {
mLastFrameAnimationTimeMs = level;
invalidateSelf();
return true;
}
return false;
}
@Override
public void dropCaches() {
if (mAnimationBackend != null) {
mAnimationBackend.clear();
}
}
}