Skip to content

Commit

Permalink
Improve animation runner interaction with view
Browse files Browse the repository at this point in the history
In real `View` implementation, during drawing, the animation will be initialized on the first frame and cleared when it has finished (if it does not fill). Without these interactions if `draw` is actually called on the view then the animation is put into an inconsistent state. To support existing use cases `getAnimations` and `clearAnimations` methods are added to the shadow to allow test authors to inspect the animations that are applied to a view.

Note that the real implementation would also call `onAnimationStart`/`onAnimationEnd` on the view too, but in kitkat and below this interacts with the drawing cache in a way that is not well supported by Robolectric so continue to avoid doing this.

PiperOrigin-RevId: 444084414
  • Loading branch information
paulsowden authored and Copybara-Service committed Apr 29, 2022
1 parent ea68d95 commit 08f0b76
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 29 deletions.
Expand Up @@ -497,14 +497,17 @@ public void test_nextFocusDownId() throws Exception {
@Test
public void startAnimation() {
AlphaAnimation animation = new AlphaAnimation(0, 1);

Animation.AnimationListener listener = mock(Animation.AnimationListener.class);
animation.setAnimationListener(listener);

view.startAnimation(animation);
shadowMainLooper().idle();

verify(listener).onAnimationStart(animation);
verify(listener).onAnimationEnd(animation);
assertThat(animation.isInitialized()).isTrue();
assertThat(view.getAnimation()).isNull();
assertThat(shadowOf(view).getAnimations()).contains(animation);
}

@Test
Expand Down
Expand Up @@ -36,8 +36,10 @@
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.Transformation;
import com.google.common.collect.ImmutableList;
import java.io.PrintStream;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -85,6 +87,7 @@ public class ShadowView {
private View.OnCreateContextMenuListener onCreateContextMenuListener;
private Rect globalVisibleRect;
private int layerType;
private final ArrayList<Animation> animations = new ArrayList<>();
private AnimationRunner animationRunner;

/**
Expand Down Expand Up @@ -641,12 +644,27 @@ protected int getLayerType() {
return this.layerType;
}

/** Returns a list of all animations that have been set on this view. */
public ImmutableList<Animation> getAnimations() {
return ImmutableList.copyOf(animations);
}

/** Resets the list returned by {@link #getAnimations()} to an empty list. */
public void clearAnimations() {
animations.clear();
}

@Implementation
protected void setAnimation(final Animation animation) {
reflector(_View_.class, realView).setAnimation(animation);

if (animation != null) {
new AnimationRunner(animation);
animations.add(animation);
if (animationRunner != null) {
animationRunner.cancel();
}
animationRunner = new AnimationRunner(animation);
animationRunner.start();
}
}

Expand All @@ -673,61 +691,75 @@ protected boolean initialAwakenScrollBars() {

private class AnimationRunner implements Runnable {
private final Animation animation;
private long startTime, startOffset, elapsedTime;
private final Transformation transformation = new Transformation();
private long startTime;
private long elapsedTime;
private boolean canceled;

AnimationRunner(Animation animation) {
this.animation = animation;
start();
}

private void start() {
startTime = animation.getStartTime();
startOffset = animation.getStartOffset();
Choreographer choreographer = Choreographer.getInstance();
if (animationRunner != null) {
choreographer.removeCallbacks(Choreographer.CALLBACK_ANIMATION, animationRunner, null);
long startOffset = animation.getStartOffset();
long startDelay =
startTime == Animation.START_ON_FIRST_FRAME
? startOffset
: (startTime + startOffset) - SystemClock.uptimeMillis();
Choreographer.getInstance()
.postCallbackDelayed(Choreographer.CALLBACK_ANIMATION, this, null, startDelay);
}

private boolean step() {
long animationTime =
animation.getStartTime() == Animation.START_ON_FIRST_FRAME
? SystemClock.uptimeMillis()
: (animation.getStartTime() + animation.getStartOffset() + elapsedTime);
// Note in real android the parent is non-nullable, retain legacy robolectric behavior which
// allows detached views to animate.
if (!animation.isInitialized() && realView.getParent() != null) {
View parent = (View) realView.getParent();
animation.initialize(
realView.getWidth(), realView.getHeight(), parent.getWidth(), parent.getHeight());
}
animationRunner = this;
int startDelay;
if (startTime == Animation.START_ON_FIRST_FRAME) {
startDelay = (int) startOffset;
} else {
startDelay = (int) ((startTime + startOffset) - SystemClock.uptimeMillis());
boolean next = animation.getTransformation(animationTime, transformation);
// Note in real view implementation it doesn't check the animation equality before clearing,
// but in the real implementation the animation listeners are posted so it doesn't race with
// chained animations.
if (realView.getAnimation() == animation && !next) {
if (!animation.getFillAfter()) {
realView.clearAnimation();
}
}
choreographer.postCallbackDelayed(Choreographer.CALLBACK_ANIMATION, this, null, startDelay);
// We can't handle infinitely repeating animations in the current scheduling model, so abort
// after one iteration.
return next
&& (animation.getRepeatCount() != Animation.INFINITE
|| elapsedTime < animation.getDuration());
}

@Override
public void run() {
// Abort if start time has been messed with, as this simulation is only designed to handle
// standard situations.
if (!canceled
&& (animation.getStartTime() == startTime && animation.getStartOffset() == startOffset)
&& animation.getTransformation(
startTime == Animation.START_ON_FIRST_FRAME
? SystemClock.uptimeMillis()
: (startTime + startOffset + elapsedTime),
new Transformation())
&&
// We can't handle infinitely repeating animations in the current scheduling model,
// so abort after one iteration.
!(animation.getRepeatCount() == Animation.INFINITE
&& elapsedTime >= animation.getDuration())) {
// Update startTime if it had a value of Animation.START_ON_FIRST_FRAME
if (!canceled && animation.getStartTime() == startTime && step()) {
// Start time updates for repeating animations and if START_ON_FIRST_FRAME.
startTime = animation.getStartTime();
elapsedTime +=
ShadowLooper.looperMode().equals(LooperMode.Mode.LEGACY)
? ShadowChoreographer.getFrameInterval() / TimeUtils.NANOS_PER_MS
: ShadowChoreographer.getFrameDelay().toMillis();
Choreographer.getInstance().postCallback(Choreographer.CALLBACK_ANIMATION, this, null);
} else {
} else if (animationRunner == this) {
animationRunner = null;
}
}

public void cancel() {
this.canceled = true;
Choreographer.getInstance()
.removeCallbacks(Choreographer.CALLBACK_ANIMATION, animationRunner, null);
}
}

Expand Down

0 comments on commit 08f0b76

Please sign in to comment.