| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.bubbles.animation; |
| |
| import static org.junit.Assert.assertEquals; |
| import static org.junit.Assert.assertFalse; |
| import static org.junit.Assert.assertTrue; |
| import static org.mockito.ArgumentMatchers.any; |
| import static org.mockito.ArgumentMatchers.anyInt; |
| import static org.mockito.ArgumentMatchers.eq; |
| import static org.mockito.Mockito.inOrder; |
| import static org.mockito.Mockito.times; |
| import static org.mockito.Mockito.verify; |
| |
| import android.os.SystemClock; |
| import android.testing.AndroidTestingRunner; |
| import android.view.View; |
| import android.widget.FrameLayout; |
| |
| import androidx.dynamicanimation.animation.DynamicAnimation; |
| import androidx.dynamicanimation.animation.SpringForce; |
| import androidx.test.filters.SmallTest; |
| |
| import com.google.android.collect.Sets; |
| |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.mockito.InOrder; |
| import org.mockito.Mockito; |
| import org.mockito.Spy; |
| |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Set; |
| import java.util.concurrent.CountDownLatch; |
| import java.util.concurrent.TimeUnit; |
| |
| @SmallTest |
| @RunWith(AndroidTestingRunner.class) |
| /** Tests the PhysicsAnimationLayout itself, with a basic test animation controller. */ |
| public class PhysicsAnimationLayoutTest extends PhysicsAnimationLayoutTestCase { |
| static final float TEST_TRANSLATION_X_OFFSET = 15f; |
| |
| @Spy |
| private TestableAnimationController mTestableController = new TestableAnimationController(); |
| |
| @Before |
| public void setUp() throws Exception { |
| super.setUp(); |
| |
| // By default, use translation animations, chain the X animations with the default |
| // offset, and don't actually remove views immediately (since most implementations will wait |
| // to animate child views out before actually removing them). |
| mTestableController.setAnimatedProperties(Sets.newHashSet( |
| DynamicAnimation.TRANSLATION_X, |
| DynamicAnimation.TRANSLATION_Y)); |
| mTestableController.setChainedProperties(Sets.newHashSet(DynamicAnimation.TRANSLATION_X)); |
| mTestableController.setOffsetForProperty( |
| DynamicAnimation.TRANSLATION_X, TEST_TRANSLATION_X_OFFSET); |
| mTestableController.setRemoveImmediately(false); |
| } |
| |
| @Test |
| public void testHierarchyChanges() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| // Make sure the controller was notified of all the views we added. |
| for (View mView : mViews) { |
| Mockito.verify(mTestableController).onChildAdded(mView, 0); |
| } |
| |
| // Remove some views and ensure the controller was notified, with the proper indices. |
| mTestableController.setRemoveImmediately(true); |
| mLayout.removeView(mViews.get(1)); |
| mLayout.removeView(mViews.get(2)); |
| Mockito.verify(mTestableController).onChildRemoved( |
| eq(mViews.get(1)), eq(1), any()); |
| Mockito.verify(mTestableController).onChildRemoved( |
| eq(mViews.get(2)), eq(1), any()); |
| |
| // Make sure we still get view added notifications after doing some removals. |
| final View newBubble = new FrameLayout(mContext); |
| mLayout.addView(newBubble, 0); |
| Mockito.verify(mTestableController).onChildAdded(newBubble, 0); |
| } |
| |
| @Test |
| public void testUpdateValueNotChained() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| // Don't chain any values. |
| mTestableController.setChainedProperties(Sets.newHashSet()); |
| |
| // Child views should not be translated. |
| assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f); |
| assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f); |
| |
| // Animate the first child's translation X. |
| final CountDownLatch animLatch = new CountDownLatch(1); |
| |
| mTestableController |
| .animationForChildAtIndex(0) |
| .translationX(100) |
| .start(animLatch::countDown); |
| animLatch.await(1, TimeUnit.SECONDS); |
| |
| // Ensure that the first view has been translated, but not the second one. |
| assertEquals(100, mLayout.getChildAt(0).getTranslationX(), .1f); |
| assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f); |
| } |
| |
| @Test |
| public void testUpdateValueXChained() throws InterruptedException { |
| testChainedTranslationAnimations(); |
| } |
| |
| @Test |
| public void testSetEndActions() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| mTestableController.setChainedProperties(Sets.newHashSet()); |
| |
| final CountDownLatch xLatch = new CountDownLatch(1); |
| Runnable xEndAction = Mockito.spy(new Runnable() { |
| |
| @Override |
| public void run() { |
| xLatch.countDown(); |
| } |
| }); |
| |
| final CountDownLatch yLatch = new CountDownLatch(1); |
| Runnable yEndAction = Mockito.spy(new Runnable() { |
| |
| @Override |
| public void run() { |
| yLatch.countDown(); |
| } |
| }); |
| |
| // Set end listeners for both x and y. |
| mTestableController.setEndActionForProperty(xEndAction, DynamicAnimation.TRANSLATION_X); |
| mTestableController.setEndActionForProperty(yEndAction, DynamicAnimation.TRANSLATION_Y); |
| |
| // Animate x, and wait for it to finish. |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(100) |
| .start(); |
| |
| xLatch.await(); |
| yLatch.await(1, TimeUnit.SECONDS); |
| |
| // Make sure the x end listener was called only one time, and the y listener was never |
| // called since we didn't animate y. Wait 1 second after the original animation end trigger |
| // to make sure it doesn't get called again. |
| Mockito.verify(xEndAction, Mockito.after(1000).times(1)).run(); |
| Mockito.verify(yEndAction, Mockito.after(1000).never()).run(); |
| } |
| |
| @Test |
| public void testRemoveEndListeners() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| mTestableController.setChainedProperties(Sets.newHashSet()); |
| |
| final CountDownLatch xLatch = new CountDownLatch(1); |
| Runnable xEndListener = Mockito.spy(new Runnable() { |
| |
| @Override |
| public void run() { |
| xLatch.countDown(); |
| } |
| }); |
| |
| // Set the end listener. |
| mTestableController.setEndActionForProperty(xEndListener, DynamicAnimation.TRANSLATION_X); |
| |
| // Animate x, and wait for it to finish. |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(100) |
| .start(); |
| xLatch.await(); |
| |
| InOrder endListenerCalls = inOrder(xEndListener); |
| endListenerCalls.verify(xEndListener, Mockito.times(1)).run(); |
| |
| // Animate X again, remove the end listener. |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(1000) |
| .start(); |
| mTestableController.removeEndActionForProperty(DynamicAnimation.TRANSLATION_X); |
| xLatch.await(1, TimeUnit.SECONDS); |
| |
| // Make sure the end listener was not called. |
| endListenerCalls.verifyNoMoreInteractions(); |
| } |
| |
| @Test |
| public void testSetController() throws InterruptedException { |
| // Add the bubbles, then set the controller, to make sure that a controller added to an |
| // already-initialized view works correctly. |
| addOneMoreThanBubbleLimitBubbles(); |
| mLayout.setActiveController(mTestableController); |
| testChainedTranslationAnimations(); |
| |
| TestableAnimationController secondController = |
| Mockito.spy(new TestableAnimationController()); |
| secondController.setAnimatedProperties(Sets.newHashSet( |
| DynamicAnimation.SCALE_X, DynamicAnimation.SCALE_Y)); |
| secondController.setChainedProperties(Sets.newHashSet( |
| DynamicAnimation.SCALE_X)); |
| secondController.setOffsetForProperty( |
| DynamicAnimation.SCALE_X, 10f); |
| secondController.setRemoveImmediately(true); |
| |
| mLayout.setActiveController(secondController); |
| mTestableController.animationForChildAtIndex(0) |
| .scaleX(1.5f) |
| .start(); |
| |
| waitForPropertyAnimations(DynamicAnimation.SCALE_X); |
| |
| // Make sure we never asked the original controller about any SCALE animations, that would |
| // mean the controller wasn't switched over properly. |
| Mockito.verify(mTestableController, Mockito.never()) |
| .getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt()); |
| Mockito.verify(mTestableController, Mockito.never()) |
| .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X)); |
| |
| // Make sure we asked the new controller about its animated properties, and configuration |
| // options. |
| Mockito.verify(secondController, Mockito.atLeastOnce()) |
| .getAnimatedProperties(); |
| Mockito.verify(secondController, Mockito.atLeastOnce()) |
| .getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt()); |
| Mockito.verify(secondController, Mockito.atLeastOnce()) |
| .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X)); |
| |
| mLayout.setActiveController(mTestableController); |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(100f) |
| .start(); |
| |
| waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X); |
| |
| // Make sure we never asked the second controller about the TRANSLATION_X animation. |
| Mockito.verify(secondController, Mockito.never()) |
| .getNextAnimationInChain(eq(DynamicAnimation.TRANSLATION_X), anyInt()); |
| Mockito.verify(secondController, Mockito.never()) |
| .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.TRANSLATION_X)); |
| |
| } |
| |
| @Test |
| public void testArePropertiesAnimating() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| assertFalse(mLayout.arePropertiesAnimating( |
| DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)); |
| |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(100f) |
| .start(); |
| |
| // Wait for the animations to get underway. |
| SystemClock.sleep(50); |
| |
| assertTrue(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_X)); |
| assertFalse(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_Y)); |
| |
| waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X); |
| |
| assertFalse(mLayout.arePropertiesAnimating( |
| DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)); |
| } |
| |
| @Test |
| public void testCancelAllAnimations() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| mTestableController.animationForChildAtIndex(0) |
| .position(1000, 1000) |
| .start(); |
| |
| mLayout.cancelAllAnimations(); |
| |
| // Animations should be somewhere before their end point. |
| assertTrue(mViews.get(0).getTranslationX() < 1000); |
| assertTrue(mViews.get(0).getTranslationY() < 1000); |
| } |
| |
| /** Standard test of chained translation animations. */ |
| private void testChainedTranslationAnimations() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f); |
| assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f); |
| |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(100f) |
| .start(); |
| |
| waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X); |
| |
| for (int i = 0; i < mLayout.getChildCount(); i++) { |
| assertEquals( |
| 100 + i * TEST_TRANSLATION_X_OFFSET, |
| mLayout.getChildAt(i).getTranslationX(), .1f); |
| } |
| |
| // Ensure that the Y translations were unaffected. |
| assertEquals(0, mLayout.getChildAt(0).getTranslationY(), .1f); |
| assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f); |
| |
| // Animate the first child's Y translation. |
| mTestableController.animationForChildAtIndex(0) |
| .translationY(100f) |
| .start(); |
| |
| waitForPropertyAnimations(DynamicAnimation.TRANSLATION_Y); |
| |
| // Ensure that only the first view's Y translation chained, since we only chained X |
| // translations. |
| assertEquals(100, mLayout.getChildAt(0).getTranslationY(), .1f); |
| assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f); |
| } |
| |
| @Test |
| public void testPhysicsAnimator() throws InterruptedException { |
| mLayout.setActiveController(mTestableController); |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| Runnable afterAll = Mockito.mock(Runnable.class); |
| Runnable after = Mockito.spy(new Runnable() { |
| int mCallCount = 0; |
| |
| @Override |
| public void run() { |
| // Make sure that if only one of the animations has finished, we didn't already call |
| // afterAll. |
| if (mCallCount == 1) { |
| Mockito.verifyNoMoreInteractions(afterAll); |
| } |
| } |
| }); |
| |
| // Animate from x = 7 to x = 100, and from y = 100 to 7 = 200, calling 'after' after each |
| // property's animation completes, then call afterAll when they're all complete. |
| mTestableController.animationForChildAtIndex(0) |
| .translationX(7, 100, after) |
| .translationY(100, 200, after) |
| .start(afterAll); |
| |
| // We should have immediately set the 'from' values. |
| assertEquals(7, mViews.get(0).getTranslationX(), .01f); |
| assertEquals(100, mViews.get(0).getTranslationY(), .01f); |
| |
| waitForPropertyAnimations( |
| DynamicAnimation.TRANSLATION_X, |
| DynamicAnimation.TRANSLATION_Y); |
| |
| // We should have called the after callback twice, and afterAll once. We verify in the |
| // mocked callback that afterAll isn't called before both finish. |
| Mockito.verify(after, times(2)).run(); |
| Mockito.verify(afterAll).run(); |
| |
| // Make sure we actually animated the views. |
| assertEquals(100, mViews.get(0).getTranslationX(), .01f); |
| assertEquals(200, mViews.get(0).getTranslationY(), .01f); |
| } |
| |
| @Test |
| public void testAnimationsForChildrenFromIndex() throws InterruptedException { |
| // Don't chain since we're going to invoke each animation independently. |
| mTestableController.setChainedProperties(new HashSet<>()); |
| |
| mLayout.setActiveController(mTestableController); |
| |
| addOneMoreThanBubbleLimitBubbles(); |
| |
| Runnable allEnd = Mockito.mock(Runnable.class); |
| |
| mTestableController.animationsForChildrenFromIndex( |
| 1, (index, animation) -> animation.translationX((index - 1) * 50)) |
| .startAll(allEnd); |
| |
| waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X); |
| |
| assertEquals(0, mViews.get(0).getTranslationX(), .1f); |
| assertEquals(0, mViews.get(1).getTranslationX(), .1f); |
| assertEquals(50, mViews.get(2).getTranslationX(), .1f); |
| assertEquals(100, mViews.get(3).getTranslationX(), .1f); |
| |
| Mockito.verify(allEnd, times(1)).run(); |
| } |
| |
| @Test |
| public void testAnimationsForChildrenFromIndex_noChildren() { |
| mLayout.setActiveController(mTestableController); |
| |
| final Runnable after = Mockito.mock(Runnable.class); |
| mTestableController |
| .animationsForChildrenFromIndex(0, (index, animation) -> { }) |
| .startAll(after); |
| |
| verify(after, Mockito.times(1)).run(); |
| } |
| |
| /** |
| * Animation controller with configuration methods whose return values can be set by individual |
| * tests. |
| */ |
| private class TestableAnimationController |
| extends PhysicsAnimationLayout.PhysicsAnimationController { |
| private Set<DynamicAnimation.ViewProperty> mAnimatedProperties = new HashSet<>(); |
| private Set<DynamicAnimation.ViewProperty> mChainedProperties = new HashSet<>(); |
| private HashMap<DynamicAnimation.ViewProperty, Float> mOffsetForProperty = new HashMap<>(); |
| private boolean mRemoveImmediately = false; |
| |
| void setAnimatedProperties( |
| Set<DynamicAnimation.ViewProperty> animatedProperties) { |
| mAnimatedProperties = animatedProperties; |
| } |
| |
| void setChainedProperties( |
| Set<DynamicAnimation.ViewProperty> chainedProperties) { |
| mChainedProperties = chainedProperties; |
| } |
| |
| void setOffsetForProperty( |
| DynamicAnimation.ViewProperty property, float offset) { |
| mOffsetForProperty.put(property, offset); |
| } |
| |
| public void setRemoveImmediately(boolean removeImmediately) { |
| mRemoveImmediately = removeImmediately; |
| } |
| |
| @Override |
| Set<DynamicAnimation.ViewProperty> getAnimatedProperties() { |
| return mAnimatedProperties; |
| } |
| |
| @Override |
| int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) { |
| return mChainedProperties.contains(property) ? index + 1 : NONE; |
| } |
| |
| @Override |
| float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) { |
| return mOffsetForProperty.getOrDefault(property, 0f); |
| } |
| |
| @Override |
| SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) { |
| return new SpringForce(); |
| } |
| |
| @Override |
| void onChildAdded(View child, int index) {} |
| |
| @Override |
| void onChildRemoved(View child, int index, Runnable finishRemoval) { |
| if (mRemoveImmediately) { |
| finishRemoval.run(); |
| } |
| } |
| |
| @Override |
| void onChildReordered(View child, int oldIndex, int newIndex) {} |
| |
| @Override |
| void onActiveControllerForLayout(PhysicsAnimationLayout layout) {} |
| } |
| } |