Refactor the design and improve the animations of Accessibility Floating Menu(11/n).

Goals:
Show the dock tooltip to indicate users how to use the feature of moving the menu to be tucked.

Bug: 227715451
Test: atest MenuAnimationControllerTest
Change-Id: Idd0d85f7d333701a7545e99a20164243e0ce12ec
This commit is contained in:
Peter Liang
2022-11-22 20:59:11 +08:00
parent cb6558813e
commit 403730deb8
5 changed files with 235 additions and 8 deletions

View File

@@ -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<DynamicAnimation.ViewProperty, DynamicAnimation> mPositionAnimations =
@VisibleForTesting
final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> 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"));
}

View File

@@ -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<Boolean> callback) {
callback.onReady(Prefs.getBoolean(mContext,
Prefs.Key.HAS_SEEN_ACCESSIBILITY_FLOATING_MENU_DOCK_TOOLTIP,
DEFAULT_HAS_SEEN_DOCK_TOOLTIP_VALUE));
}
void loadMigrationTooltipVisibility(OnInfoReady<Boolean> 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,

View File

@@ -89,10 +89,13 @@ class MenuViewLayer extends FrameLayout implements
private final IAccessibilityFloatingMenu mFloatingMenu;
private final DismissAnimationController mDismissAnimationController;
private final MenuViewModel mMenuViewModel;
private final Observer<Boolean> mDockTooltipObserver =
this::onDockTooltipVisibilityChanged;
private final Observer<Boolean> mMigrationTooltipObserver =
this::onMigrationTooltipVisibilityChanged;
private final Rect mImeInsetsRect = new Rect();
private boolean mIsMigrationTooltipShowing;
private boolean mShouldShowDockTooltip;
private Optional<MenuEduTooltipView> 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);

View File

@@ -36,6 +36,7 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged {
private final MutableLiveData<MenuFadeEffectInfo> mFadeEffectInfoData =
new MutableLiveData<>();
private final MutableLiveData<Boolean> mMoveToTuckedData = new MutableLiveData<>();
private final MutableLiveData<Boolean> mDockTooltipData = new MutableLiveData<>();
private final MutableLiveData<Boolean> mMigrationTooltipData = new MutableLiveData<>();
private final MutableLiveData<Position> 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<Boolean> getDockTooltipVisibilityData() {
mInfoRepository.loadDockTooltipVisibility(mDockTooltipData::setValue);
return mDockTooltipData;
}
LiveData<Boolean> getMigrationTooltipVisibilityData() {
mInfoRepository.loadMigrationTooltipVisibility(mMigrationTooltipData::setValue);
return mMigrationTooltipData;

View File

@@ -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<DynamicAnimation.OnAnimationEndListener> 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<DynamicAnimation> 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<DynamicAnimation.OnAnimationEndListener> 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));
}
}
}