diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java index 1e14763e57d54..e2a9d5456a8ed 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java @@ -25,6 +25,9 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; import android.view.View; +import android.view.animation.Animation; +import android.view.animation.OvershootInterpolator; +import android.view.animation.TranslateAnimation; import androidx.dynamicanimation.animation.DynamicAnimation; import androidx.dynamicanimation.animation.FlingAnimation; @@ -33,6 +36,7 @@ import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import androidx.recyclerview.widget.RecyclerView; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.Preconditions; import java.util.HashMap; @@ -55,7 +59,11 @@ class MenuAnimationController { private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f; private static final float SPRING_STIFFNESS = 700f; private static final float ESCAPE_VELOCITY = 750f; + // Make tucked animation by using translation X relative to the view itself. + private static final float ANIMATION_TO_X_VALUE = 0.5f; + private static final int ANIMATION_START_OFFSET_MS = 600; + private static final int ANIMATION_DURATION_MS = 600; private static final int FADE_OUT_DURATION_MS = 1000; private static final int FADE_EFFECT_DURATION_MS = 3000; @@ -64,10 +72,12 @@ class MenuAnimationController { private final Handler mHandler; private boolean mIsFadeEffectEnabled; private DismissAnimationController.DismissCallback mDismissCallback; + private Runnable mSpringAnimationsEndAction; // Cache the animations state of {@link DynamicAnimation.TRANSLATION_X} and {@link // DynamicAnimation.TRANSLATION_Y} to be well controlled by the touch handler - private final HashMap mPositionAnimations = + @VisibleForTesting + final HashMap mPositionAnimations = new HashMap<>(); MenuAnimationController(MenuView menuView) { @@ -102,6 +112,13 @@ class MenuAnimationController { } } + /** + * Sets the action to be called when the all dynamic animations are completed. + */ + void setSpringAnimationsEndAction(Runnable runnable) { + mSpringAnimationsEndAction = runnable; + } + void setDismissCallback( DismissAnimationController.DismissCallback dismissCallback) { mDismissCallback = dismissCallback; @@ -192,7 +209,7 @@ class MenuAnimationController { ? bounds.right : bounds.bottom; - final FlingAnimation flingAnimation = new FlingAnimation(mMenuView, menuPositionProperty); + final FlingAnimation flingAnimation = createFlingAnimation(mMenuView, menuPositionProperty); flingAnimation.setFriction(friction) .setStartVelocity(velocity) .setMinValue(Math.min(currentValue, min)) @@ -217,7 +234,14 @@ class MenuAnimationController { flingAnimation.start(); } - private void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, + @VisibleForTesting + FlingAnimation createFlingAnimation(MenuView menuView, + MenuPositionProperty menuPositionProperty) { + return new FlingAnimation(menuView, menuPositionProperty); + } + + @VisibleForTesting + void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, float velocity, float finalPosition) { final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); final SpringAnimation springAnimation = @@ -228,8 +252,13 @@ class MenuAnimationController { return; } - onSpringAnimationEnd(new PointF(mMenuView.getTranslationX(), - mMenuView.getTranslationY())); + final boolean areAnimationsRunning = + mPositionAnimations.values().stream().anyMatch( + DynamicAnimation::isRunning); + if (!areAnimationsRunning) { + onSpringAnimationsEnd(new PointF(mMenuView.getTranslationX(), + mMenuView.getTranslationY())); + } }) .setStartVelocity(velocity); @@ -332,11 +361,15 @@ class MenuAnimationController { .start(); } - private void onSpringAnimationEnd(PointF position) { + private void onSpringAnimationsEnd(PointF position) { mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); constrainPositionAndUpdate(position); fadeOutIfEnabled(); + + if (mSpringAnimationsEndAction != null) { + mSpringAnimationsEndAction.run(); + } } private void constrainPositionAndUpdate(PointF position) { @@ -387,6 +420,26 @@ class MenuAnimationController { mHandler.removeCallbacksAndMessages(/* token= */ null); } + void startTuckedAnimationPreview() { + fadeInNowIfEnabled(); + + final float toXValue = isOnLeftSide() + ? -ANIMATION_TO_X_VALUE + : ANIMATION_TO_X_VALUE; + final TranslateAnimation animation = + new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, toXValue, + Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0); + animation.setDuration(ANIMATION_DURATION_MS); + animation.setRepeatMode(Animation.REVERSE); + animation.setInterpolator(new OvershootInterpolator()); + animation.setRepeatCount(Animation.INFINITE); + animation.setStartOffset(ANIMATION_START_OFFSET_MS); + + mMenuView.startAnimation(animation); + } + private Handler createUiHandler() { return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null")); } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java index 60518b735622a..05e1d3f0e126b 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java @@ -56,6 +56,7 @@ class MenuInfoRepository { @FloatRange(from = 0.0, to = 1.0) private static final float DEFAULT_MENU_POSITION_Y_PERCENT = 0.77f; private static final boolean DEFAULT_MOVE_TO_TUCKED_VALUE = false; + private static final boolean DEFAULT_HAS_SEEN_DOCK_TOOLTIP_VALUE = false; private static final int DEFAULT_MIGRATION_TOOLTIP_VALUE_PROMPT = MigrationPrompt.DISABLED; private final Context mContext; @@ -114,6 +115,12 @@ class MenuInfoRepository { DEFAULT_MOVE_TO_TUCKED_VALUE)); } + void loadDockTooltipVisibility(OnInfoReady callback) { + callback.onReady(Prefs.getBoolean(mContext, + Prefs.Key.HAS_SEEN_ACCESSIBILITY_FLOATING_MENU_DOCK_TOOLTIP, + DEFAULT_HAS_SEEN_DOCK_TOOLTIP_VALUE)); + } + void loadMigrationTooltipVisibility(OnInfoReady callback) { callback.onReady(Settings.Secure.getIntForUser(mContext.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_MIGRATION_TOOLTIP_PROMPT, @@ -153,6 +160,11 @@ class MenuInfoRepository { percentagePosition.toString()); } + void updateDockTooltipVisibility(boolean hasSeen) { + Prefs.putBoolean(mContext, Prefs.Key.HAS_SEEN_ACCESSIBILITY_FLOATING_MENU_DOCK_TOOLTIP, + hasSeen); + } + void updateMigrationTooltipVisibility(boolean visible) { Settings.Secure.putIntForUser(mContext.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_MIGRATION_TOOLTIP_PROMPT, diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java index 070fa236906c9..6f5b39cc7d56c 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java @@ -89,10 +89,13 @@ class MenuViewLayer extends FrameLayout implements private final IAccessibilityFloatingMenu mFloatingMenu; private final DismissAnimationController mDismissAnimationController; private final MenuViewModel mMenuViewModel; + private final Observer mDockTooltipObserver = + this::onDockTooltipVisibilityChanged; private final Observer mMigrationTooltipObserver = this::onMigrationTooltipVisibilityChanged; private final Rect mImeInsetsRect = new Rect(); private boolean mIsMigrationTooltipShowing; + private boolean mShouldShowDockTooltip; private Optional mEduTooltipView = Optional.empty(); @IntDef({ @@ -111,10 +114,12 @@ class MenuViewLayer extends FrameLayout implements @StringDef({ TooltipType.MIGRATION, + TooltipType.DOCK, }) @Retention(RetentionPolicy.SOURCE) @interface TooltipType { String MIGRATION = "migration"; + String DOCK = "dock"; } @VisibleForTesting @@ -154,6 +159,7 @@ class MenuViewLayer extends FrameLayout implements mMenuView = new MenuView(context, mMenuViewModel, mMenuViewAppearance); mMenuAnimationController = mMenuView.getMenuAnimationController(); mMenuAnimationController.setDismissCallback(this::hideMenuAndShowMessage); + mMenuAnimationController.setSpringAnimationsEndAction(this::onSpringAnimationsEndAction); mDismissView = new DismissView(context); mDismissAnimationController = new DismissAnimationController(mDismissView, mMenuView); mDismissAnimationController.setMagnetListener(new MagnetizedObject.MagnetListener() { @@ -236,6 +242,7 @@ class MenuViewLayer extends FrameLayout implements setOnClickListener(this); setOnApplyWindowInsetsListener((view, insets) -> onWindowInsetsApplied(insets)); getViewTreeObserver().addOnComputeInternalInsetsListener(this); + mMenuViewModel.getDockTooltipVisibilityData().observeForever(mDockTooltipObserver); mMenuViewModel.getMigrationTooltipVisibilityData().observeForever( mMigrationTooltipObserver); mMessageView.setUndoListener(view -> undo()); @@ -250,6 +257,7 @@ class MenuViewLayer extends FrameLayout implements setOnClickListener(null); setOnApplyWindowInsetsListener(null); getViewTreeObserver().removeOnComputeInternalInsetsListener(this); + mMenuViewModel.getDockTooltipVisibilityData().removeObserver(mDockTooltipObserver); mMenuViewModel.getMigrationTooltipVisibilityData().removeObserver( mMigrationTooltipObserver); mHandler.removeCallbacksAndMessages(/* token= */ null); @@ -306,6 +314,21 @@ class MenuViewLayer extends FrameLayout implements } } + private void onDockTooltipVisibilityChanged(boolean hasSeenTooltip) { + mShouldShowDockTooltip = !hasSeenTooltip; + } + + private void onSpringAnimationsEndAction() { + if (mShouldShowDockTooltip) { + mEduTooltipView = Optional.of(new MenuEduTooltipView(mContext, mMenuViewAppearance)); + mEduTooltipView.ifPresent(view -> addTooltipView(view, + getContext().getText(R.string.accessibility_floating_button_docking_tooltip), + TooltipType.DOCK)); + + mMenuAnimationController.startTuckedAnimationPreview(); + } + } + private CharSequence getMigrationMessage() { final Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_DETAILS_SETTINGS); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -341,6 +364,12 @@ class MenuViewLayer extends FrameLayout implements mIsMigrationTooltipShowing = false; } + if (tooltipView.getTag().equals(TooltipType.DOCK)) { + mMenuViewModel.updateDockTooltipVisibility(/* hasSeen= */ true); + mMenuView.clearAnimation(); + mShouldShowDockTooltip = false; + } + removeView(tooltipView); mMenuListViewTouchHandler.setOnActionDownEndListener(null); diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java index 290f97e6ba7dc..5fea3b0ba2f98 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java @@ -36,6 +36,7 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { private final MutableLiveData mFadeEffectInfoData = new MutableLiveData<>(); private final MutableLiveData mMoveToTuckedData = new MutableLiveData<>(); + private final MutableLiveData mDockTooltipData = new MutableLiveData<>(); private final MutableLiveData mMigrationTooltipData = new MutableLiveData<>(); private final MutableLiveData mPercentagePositionData = new MutableLiveData<>(); private final MenuInfoRepository mInfoRepository; @@ -67,6 +68,10 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { mInfoRepository.updateMenuSavingPosition(percentagePosition); } + void updateDockTooltipVisibility(boolean hasSeen) { + mInfoRepository.updateDockTooltipVisibility(hasSeen); + } + void updateMigrationTooltipVisibility(boolean visible) { mInfoRepository.updateMigrationTooltipVisibility(visible); } @@ -76,6 +81,11 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { return mMoveToTuckedData; } + LiveData getDockTooltipVisibilityData() { + mInfoRepository.loadDockTooltipVisibility(mDockTooltipData::setValue); + return mDockTooltipData; + } + LiveData getMigrationTooltipVisibilityData() { mInfoRepository.loadMigrationTooltipVisibility(mMigrationTooltipData::setValue); return mMigrationTooltipData; diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java index b2c52668e057a..0cdd6e2ce85e7 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java @@ -20,8 +20,10 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; import android.graphics.PointF; import android.testing.AndroidTestingRunner; @@ -30,6 +32,10 @@ import android.view.View; import android.view.ViewPropertyAnimator; import android.view.WindowManager; +import androidx.dynamicanimation.animation.DynamicAnimation; +import androidx.dynamicanimation.animation.FlingAnimation; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; import androidx.test.filters.SmallTest; import com.android.systemui.Prefs; @@ -39,6 +45,9 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; /** Tests for {@link MenuAnimationController}. */ @RunWith(AndroidTestingRunner.class) @@ -47,9 +56,10 @@ import org.junit.runner.RunWith; public class MenuAnimationControllerTest extends SysuiTestCase { private boolean mLastIsMoveToTucked; + private ArgumentCaptor mEndListenerCaptor; private ViewPropertyAnimator mViewPropertyAnimator; private MenuView mMenuView; - private MenuAnimationController mMenuAnimationController; + private TestMenuAnimationController mMenuAnimationController; @Before public void setUp() throws Exception { @@ -62,15 +72,17 @@ public class MenuAnimationControllerTest extends SysuiTestCase { mViewPropertyAnimator = spy(mMenuView.animate()); doReturn(mViewPropertyAnimator).when(mMenuView).animate(); - mMenuAnimationController = new MenuAnimationController(mMenuView); + mMenuAnimationController = new TestMenuAnimationController(mMenuView); mLastIsMoveToTucked = Prefs.getBoolean(mContext, Prefs.Key.HAS_ACCESSIBILITY_FLOATING_MENU_TUCKED, /* defaultValue= */ false); + mEndListenerCaptor = ArgumentCaptor.forClass(DynamicAnimation.OnAnimationEndListener.class); } @After public void tearDown() throws Exception { Prefs.putBoolean(mContext, Prefs.Key.HAS_ACCESSIBILITY_FLOATING_MENU_TUCKED, mLastIsMoveToTucked); + mEndListenerCaptor.getAllValues().clear(); } @Test @@ -122,4 +134,115 @@ public class MenuAnimationControllerTest extends SysuiTestCase { assertThat(isMoveToTucked).isFalse(); } + + @Test + public void startTuckedAnimationPreview_hasAnimation() { + mMenuView.clearAnimation(); + + mMenuAnimationController.startTuckedAnimationPreview(); + + assertThat(mMenuView.getAnimation()).isNotNull(); + } + + @Test + public void startSpringAnimationsAndEndOneAnimation_notTriggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + setupAndRunSpringAnimations(); + final Optional anyAnimation = + mMenuAnimationController.mPositionAnimations.values().stream().findAny(); + anyAnimation.ifPresent(this::skipAnimationToEnd); + + verifyZeroInteractions(onSpringAnimationsEndCallback); + } + + @Test + public void startAndEndSpringAnimations_triggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + setupAndRunSpringAnimations(); + mMenuAnimationController.mPositionAnimations.values().forEach(this::skipAnimationToEnd); + + verify(onSpringAnimationsEndCallback).run(); + } + + @Test + public void flingThenSpringAnimationsAreEnded_triggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + mMenuAnimationController.flingMenuThenSpringToEdge(/* x= */ 0, /* velocityX= */ + 100, /* velocityY= */ 100); + mMenuAnimationController.mPositionAnimations.values() + .forEach(animation -> verify((FlingAnimation) animation).addEndListener( + mEndListenerCaptor.capture())); + mEndListenerCaptor.getAllValues() + .forEach(listener -> listener.onAnimationEnd(mock(DynamicAnimation.class), + /* canceled */ false, /* endValue */ 0, /* endVelocity */ 0)); + mMenuAnimationController.mPositionAnimations.values().forEach(this::skipAnimationToEnd); + + verify(onSpringAnimationsEndCallback).run(); + } + + @Test + public void existFlingIsRunningAndTheOtherAreEnd_notTriggerEndAction() { + final Runnable onSpringAnimationsEndCallback = mock(Runnable.class); + mMenuAnimationController.setSpringAnimationsEndAction(onSpringAnimationsEndCallback); + + mMenuAnimationController.flingMenuThenSpringToEdge(/* x= */ 0, /* velocityX= */ + 200, /* velocityY= */ 200); + mMenuAnimationController.mPositionAnimations.values() + .forEach(animation -> verify((FlingAnimation) animation).addEndListener( + mEndListenerCaptor.capture())); + final Optional anyAnimation = + mEndListenerCaptor.getAllValues().stream().findAny(); + anyAnimation.ifPresent( + listener -> listener.onAnimationEnd(mock(DynamicAnimation.class), /* canceled */ + false, /* endValue */ 0, /* endVelocity */ 0)); + mMenuAnimationController.mPositionAnimations.values() + .stream() + .filter(animation -> animation instanceof SpringAnimation) + .forEach(this::skipAnimationToEnd); + + verifyZeroInteractions(onSpringAnimationsEndCallback); + } + + private void setupAndRunSpringAnimations() { + final float stiffness = 700f; + final float dampingRatio = 0.85f; + final float velocity = 100f; + final float finalPosition = 300f; + + mMenuAnimationController.springMenuWith(DynamicAnimation.TRANSLATION_X, new SpringForce() + .setStiffness(stiffness) + .setDampingRatio(dampingRatio), velocity, finalPosition); + mMenuAnimationController.springMenuWith(DynamicAnimation.TRANSLATION_Y, new SpringForce() + .setStiffness(stiffness) + .setDampingRatio(dampingRatio), velocity, finalPosition); + } + + private void skipAnimationToEnd(DynamicAnimation animation) { + final SpringAnimation springAnimation = ((SpringAnimation) animation); + // The doAnimationFrame function is used for skipping animation to the end. + springAnimation.doAnimationFrame(100); + springAnimation.skipToEnd(); + springAnimation.doAnimationFrame(200); + } + + /** + * Wrapper class for testing. + */ + private static class TestMenuAnimationController extends MenuAnimationController { + TestMenuAnimationController(MenuView menuView) { + super(menuView); + } + + @Override + FlingAnimation createFlingAnimation(MenuView menuView, + MenuPositionProperty menuPositionProperty) { + return spy(super.createFlingAnimation(menuView, menuPositionProperty)); + } + } }