Skip to content

Commit 299bfa5

Browse files
Material Design Teamdrchen
authored andcommitted
[Animation] Introduce AnimationCoordinator
The AnimationCoordinator manages time-based and physics-based animations, allowing them to be started together, and providing callbacks for start and end events. PiperOrigin-RevId: 878557096
1 parent f1aff91 commit 299bfa5

2 files changed

Lines changed: 346 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.android.material.animation;
17+
18+
import android.animation.Animator;
19+
import android.animation.AnimatorListenerAdapter;
20+
import android.animation.AnimatorSet;
21+
import androidx.annotation.NonNull;
22+
import androidx.annotation.RestrictTo;
23+
import androidx.annotation.RestrictTo.Scope;
24+
import androidx.dynamicanimation.animation.DynamicAnimation;
25+
import androidx.dynamicanimation.animation.SpringAnimation;
26+
import java.util.ArrayList;
27+
import java.util.List;
28+
29+
/**
30+
* Manages multiple animations, including time-based animations, like {@link Animator} and {@link
31+
* AnimatorSet}, and physics-based animations, like {@link SpringAnimation}, allowing them to be
32+
* started together and providing callbacks for start and end events.
33+
*
34+
* @hide
35+
*/
36+
@RestrictTo(Scope.LIBRARY_GROUP)
37+
public class AnimationCoordinator {
38+
39+
/** Listener for animation coordinator events. */
40+
public interface Listener {
41+
/** Called before any animation starts. */
42+
void onAnimationsStart();
43+
44+
/** Called after all animations have finished. */
45+
void onAnimationsEnd();
46+
}
47+
48+
private final List<Animator> durationAnimations = new ArrayList<>();
49+
private final List<DynamicAnimation<?>> dynamicAnimations = new ArrayList<>();
50+
private final List<Listener> listeners = new ArrayList<>();
51+
52+
private int animationsRunning = 0;
53+
private boolean started = false;
54+
55+
public AnimationCoordinator() {}
56+
57+
/** Adds an {@link Animator} or {@link AnimatorSet} to be managed by this coordinator. */
58+
public void addAnimator(@NonNull Animator animator) {
59+
durationAnimations.add(animator);
60+
}
61+
62+
/** Adds a {@link DynamicAnimation} to be managed by this coordinator. */
63+
public void addDynamicAnimation(@NonNull DynamicAnimation<?> dynamicAnimation) {
64+
dynamicAnimations.add(dynamicAnimation);
65+
}
66+
67+
/** Adds a listener to receive animation start and end events. */
68+
public void addListener(@NonNull Listener listener) {
69+
listeners.add(listener);
70+
}
71+
72+
/** Removes a listener. */
73+
public void removeListener(@NonNull Listener listener) {
74+
listeners.remove(listener);
75+
}
76+
77+
/** Clears all animations and listeners. */
78+
public void clear() {
79+
List<Animator> animatorsToEnd = new ArrayList<>(durationAnimations);
80+
durationAnimations.clear();
81+
for (Animator animator : animatorsToEnd) {
82+
animator.end();
83+
}
84+
85+
List<DynamicAnimation<?>> dynamicAnimsToClear = new ArrayList<>(dynamicAnimations);
86+
dynamicAnimations.clear();
87+
for (DynamicAnimation<?> dynamicAnimation : dynamicAnimsToClear) {
88+
if (dynamicAnimation instanceof SpringAnimation) {
89+
SpringAnimation springAnimation = (SpringAnimation) dynamicAnimation;
90+
if (springAnimation.canSkipToEnd()) {
91+
springAnimation.skipToEnd();
92+
} else {
93+
springAnimation.cancel();
94+
}
95+
} else {
96+
dynamicAnimation.cancel();
97+
}
98+
}
99+
100+
listeners.clear();
101+
animationsRunning = 0;
102+
started = false;
103+
}
104+
105+
/**
106+
* Starts all managed animations simultaneously. If animations are already running, this method
107+
* does nothing.
108+
*/
109+
public void start() {
110+
if (started) {
111+
return;
112+
}
113+
started = true;
114+
115+
for (Listener listener : listeners) {
116+
listener.onAnimationsStart();
117+
}
118+
119+
animationsRunning = dynamicAnimations.size();
120+
if (!durationAnimations.isEmpty()) {
121+
animationsRunning++;
122+
}
123+
124+
if (animationsRunning == 0) {
125+
notifyAnimationsEnd();
126+
return;
127+
}
128+
129+
DynamicAnimation.OnAnimationEndListener dynamicListener =
130+
new DynamicAnimation.OnAnimationEndListener() {
131+
@SuppressWarnings("rawtypes") // interface is defined using raw type
132+
@Override
133+
public void onAnimationEnd(
134+
DynamicAnimation animation, boolean canceled, float value, float velocity) {
135+
animation.removeEndListener(this);
136+
onAnimationFinished();
137+
}
138+
};
139+
for (DynamicAnimation<?> dynamicAnimation : dynamicAnimations) {
140+
dynamicAnimation.addEndListener(dynamicListener);
141+
dynamicAnimation.start();
142+
}
143+
144+
if (!durationAnimations.isEmpty()) {
145+
AnimatorSet animatorSet = new AnimatorSet();
146+
AnimatorSetCompat.playTogether(animatorSet, new ArrayList<>(durationAnimations));
147+
animatorSet.addListener(
148+
new AnimatorListenerAdapter() {
149+
@Override
150+
public void onAnimationEnd(Animator animation) {
151+
onAnimationFinished();
152+
}
153+
});
154+
animatorSet.start();
155+
}
156+
}
157+
158+
private void onAnimationFinished() {
159+
animationsRunning--;
160+
if (animationsRunning == 0) {
161+
notifyAnimationsEnd();
162+
}
163+
}
164+
165+
private void notifyAnimationsEnd() {
166+
for (Listener listener : listeners) {
167+
listener.onAnimationsEnd();
168+
}
169+
started = false;
170+
}
171+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.android.material.animation;
17+
18+
import static com.google.common.truth.Truth.assertThat;
19+
import static org.mockito.Mockito.mock;
20+
import static org.mockito.Mockito.never;
21+
import static org.mockito.Mockito.verify;
22+
23+
import android.animation.ValueAnimator;
24+
import android.content.Context;
25+
import android.view.View;
26+
import androidx.dynamicanimation.animation.SpringAnimation;
27+
import androidx.dynamicanimation.animation.SpringForce;
28+
import androidx.test.core.app.ApplicationProvider;
29+
import org.junit.Before;
30+
import org.junit.Test;
31+
import org.junit.runner.RunWith;
32+
import org.robolectric.RobolectricTestRunner;
33+
import org.robolectric.shadows.ShadowLooper;
34+
35+
@RunWith(RobolectricTestRunner.class)
36+
public class AnimationCoordinatorTest {
37+
38+
private Context context;
39+
private AnimationCoordinator coordinator;
40+
private AnimationCoordinator.Listener mockListener;
41+
42+
@Before
43+
public void setUp() {
44+
context = ApplicationProvider.getApplicationContext();
45+
coordinator = new AnimationCoordinator();
46+
mockListener = mock(AnimationCoordinator.Listener.class);
47+
coordinator.addListener(mockListener);
48+
}
49+
50+
@Test
51+
public void start_noAnimations_callsListenersImmediately() {
52+
coordinator.start();
53+
ShadowLooper.idleMainLooper();
54+
55+
verify(mockListener).onAnimationsStart();
56+
verify(mockListener).onAnimationsEnd();
57+
}
58+
59+
@Test
60+
public void start_onlyDurationAnimations_callsListeners() {
61+
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
62+
animator.setDuration(100);
63+
coordinator.addAnimator(animator);
64+
65+
coordinator.start();
66+
ShadowLooper.idleMainLooper();
67+
68+
verify(mockListener).onAnimationsStart();
69+
verify(mockListener).onAnimationsEnd();
70+
}
71+
72+
@Test
73+
public void start_onlySpringAnimations_callsListeners() {
74+
View testView = new View(context);
75+
SpringAnimation springAnimation =
76+
new SpringAnimation(testView, SpringAnimation.ALPHA)
77+
.setSpring(new SpringForce(1f).setStiffness(0.01f).setDampingRatio(1f));
78+
coordinator.addDynamicAnimation(springAnimation);
79+
80+
coordinator.start();
81+
ShadowLooper.idleMainLooper();
82+
83+
verify(mockListener).onAnimationsStart();
84+
verify(mockListener).onAnimationsEnd();
85+
}
86+
87+
@Test
88+
public void start_mixedAnimations_callsListeners() {
89+
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
90+
animator.setDuration(100);
91+
coordinator.addAnimator(animator);
92+
93+
View testView = new View(context);
94+
SpringAnimation springAnimation =
95+
new SpringAnimation(testView, SpringAnimation.ALPHA)
96+
.setSpring(new SpringForce(1f).setStiffness(0.01f).setDampingRatio(1f));
97+
coordinator.addDynamicAnimation(springAnimation);
98+
99+
coordinator.start();
100+
ShadowLooper.idleMainLooper();
101+
102+
verify(mockListener).onAnimationsStart();
103+
verify(mockListener).onAnimationsEnd();
104+
}
105+
106+
@Test
107+
public void start_calledTwice_onlyRunsOnce() {
108+
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
109+
animator.setDuration(100);
110+
coordinator.addAnimator(animator);
111+
112+
coordinator.start();
113+
coordinator.start(); // Second call should be ignored
114+
ShadowLooper.idleMainLooper();
115+
116+
verify(mockListener).onAnimationsStart();
117+
verify(mockListener).onAnimationsEnd();
118+
}
119+
120+
@Test
121+
public void removeListener_preventsCallbacks() {
122+
coordinator.removeListener(mockListener);
123+
coordinator.start();
124+
ShadowLooper.idleMainLooper();
125+
126+
verify(mockListener, never()).onAnimationsStart();
127+
verify(mockListener, never()).onAnimationsEnd();
128+
}
129+
130+
@Test
131+
public void clear_removesAnimationsAndListeners() {
132+
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
133+
animator.setDuration(100);
134+
coordinator.addAnimator(animator);
135+
136+
coordinator.clear();
137+
coordinator.start();
138+
ShadowLooper.idleMainLooper();
139+
140+
verify(mockListener, never()).onAnimationsStart();
141+
verify(mockListener, never()).onAnimationsEnd();
142+
}
143+
144+
@Test
145+
public void clear_whileRunning_cancelsAndJumpsToEnd() {
146+
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
147+
animator.setDuration(100);
148+
coordinator.addAnimator(animator);
149+
150+
View testView = new View(context);
151+
SpringAnimation springAnimation =
152+
new SpringAnimation(testView, SpringAnimation.ALPHA)
153+
.setSpring(new SpringForce(1f).setStiffness(0.01f).setDampingRatio(1f));
154+
coordinator.addDynamicAnimation(springAnimation);
155+
156+
coordinator.start();
157+
158+
// Verify animations are running
159+
assertThat(animator.isRunning()).isTrue();
160+
assertThat(springAnimation.isRunning()).isTrue();
161+
162+
coordinator.clear();
163+
ShadowLooper.idleMainLooper();
164+
165+
// Verify animations are stopped and jumped to end
166+
assertThat(animator.isRunning()).isFalse();
167+
assertThat((float) animator.getAnimatedValue()).isEqualTo(1f);
168+
assertThat(springAnimation.isRunning()).isFalse();
169+
170+
// Verify listeners are not called after clear
171+
ShadowLooper.idleMainLooper();
172+
verify(mockListener).onAnimationsStart();
173+
verify(mockListener, never()).onAnimationsEnd();
174+
}
175+
}

0 commit comments

Comments
 (0)