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 9d2a4d74a0e6a..d6d0399035054 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java @@ -16,24 +16,363 @@ package com.android.systemui.accessibility.floatingmenu; +import static android.util.MathUtils.constrain; + +import static java.util.Objects.requireNonNull; + +import android.animation.ValueAnimator; import android.graphics.PointF; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; import androidx.dynamicanimation.animation.DynamicAnimation; +import androidx.dynamicanimation.animation.FlingAnimation; +import androidx.dynamicanimation.animation.FloatPropertyCompat; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.HashMap; /** - * Controls the interaction animations of the menu view {@link MenuView}. + * Controls the interaction animations of the {@link MenuView}. Also, it will use the relative + * coordinate based on the {@link MenuViewLayer} to compute the offset of the {@link MenuView}. */ class MenuAnimationController { + private static final String TAG = "MenuAnimationController"; + private static final boolean DEBUG = false; + private static final float MIN_PERCENT = 0.0f; + private static final float MAX_PERCENT = 1.0f; + private static final float COMPLETELY_OPAQUE = 1.0f; + private static final float FLING_FRICTION_SCALAR = 1.9f; + private static final float DEFAULT_FRICTION = 4.2f; + 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; + + private static final int FADE_OUT_DURATION_MS = 1000; + private static final int FADE_EFFECT_DURATION_MS = 3000; + private final MenuView mMenuView; + private final ValueAnimator mFadeOutAnimator; + private final Handler mHandler; + private boolean mIsMovedToEdge; + private boolean mIsFadeEffectEnabled; + + // 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 = + new HashMap<>(); MenuAnimationController(MenuView menuView) { mMenuView = menuView; + + mHandler = createUiHandler(); + mFadeOutAnimator = new ValueAnimator(); + mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS); + mFadeOutAnimator.addUpdateListener( + (animation) -> menuView.setAlpha((float) animation.getAnimatedValue())); } void moveToPosition(PointF position) { - DynamicAnimation.TRANSLATION_X.setValue(mMenuView, position.x); - DynamicAnimation.TRANSLATION_Y.setValue(mMenuView, position.y); + moveToPositionX(position.x); + moveToPositionY(position.y); + } + void moveToPositionX(float positionX) { + DynamicAnimation.TRANSLATION_X.setValue(mMenuView, positionX); + } + + private void moveToPositionY(float positionY) { + DynamicAnimation.TRANSLATION_Y.setValue(mMenuView, positionY); + } + + void moveToPositionYIfNeeded(float positionY) { + // If the list view was out of screen bounds, it would allow users to nest scroll inside + // and avoid conflicting with outer scroll. + final RecyclerView listView = (RecyclerView) mMenuView.getChildAt(/* index= */ 0); + if (listView.getOverScrollMode() == View.OVER_SCROLL_NEVER) { + moveToPositionY(positionY); + } + } + + void moveToTopLeftPosition() { + mIsMovedToEdge = false; + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.top)); + } + + void moveToTopRightPosition() { + mIsMovedToEdge = false; + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.top)); + } + + void moveToBottomLeftPosition() { + mIsMovedToEdge = false; + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + moveAndPersistPosition(new PointF(draggableBounds.left, draggableBounds.bottom)); + } + + void moveToBottomRightPosition() { + mIsMovedToEdge = false; + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + moveAndPersistPosition(new PointF(draggableBounds.right, draggableBounds.bottom)); + } + + void moveAndPersistPosition(PointF position) { + moveToPosition(position); mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); + constrainPositionAndUpdate(position); + } + + void flingMenuThenSpringToEdge(float x, float velocityX, float velocityY) { + final boolean shouldMenuFlingLeft = isOnLeftSide() + ? velocityX < ESCAPE_VELOCITY + : velocityX < -ESCAPE_VELOCITY; + + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + final float finalPositionX = shouldMenuFlingLeft + ? draggableBounds.left : draggableBounds.right; + + final float minimumVelocityToReachEdge = + (finalPositionX - x) * (FLING_FRICTION_SCALAR * DEFAULT_FRICTION); + + final float startXVelocity = shouldMenuFlingLeft + ? Math.min(minimumVelocityToReachEdge, velocityX) + : Math.max(minimumVelocityToReachEdge, velocityX); + + flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_X, + startXVelocity, + FLING_FRICTION_SCALAR, + new SpringForce() + .setStiffness(SPRING_STIFFNESS) + .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO), + finalPositionX); + + flingThenSpringMenuWith(DynamicAnimation.TRANSLATION_Y, + velocityY, + FLING_FRICTION_SCALAR, + new SpringForce() + .setStiffness(SPRING_STIFFNESS) + .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO), + /* finalPosition= */ null); + } + + private void flingThenSpringMenuWith(DynamicAnimation.ViewProperty property, float velocity, + float friction, SpringForce spring, Float finalPosition) { + + final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); + final float currentValue = menuPositionProperty.getValue(mMenuView); + final Rect bounds = mMenuView.getMenuDraggableBounds(); + final float min = + property.equals(DynamicAnimation.TRANSLATION_X) + ? bounds.left + : bounds.top; + final float max = + property.equals(DynamicAnimation.TRANSLATION_X) + ? bounds.right + : bounds.bottom; + + final FlingAnimation flingAnimation = new FlingAnimation(mMenuView, menuPositionProperty); + flingAnimation.setFriction(friction) + .setStartVelocity(velocity) + .setMinValue(Math.min(currentValue, min)) + .setMaxValue(Math.max(currentValue, max)) + .addEndListener((animation, canceled, endValue, endVelocity) -> { + if (canceled) { + if (DEBUG) { + Log.d(TAG, "The fling animation was canceled."); + } + + return; + } + + final float endPosition = finalPosition != null + ? finalPosition + : Math.max(min, Math.min(max, endValue)); + springMenuWith(property, spring, endVelocity, endPosition); + }); + + cancelAnimation(property); + mPositionAnimations.put(property, flingAnimation); + flingAnimation.start(); + } + + private void springMenuWith(DynamicAnimation.ViewProperty property, SpringForce spring, + float velocity, float finalPosition) { + final MenuPositionProperty menuPositionProperty = new MenuPositionProperty(property); + final SpringAnimation springAnimation = + new SpringAnimation(mMenuView, menuPositionProperty) + .setSpring(spring) + .addEndListener((animation, canceled, endValue, endVelocity) -> { + if (canceled || endValue != finalPosition) { + return; + } + + onSpringAnimationEnd(new PointF(mMenuView.getTranslationX(), + mMenuView.getTranslationY())); + }) + .setStartVelocity(velocity); + + cancelAnimation(property); + mPositionAnimations.put(property, springAnimation); + springAnimation.animateToFinalPosition(finalPosition); + } + + /** + * Determines whether to hide the menu to the edge of the screen with the given current + * translation x of the menu view. It should be used when receiving the action up touch event. + * + * @param currentXTranslation the current translation x of the menu view. + * @return true if the menu would be hidden to the edge, otherwise false. + */ + boolean maybeMoveToEdgeAndHide(float currentXTranslation) { + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + + // If the translation x is zero, it should be at the left of the bound. + if (currentXTranslation < draggableBounds.left + || currentXTranslation > draggableBounds.right) { + moveToEdgeAndHide(); + return true; + } + + fadeOutIfEnabled(); + return false; + } + + private boolean isOnLeftSide() { + return mMenuView.getTranslationX() < mMenuView.getMenuDraggableBounds().centerX(); + } + + boolean isMovedToEdge() { + return mIsMovedToEdge; + } + + void moveToEdgeAndHide() { + mIsMovedToEdge = true; + + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + final float endY = constrain(mMenuView.getTranslationY(), draggableBounds.top, + draggableBounds.bottom); + final float menuHalfWidth = mMenuView.getWidth() / 2.0f; + final float endX = isOnLeftSide() + ? draggableBounds.left - menuHalfWidth + : draggableBounds.right + menuHalfWidth; + moveAndPersistPosition(new PointF(endX, endY)); + + // Keep the touch region let users could click extra space to pop up the menu view + // from the screen edge + mMenuView.onBoundsInParentChanged(isOnLeftSide() + ? draggableBounds.left + : draggableBounds.right, (int) mMenuView.getTranslationY()); + + fadeOutIfEnabled(); + } + + void moveOutEdgeAndShow() { + mIsMovedToEdge = false; + + mMenuView.onPositionChanged(); + mMenuView.onEdgeChangedIfNeeded(); + } + + void cancelAnimations() { + cancelAnimation(DynamicAnimation.TRANSLATION_X); + cancelAnimation(DynamicAnimation.TRANSLATION_Y); + } + + private void cancelAnimation(DynamicAnimation.ViewProperty property) { + if (!mPositionAnimations.containsKey(property)) { + return; + } + + mPositionAnimations.get(property).cancel(); + } + + void onDraggingStart() { + mMenuView.onDraggingStart(); + } + + private void onSpringAnimationEnd(PointF position) { + mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y); + constrainPositionAndUpdate(position); + + fadeOutIfEnabled(); + } + + private void constrainPositionAndUpdate(PointF position) { + final Rect draggableBounds = mMenuView.getMenuDraggableBounds(); + // Have the space gap margin between the top bound and the menu view, so actually the + // position y range needs to cut the margin. + position.offset(-draggableBounds.left, -draggableBounds.top); + + final float percentageX = position.x < draggableBounds.centerX() + ? MIN_PERCENT : MAX_PERCENT; + + final float percentageY = position.y < 0 || draggableBounds.height() == 0 + ? MIN_PERCENT + : Math.min(MAX_PERCENT, position.y / draggableBounds.height()); + mMenuView.persistPositionAndUpdateEdge(new Position(percentageX, percentageY)); + } + + void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) { + mIsFadeEffectEnabled = isFadeEffectEnabled; + + mHandler.removeCallbacksAndMessages(/* token= */ null); + mFadeOutAnimator.cancel(); + mFadeOutAnimator.setFloatValues(COMPLETELY_OPAQUE, newOpacityValue); + mHandler.post(() -> mMenuView.setAlpha( + mIsFadeEffectEnabled ? newOpacityValue : COMPLETELY_OPAQUE)); + } + + void fadeInNowIfEnabled() { + if (!mIsFadeEffectEnabled) { + return; + } + + cancelAndRemoveCallbacksAndMessages(); + mHandler.post(() -> mMenuView.setAlpha(COMPLETELY_OPAQUE)); + } + + void fadeOutIfEnabled() { + if (!mIsFadeEffectEnabled) { + return; + } + + cancelAndRemoveCallbacksAndMessages(); + mHandler.postDelayed(mFadeOutAnimator::start, FADE_EFFECT_DURATION_MS); + } + + private void cancelAndRemoveCallbacksAndMessages() { + mFadeOutAnimator.cancel(); + mHandler.removeCallbacksAndMessages(/* token= */ null); + } + + private Handler createUiHandler() { + return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null")); + } + + static class MenuPositionProperty + extends FloatPropertyCompat { + private final DynamicAnimation.ViewProperty mProperty; + + MenuPositionProperty(DynamicAnimation.ViewProperty property) { + super(property.toString()); + mProperty = property; + } + + @Override + public float getValue(MenuView menuView) { + return mProperty.getValue(menuView); + } + + @Override + public void setValue(MenuView menuView, float value) { + mProperty.setValue(menuView, value); + } } } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuFadeEffectInfo.kt b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuFadeEffectInfo.kt new file mode 100644 index 0000000000000..83c344cca2140 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuFadeEffectInfo.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 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.accessibility.floatingmenu + +import android.annotation.FloatRange + +@FloatRange(from = 0.0, to = 1.0) const val DEFAULT_OPACITY_VALUE = 0.55f +const val DEFAULT_FADE_EFFECT_IS_ENABLED = 1 + +/** The data class for the fade effect info of the accessibility floating menu view. */ +data class MenuFadeEffectInfo(val isFadeEffectEnabled: Boolean, val opacity: Float) 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 0649463c8238d..57019de762a4a 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepository.java @@ -16,11 +16,15 @@ package com.android.systemui.accessibility.floatingmenu; +import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED; +import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_OPACITY; import static android.provider.Settings.Secure.ACCESSIBILITY_FLOATING_MENU_SIZE; import static android.provider.Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES; import static android.view.accessibility.AccessibilityManager.ACCESSIBILITY_BUTTON; import static com.android.internal.accessibility.dialog.AccessibilityTargetHelper.getTargets; +import static com.android.systemui.accessibility.floatingmenu.MenuFadeEffectInfoKt.DEFAULT_FADE_EFFECT_IS_ENABLED; +import static com.android.systemui.accessibility.floatingmenu.MenuFadeEffectInfoKt.DEFAULT_OPACITY_VALUE; import static com.android.systemui.accessibility.floatingmenu.MenuViewAppearance.MenuSizeType.SMALL; import android.annotation.FloatRange; @@ -72,6 +76,15 @@ class MenuInfoRepository { } }; + @VisibleForTesting + final ContentObserver mMenuFadeOutContentObserver = + new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange) { + mSettingsContentsCallback.onFadeEffectInfoChanged(getMenuFadeEffectInfo()); + } + }; + MenuInfoRepository(Context context, OnSettingsContentsChanged settingsContentsChanged) { mContext = context; mSettingsContentsCallback = settingsContentsChanged; @@ -91,6 +104,15 @@ class MenuInfoRepository { callback.onReady(getMenuSizeTypeFromSettings(mContext)); } + void loadMenuFadeEffectInfo(OnInfoReady callback) { + callback.onReady(getMenuFadeEffectInfo()); + } + + private MenuFadeEffectInfo getMenuFadeEffectInfo() { + return new MenuFadeEffectInfo(isMenuFadeEffectEnabledFromSettings(mContext), + getMenuOpacityFromSettings(mContext)); + } + void updateMenuSavingPosition(Position percentagePosition) { mPercentagePosition = percentagePosition; Prefs.putString(mContext, Prefs.Key.ACCESSIBILITY_FLOATING_MENU_POSITION, @@ -119,17 +141,28 @@ class MenuInfoRepository { Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_FLOATING_MENU_SIZE), /* notifyForDescendants */ false, mMenuSizeContentObserver, UserHandle.USER_CURRENT); + mContext.getContentResolver().registerContentObserver( + Settings.Secure.getUriFor(ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED), + /* notifyForDescendants */ false, mMenuFadeOutContentObserver, + UserHandle.USER_CURRENT); + mContext.getContentResolver().registerContentObserver( + Settings.Secure.getUriFor(ACCESSIBILITY_FLOATING_MENU_OPACITY), + /* notifyForDescendants */ false, mMenuFadeOutContentObserver, + UserHandle.USER_CURRENT); } void unregisterContentObservers() { mContext.getContentResolver().unregisterContentObserver(mMenuTargetFeaturesContentObserver); mContext.getContentResolver().unregisterContentObserver(mMenuSizeContentObserver); + mContext.getContentResolver().unregisterContentObserver(mMenuFadeOutContentObserver); } interface OnSettingsContentsChanged { void onTargetFeaturesChanged(List newTargetFeatures); void onSizeTypeChanged(int newSizeType); + + void onFadeEffectInfoChanged(MenuFadeEffectInfo fadeEffectInfo); } interface OnInfoReady { @@ -140,4 +173,16 @@ class MenuInfoRepository { return Settings.Secure.getIntForUser(context.getContentResolver(), ACCESSIBILITY_FLOATING_MENU_SIZE, SMALL, UserHandle.USER_CURRENT); } + + private static boolean isMenuFadeEffectEnabledFromSettings(Context context) { + return Settings.Secure.getIntForUser(context.getContentResolver(), + ACCESSIBILITY_FLOATING_MENU_FADE_ENABLED, + DEFAULT_FADE_EFFECT_IS_ENABLED, UserHandle.USER_CURRENT) == /* enabled */ 1; + } + + private static float getMenuOpacityFromSettings(Context context) { + return Settings.Secure.getFloatForUser(context.getContentResolver(), + ACCESSIBILITY_FLOATING_MENU_OPACITY, DEFAULT_OPACITY_VALUE, + UserHandle.USER_CURRENT); + } } diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegate.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegate.java new file mode 100644 index 0000000000000..e69a24810fdc0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegate.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2022 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.accessibility.floatingmenu; + +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS; +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS; + +import android.content.res.Resources; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; + +import com.android.systemui.R; + +/** + * An accessibility item delegate for the individual items of the list view in the + * {@link MenuView}. + */ +class MenuItemAccessibilityDelegate extends RecyclerViewAccessibilityDelegate.ItemDelegate { + private final MenuAnimationController mAnimationController; + + MenuItemAccessibilityDelegate(@NonNull RecyclerViewAccessibilityDelegate recyclerViewDelegate, + MenuAnimationController animationController) { + super(recyclerViewDelegate); + mAnimationController = animationController; + } + + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + + final Resources res = host.getResources(); + final AccessibilityNodeInfoCompat.AccessibilityActionCompat moveTopLeft = + new AccessibilityNodeInfoCompat.AccessibilityActionCompat(R.id.action_move_top_left, + res.getString( + R.string.accessibility_floating_button_action_move_top_left)); + info.addAction(moveTopLeft); + + final AccessibilityNodeInfoCompat.AccessibilityActionCompat moveTopRight = + new AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.action_move_top_right, + res.getString( + R.string.accessibility_floating_button_action_move_top_right)); + info.addAction(moveTopRight); + + final AccessibilityNodeInfoCompat.AccessibilityActionCompat moveBottomLeft = + new AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.action_move_bottom_left, + res.getString( + R.string.accessibility_floating_button_action_move_bottom_left)); + info.addAction(moveBottomLeft); + + final AccessibilityNodeInfoCompat.AccessibilityActionCompat moveBottomRight = + new AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.action_move_bottom_right, + res.getString( + R.string.accessibility_floating_button_action_move_bottom_right)); + info.addAction(moveBottomRight); + + final int moveEdgeId = mAnimationController.isMovedToEdge() + ? R.id.action_move_out_edge_and_show + : R.id.action_move_to_edge_and_hide; + final int moveEdgeTextResId = mAnimationController.isMovedToEdge() + ? R.string.accessibility_floating_button_action_move_out_edge_and_show + : R.string.accessibility_floating_button_action_move_to_edge_and_hide_to_half; + final AccessibilityNodeInfoCompat.AccessibilityActionCompat moveToOrOutEdge = + new AccessibilityNodeInfoCompat.AccessibilityActionCompat(moveEdgeId, + res.getString(moveEdgeTextResId)); + info.addAction(moveToOrOutEdge); + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (action == ACTION_ACCESSIBILITY_FOCUS) { + mAnimationController.fadeInNowIfEnabled(); + } + + if (action == ACTION_CLEAR_ACCESSIBILITY_FOCUS) { + mAnimationController.fadeOutIfEnabled(); + } + + if (action == R.id.action_move_top_left) { + mAnimationController.moveToTopLeftPosition(); + return true; + } + + if (action == R.id.action_move_top_right) { + mAnimationController.moveToTopRightPosition(); + return true; + } + + if (action == R.id.action_move_bottom_left) { + mAnimationController.moveToBottomLeftPosition(); + return true; + } + + if (action == R.id.action_move_bottom_right) { + mAnimationController.moveToBottomRightPosition(); + return true; + } + + if (action == R.id.action_move_to_edge_and_hide) { + mAnimationController.moveToEdgeAndHide(); + return true; + } + + if (action == R.id.action_move_out_edge_and_show) { + mAnimationController.moveOutEdgeAndShow(); + return true; + } + + return super.performAccessibilityAction(host, action, args); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java new file mode 100644 index 0000000000000..3146c9f0d2af4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandler.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2022 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.accessibility.floatingmenu; + +import android.graphics.PointF; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Controls the all touch events of the accessibility target features view{@link RecyclerView} in + * the {@link MenuView}. And then compute the gestures' velocity for fling and spring + * animations. + */ +class MenuListViewTouchHandler implements RecyclerView.OnItemTouchListener { + private static final int VELOCITY_UNIT_SECONDS = 1000; + private final VelocityTracker mVelocityTracker = VelocityTracker.obtain(); + private final MenuAnimationController mMenuAnimationController; + private final PointF mDown = new PointF(); + private final PointF mMenuTranslationDown = new PointF(); + private boolean mIsDragging = false; + private float mTouchSlop; + + MenuListViewTouchHandler(MenuAnimationController menuAnimationController) { + mMenuAnimationController = menuAnimationController; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, + @NonNull MotionEvent motionEvent) { + + final View menuView = (View) recyclerView.getParent(); + addMovement(motionEvent); + + final float dx = motionEvent.getRawX() - mDown.x; + final float dy = motionEvent.getRawY() - mDown.y; + + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_DOWN: + mMenuAnimationController.fadeInNowIfEnabled(); + mTouchSlop = ViewConfiguration.get(recyclerView.getContext()).getScaledTouchSlop(); + mDown.set(motionEvent.getRawX(), motionEvent.getRawY()); + mMenuTranslationDown.set(menuView.getTranslationX(), menuView.getTranslationY()); + + mMenuAnimationController.cancelAnimations(); + break; + case MotionEvent.ACTION_MOVE: + if (mIsDragging || Math.hypot(dx, dy) > mTouchSlop) { + if (!mIsDragging) { + mIsDragging = true; + mMenuAnimationController.onDraggingStart(); + } + + mMenuAnimationController.moveToPositionX(mMenuTranslationDown.x + dx); + mMenuAnimationController.moveToPositionYIfNeeded(mMenuTranslationDown.y + dy); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (mIsDragging) { + final float endX = mMenuTranslationDown.x + dx; + mIsDragging = false; + + if (!mMenuAnimationController.maybeMoveToEdgeAndHide(endX)) { + mVelocityTracker.computeCurrentVelocity(VELOCITY_UNIT_SECONDS); + mMenuAnimationController.flingMenuThenSpringToEdge(endX, + mVelocityTracker.getXVelocity(), mVelocityTracker.getYVelocity()); + } + + // Avoid triggering the listener of the item. + return true; + } + + break; + default: // Do nothing + } + + // not consume all the events here because keeping the scroll behavior of list view. + return false; + } + + @Override + public void onTouchEvent(@NonNull RecyclerView recyclerView, + @NonNull MotionEvent motionEvent) { + // Do nothing + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean b) { + // Do nothing + } + + /** + * Adds a movement to the velocity tracker using raw screen coordinates. + */ + private void addMovement(MotionEvent motionEvent) { + final float deltaX = motionEvent.getRawX() - motionEvent.getX(); + final float deltaY = motionEvent.getRawY() - motionEvent.getY(); + motionEvent.offsetLocation(deltaX, deltaY); + mVelocityTracker.addMovement(motionEvent); + motionEvent.offsetLocation(-deltaX, -deltaY); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java index d6f8885f6230f..15d139cf15da5 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java @@ -28,9 +28,12 @@ import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import androidx.core.view.AccessibilityDelegateCompat; import androidx.lifecycle.Observer; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; import com.android.internal.accessibility.dialog.AccessibilityTarget; @@ -39,7 +42,7 @@ import java.util.Collections; import java.util.List; /** - * The container view displays the accessibility features. + * The menu view displays the accessibility features. */ @SuppressLint("ViewConstructor") class MenuView extends FrameLayout implements @@ -53,6 +56,8 @@ class MenuView extends FrameLayout implements private final RecyclerView mTargetFeaturesView; private final ViewTreeObserver.OnDrawListener mSystemGestureExcludeUpdater = this::updateSystemGestureExcludeRects; + private final Observer mFadeEffectInfoObserver = + this::onMenuFadeEffectInfoChanged; private final Observer mPercentagePositionObserver = this::onPercentagePosition; private final Observer mSizeTypeObserver = this::onSizeTypeChanged; private final Observer> mTargetFeaturesObserver = @@ -65,10 +70,20 @@ class MenuView extends FrameLayout implements mMenuViewModel = menuViewModel; mMenuViewAppearance = menuViewAppearance; mMenuAnimationController = new MenuAnimationController(this); + mAdapter = new AccessibilityTargetAdapter(mTargetFeatures); mTargetFeaturesView = new RecyclerView(context); mTargetFeaturesView.setAdapter(mAdapter); mTargetFeaturesView.setLayoutManager(new LinearLayoutManager(context)); + mTargetFeaturesView.setAccessibilityDelegateCompat( + new RecyclerViewAccessibilityDelegate(mTargetFeaturesView) { + @NonNull + @Override + public AccessibilityDelegateCompat getItemDelegate() { + return new MenuItemAccessibilityDelegate(/* recyclerViewDelegate= */ this, + mMenuAnimationController); + } + }); setLayoutParams(new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); // Avoid drawing out of bounds of the parent view setClipToOutline(true); @@ -93,6 +108,10 @@ class MenuView extends FrameLayout implements mTargetFeaturesView.setOverScrollMode(mMenuViewAppearance.getMenuScrollMode()); } + void addOnItemTouchListenerToList(RecyclerView.OnItemTouchListener listener) { + mTargetFeaturesView.addOnItemTouchListener(listener); + } + @SuppressLint("NotifyDataSetChanged") private void onItemSizeChanged() { mAdapter.setItemPadding(mMenuViewAppearance.getMenuPadding()); @@ -110,6 +129,16 @@ class MenuView extends FrameLayout implements setLayoutParams(layoutParams); } + void onEdgeChangedIfNeeded() { + final Rect draggableBounds = mMenuViewAppearance.getMenuDraggableBounds(); + if (getTranslationX() != draggableBounds.left + && getTranslationX() != draggableBounds.right) { + return; + } + + onEdgeChanged(); + } + private void onEdgeChanged() { final int[] insets = mMenuViewAppearance.getMenuInsets(); getContainerViewInsetLayer().setLayerInset(INDEX_MENU_ITEM, insets[0], insets[1], insets[2], @@ -127,7 +156,7 @@ class MenuView extends FrameLayout implements onPositionChanged(); } - private void onPositionChanged() { + void onPositionChanged() { final PointF position = mMenuViewAppearance.getMenuPosition(); mMenuAnimationController.moveToPosition(position); onBoundsInParentChanged((int) position.x, (int) position.y); @@ -135,6 +164,8 @@ class MenuView extends FrameLayout implements @SuppressLint("NotifyDataSetChanged") private void onSizeTypeChanged(int newSizeType) { + mMenuAnimationController.fadeInNowIfEnabled(); + mMenuViewAppearance.setSizeType(newSizeType); mAdapter.setItemPadding(mMenuViewAppearance.getMenuPadding()); @@ -144,10 +175,14 @@ class MenuView extends FrameLayout implements onSizeChanged(); onEdgeChanged(); onPositionChanged(); + + mMenuAnimationController.fadeOutIfEnabled(); } private void onTargetFeaturesChanged(List newTargetFeatures) { // TODO(b/252756133): Should update specific item instead of the whole list + mMenuAnimationController.fadeInNowIfEnabled(); + mTargetFeatures.clear(); mTargetFeatures.addAll(newTargetFeatures); mMenuViewAppearance.setTargetFeaturesSize(mTargetFeatures.size()); @@ -157,10 +192,56 @@ class MenuView extends FrameLayout implements onSizeChanged(); onEdgeChanged(); onPositionChanged(); + + mMenuAnimationController.fadeOutIfEnabled(); + } + + private void onMenuFadeEffectInfoChanged(MenuFadeEffectInfo fadeEffectInfo) { + mMenuAnimationController.updateOpacityWith(fadeEffectInfo.isFadeEffectEnabled(), + fadeEffectInfo.getOpacity()); + } + + Rect getMenuDraggableBounds() { + return mMenuViewAppearance.getMenuDraggableBounds(); + } + + void persistPositionAndUpdateEdge(Position percentagePosition) { + mMenuViewModel.updateMenuSavingPosition(percentagePosition); + mMenuViewAppearance.setPercentagePosition(percentagePosition); + + onEdgeChangedIfNeeded(); + } + + /** + * Uses the touch events from the parent view to identify if users clicked the extra + * space of the menu view. If yes, will use the percentage position and update the + * translations of the menu view to meet the effect of moving out from the edge. It’s only + * used when the menu view is hidden to the screen edge. + * + * @param x the current x of the touch event from the parent {@link MenuViewLayer} of the + * {@link MenuView}. + * @param y the current y of the touch event from the parent {@link MenuViewLayer} of the + * {@link MenuView}. + * @return true if consume the touch event, otherwise false. + */ + boolean maybeMoveOutEdgeAndShow(int x, int y) { + // Utilizes the touch region of the parent view to implement that users could tap extra + // the space region to show the menu from the edge. + if (!mMenuAnimationController.isMovedToEdge() || !mBoundsInParent.contains(x, y)) { + return false; + } + + mMenuAnimationController.fadeInNowIfEnabled(); + + mMenuAnimationController.moveOutEdgeAndShow(); + + mMenuAnimationController.fadeOutIfEnabled(); + return true; } void show() { mMenuViewModel.getPercentagePositionData().observeForever(mPercentagePositionObserver); + mMenuViewModel.getFadeEffectInfoData().observeForever(mFadeEffectInfoObserver); mMenuViewModel.getTargetFeaturesData().observeForever(mTargetFeaturesObserver); mMenuViewModel.getSizeTypeData().observeForever(mSizeTypeObserver); setVisibility(VISIBLE); @@ -173,6 +254,7 @@ class MenuView extends FrameLayout implements setVisibility(GONE); mBoundsInParent.setEmpty(); mMenuViewModel.getPercentagePositionData().removeObserver(mPercentagePositionObserver); + mMenuViewModel.getFadeEffectInfoData().removeObserver(mFadeEffectInfoObserver); mMenuViewModel.getTargetFeaturesData().removeObserver(mTargetFeaturesObserver); mMenuViewModel.getSizeTypeData().removeObserver(mSizeTypeObserver); mMenuViewModel.unregisterContentObservers(); @@ -180,6 +262,15 @@ class MenuView extends FrameLayout implements getViewTreeObserver().removeOnDrawListener(mSystemGestureExcludeUpdater); } + void onDraggingStart() { + final int[] insets = mMenuViewAppearance.getMenuMovingStateInsets(); + getContainerViewInsetLayer().setLayerInset(INDEX_MENU_ITEM, insets[0], insets[1], insets[2], + insets[3]); + + final GradientDrawable gradientDrawable = getContainerViewGradient(); + gradientDrawable.setCornerRadii(mMenuViewAppearance.getMenuMovingStateRadii()); + } + void onBoundsInParentChanged(int newLeft, int newTop) { mBoundsInParent.offsetTo(newLeft, newTop); } @@ -187,6 +278,7 @@ class MenuView extends FrameLayout implements void loadLayoutResources() { mMenuViewAppearance.update(); + mTargetFeaturesView.setContentDescription(mMenuViewAppearance.getContentDescription()); setBackground(mMenuViewAppearance.getMenuBackground()); setElevation(mMenuViewAppearance.getMenuElevation()); onItemSizeChanged(); diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java index bbf967310ca3f..034e96a13029f 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewAppearance.java @@ -64,6 +64,7 @@ class MenuViewAppearance { private int mElevation; private float[] mRadii; private Drawable mBackgroundDrawable; + private String mContentDescription; @IntDef({ SMALL, @@ -108,6 +109,8 @@ class MenuViewAppearance { final Drawable drawable = mRes.getDrawable(R.drawable.accessibility_floating_menu_background); mBackgroundDrawable = new InstantInsetLayerDrawable(new Drawable[]{drawable}); + mContentDescription = mRes.getString( + com.android.internal.R.string.accessibility_select_shortcut_menu_title); } void setSizeType(int sizeType) { @@ -157,6 +160,10 @@ class MenuViewAppearance { + draggableBounds.height() * mPercentagePosition.getPercentageY()); } + String getContentDescription() { + return mContentDescription; + } + Drawable getMenuBackground() { return mBackgroundDrawable; } @@ -193,6 +200,15 @@ class MenuViewAppearance { return new int[]{left, 0, right, 0}; } + int[] getMenuMovingStateInsets() { + return new int[]{0, 0, 0, 0}; + } + + float[] getMenuMovingStateRadii() { + final float radius = getMenuRadius(mTargetFeaturesSize); + return new float[]{radius, radius, radius, radius, radius, radius, radius, radius}; + } + int getMenuStrokeWidth() { return mStrokeWidth; } 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 262f43b5bccb2..5252519e9faf4 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewLayer.java @@ -19,6 +19,7 @@ package com.android.systemui.accessibility.floatingmenu; import android.annotation.IntDef; import android.annotation.SuppressLint; import android.content.Context; +import android.view.MotionEvent; import android.view.WindowManager; import android.widget.FrameLayout; @@ -53,6 +54,15 @@ class MenuViewLayer extends FrameLayout { addView(mMenuView, LayerIndex.MENU_VIEW); } + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (mMenuView.maybeMoveOutEdgeAndShow((int) event.getX(), (int) event.getY())) { + return true; + } + + return super.onInterceptTouchEvent(event); + } + @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); 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 f8fd4f5997963..e8a2b6e8767ba 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuViewModel.java @@ -33,6 +33,8 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { private final MutableLiveData> mTargetFeaturesData = new MutableLiveData<>(); private final MutableLiveData mSizeTypeData = new MutableLiveData<>(); + private final MutableLiveData mFadeEffectInfoData = + new MutableLiveData<>(); private final MutableLiveData mPercentagePositionData = new MutableLiveData<>(); private final MenuInfoRepository mInfoRepository; @@ -50,6 +52,15 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { mSizeTypeData.setValue(newSizeType); } + @Override + public void onFadeEffectInfoChanged(MenuFadeEffectInfo fadeEffectInfo) { + mFadeEffectInfoData.setValue(fadeEffectInfo); + } + + void updateMenuSavingPosition(Position percentagePosition) { + mInfoRepository.updateMenuSavingPosition(percentagePosition); + } + LiveData getPercentagePositionData() { mInfoRepository.loadMenuPosition(mPercentagePositionData::setValue); return mPercentagePositionData; @@ -60,6 +71,11 @@ class MenuViewModel implements MenuInfoRepository.OnSettingsContentsChanged { return mSizeTypeData; } + LiveData getFadeEffectInfoData() { + mInfoRepository.loadMenuFadeEffectInfo(mFadeEffectInfoData::setValue); + return mFadeEffectInfoData; + } + LiveData> getTargetFeaturesData() { mInfoRepository.loadMenuTargetFeatures(mTargetFeaturesData::setValue); return mTargetFeaturesData; diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java index d8b10e04705eb..e62a3295a7e27 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java @@ -17,6 +17,7 @@ package com.android.systemui.accessibility.floatingmenu; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.any; import static org.mockito.Mockito.verify; import android.testing.AndroidTestingRunner; @@ -25,6 +26,7 @@ import androidx.test.filters.SmallTest; import com.android.systemui.SysuiTestCase; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -42,13 +44,24 @@ public class MenuInfoRepositoryTest extends SysuiTestCase { @Mock private MenuInfoRepository.OnSettingsContentsChanged mMockSettingsContentsChanged; + private MenuInfoRepository mMenuInfoRepository; + + @Before + public void setUp() { + mMenuInfoRepository = new MenuInfoRepository(mContext, mMockSettingsContentsChanged); + } + @Test public void menuSizeTypeChanged_verifyOnSizeTypeChanged() { - final MenuInfoRepository menuInfoRepository = - new MenuInfoRepository(mContext, mMockSettingsContentsChanged); - - menuInfoRepository.mMenuSizeContentObserver.onChange(true); + mMenuInfoRepository.mMenuSizeContentObserver.onChange(true); verify(mMockSettingsContentsChanged).onSizeTypeChanged(anyInt()); } + + @Test + public void menuOpacityChanged_verifyOnFadeEffectChanged() { + mMenuInfoRepository.mMenuFadeOutContentObserver.onChange(true); + + verify(mMockSettingsContentsChanged).onFadeEffectInfoChanged(any(MenuFadeEffectInfo.class)); + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java new file mode 100644 index 0000000000000..bf6d574a0f677 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2022 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.accessibility.floatingmenu; + +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS; +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.graphics.Rect; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityNodeInfo; + +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; +import androidx.test.filters.SmallTest; + +import com.android.systemui.R; +import com.android.systemui.SysuiTestCase; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Tests for {@link MenuItemAccessibilityDelegate}. */ +@SmallTest +@TestableLooper.RunWithLooper +@RunWith(AndroidTestingRunner.class) +public class MenuItemAccessibilityDelegateTest extends SysuiTestCase { + @Rule + public MockitoRule mockito = MockitoJUnit.rule(); + + private RecyclerView mStubListView; + private MenuView mMenuView; + private MenuItemAccessibilityDelegate mMenuItemAccessibilityDelegate; + private MenuAnimationController mMenuAnimationController; + private final Rect mDraggableBounds = new Rect(100, 200, 300, 400); + + @Before + public void setUp() { + final WindowManager stubWindowManager = mContext.getSystemService(WindowManager.class); + final MenuViewAppearance stubMenuViewAppearance = new MenuViewAppearance(mContext, + stubWindowManager); + final MenuViewModel stubMenuViewModel = new MenuViewModel(mContext); + + final int halfScreenHeight = + stubWindowManager.getCurrentWindowMetrics().getBounds().height() / 2; + mMenuView = spy(new MenuView(mContext, stubMenuViewModel, stubMenuViewAppearance)); + mMenuView.setTranslationY(halfScreenHeight); + + doReturn(mDraggableBounds).when(mMenuView).getMenuDraggableBounds(); + mStubListView = new RecyclerView(mContext); + mMenuAnimationController = spy(new MenuAnimationController(mMenuView)); + mMenuItemAccessibilityDelegate = + new MenuItemAccessibilityDelegate(new RecyclerViewAccessibilityDelegate( + mStubListView), mMenuAnimationController); + } + + @Test + public void getAccessibilityActionList_matchSize() { + final AccessibilityNodeInfoCompat info = + new AccessibilityNodeInfoCompat(new AccessibilityNodeInfo()); + + mMenuItemAccessibilityDelegate.onInitializeAccessibilityNodeInfo(mStubListView, info); + + assertThat(info.getActionList().size()).isEqualTo(5); + } + + @Test + public void performMoveTopLeftAction_matchPosition() { + final boolean moveTopLeftAction = + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + R.id.action_move_top_left, + null); + + assertThat(moveTopLeftAction).isTrue(); + assertThat(mMenuView.getTranslationX()).isEqualTo(mDraggableBounds.left); + assertThat(mMenuView.getTranslationY()).isEqualTo(mDraggableBounds.top); + } + + @Test + public void performMoveTopRightAction_matchPosition() { + final boolean moveTopRightAction = + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + R.id.action_move_top_right, null); + + assertThat(moveTopRightAction).isTrue(); + assertThat(mMenuView.getTranslationX()).isEqualTo(mDraggableBounds.right); + assertThat(mMenuView.getTranslationY()).isEqualTo(mDraggableBounds.top); + } + + @Test + public void performMoveBottomLeftAction_matchPosition() { + final boolean moveBottomLeftAction = + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + R.id.action_move_bottom_left, null); + + assertThat(moveBottomLeftAction).isTrue(); + assertThat(mMenuView.getTranslationX()).isEqualTo(mDraggableBounds.left); + assertThat(mMenuView.getTranslationY()).isEqualTo(mDraggableBounds.bottom); + } + + @Test + public void performMoveBottomRightAction_matchPosition() { + final boolean moveBottomRightAction = + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + R.id.action_move_bottom_right, null); + + assertThat(moveBottomRightAction).isTrue(); + assertThat(mMenuView.getTranslationX()).isEqualTo(mDraggableBounds.right); + assertThat(mMenuView.getTranslationY()).isEqualTo(mDraggableBounds.bottom); + } + + @Test + public void performMoveToEdgeAndHideAction_success() { + final boolean moveToEdgeAndHideAction = + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + R.id.action_move_to_edge_and_hide, null); + + assertThat(moveToEdgeAndHideAction).isTrue(); + verify(mMenuAnimationController).moveToEdgeAndHide(); + } + + @Test + public void performMoveOutFromEdgeAction_success() { + final boolean moveOutEdgeAndShowAction = + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + R.id.action_move_out_edge_and_show, null); + + assertThat(moveOutEdgeAndShowAction).isTrue(); + verify(mMenuAnimationController).moveOutEdgeAndShow(); + } + + @Test + public void performFocusAction_fadeIn() { + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + ACTION_ACCESSIBILITY_FOCUS, null); + + verify(mMenuAnimationController).fadeInNowIfEnabled(); + } + + @Test + public void performClearFocusAction_fadeOut() { + mMenuItemAccessibilityDelegate.performAccessibilityAction(mStubListView, + ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); + + verify(mMenuAnimationController).fadeOutIfEnabled(); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java new file mode 100644 index 0000000000000..c5b9a294fc341 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2022 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.accessibility.floatingmenu; + +import static android.view.View.OVER_SCROLL_NEVER; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.view.MotionEvent; +import android.view.WindowManager; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.filters.SmallTest; + +import com.android.internal.accessibility.dialog.AccessibilityTarget; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.accessibility.MotionEventHelper; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Tests for {@link MenuListViewTouchHandler}. */ +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +@SmallTest +public class MenuListViewTouchHandlerTest extends SysuiTestCase { + private final List mStubTargets = new ArrayList<>( + Collections.singletonList(mock(AccessibilityTarget.class))); + private final MotionEventHelper mMotionEventHelper = new MotionEventHelper(); + private MenuView mStubMenuView; + private MenuListViewTouchHandler mTouchHandler; + private MenuAnimationController mMenuAnimationController; + private RecyclerView mStubListView; + + @Before + public void setUp() throws Exception { + final WindowManager windowManager = mContext.getSystemService(WindowManager.class); + final MenuViewModel stubMenuViewModel = new MenuViewModel(mContext); + final MenuViewAppearance stubMenuViewAppearance = new MenuViewAppearance(mContext, + windowManager); + mStubMenuView = new MenuView(mContext, stubMenuViewModel, stubMenuViewAppearance); + mStubMenuView.setTranslationX(0); + mStubMenuView.setTranslationY(0); + mMenuAnimationController = spy(new MenuAnimationController(mStubMenuView)); + mTouchHandler = new MenuListViewTouchHandler(mMenuAnimationController); + final AccessibilityTargetAdapter stubAdapter = new AccessibilityTargetAdapter(mStubTargets); + mStubListView = (RecyclerView) mStubMenuView.getChildAt(0); + mStubListView.setAdapter(stubAdapter); + } + + @Test + public void onActionDownEvent_shouldCancelAnimations() { + final MotionEvent stubDownEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 1, + MotionEvent.ACTION_DOWN, mStubMenuView.getTranslationX(), + mStubMenuView.getTranslationY()); + + mTouchHandler.onInterceptTouchEvent(mStubListView, stubDownEvent); + + verify(mMenuAnimationController).cancelAnimations(); + } + + @Test + public void onActionMoveEvent_shouldMoveToPosition() { + final int offset = 100; + final MotionEvent stubDownEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 1, + MotionEvent.ACTION_DOWN, mStubMenuView.getTranslationX(), + mStubMenuView.getTranslationY()); + final MotionEvent stubMoveEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 3, + MotionEvent.ACTION_MOVE, mStubMenuView.getTranslationX() + offset, + mStubMenuView.getTranslationY() + offset); + mStubListView.setOverScrollMode(OVER_SCROLL_NEVER); + + mTouchHandler.onInterceptTouchEvent(mStubListView, stubDownEvent); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubMoveEvent); + + assertThat(mStubMenuView.getTranslationX()).isEqualTo(offset); + assertThat(mStubMenuView.getTranslationY()).isEqualTo(offset); + } + + @Test + public void dragAndDrop_shouldFlingMenuThenSpringToEdge() { + final int offset = 100; + final MotionEvent stubDownEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 1, + MotionEvent.ACTION_DOWN, mStubMenuView.getTranslationX(), + mStubMenuView.getTranslationY()); + final MotionEvent stubMoveEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 3, + MotionEvent.ACTION_MOVE, mStubMenuView.getTranslationX() + offset, + mStubMenuView.getTranslationY() + offset); + final MotionEvent stubUpEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 5, + MotionEvent.ACTION_UP, mStubMenuView.getTranslationX() + offset, + mStubMenuView.getTranslationY() + offset); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubDownEvent); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubMoveEvent); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubUpEvent); + + verify(mMenuAnimationController).flingMenuThenSpringToEdge(anyFloat(), anyFloat(), + anyFloat()); + } + + @Test + public void dragMenuOutOfBoundsAndDrop_moveToLeftEdge_shouldMoveToEdgeAndHide() { + final int offset = -100; + final MotionEvent stubDownEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 1, + MotionEvent.ACTION_DOWN, mStubMenuView.getTranslationX(), + mStubMenuView.getTranslationY()); + final MotionEvent stubMoveEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 3, + MotionEvent.ACTION_MOVE, mStubMenuView.getTranslationX() + offset, + mStubMenuView.getTranslationY() + offset); + final MotionEvent stubUpEvent = + mMotionEventHelper.obtainMotionEvent(/* downTime= */ 0, /* eventTime= */ 5, + MotionEvent.ACTION_UP, mStubMenuView.getTranslationX() + offset, + mStubMenuView.getTranslationY() + offset); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubDownEvent); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubMoveEvent); + mTouchHandler.onInterceptTouchEvent(mStubListView, stubUpEvent); + + verify(mMenuAnimationController).moveToEdgeAndHide(); + } + + @After + public void tearDown() { + mMotionEventHelper.recycleEvents(); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java index 6549de15dad29..742ee53e99b6e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java @@ -24,12 +24,15 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import android.app.UiModeManager; +import android.graphics.Rect; +import android.graphics.drawable.GradientDrawable; import android.testing.AndroidTestingRunner; import android.testing.TestableLooper; import android.view.WindowManager; import androidx.test.filters.SmallTest; +import com.android.systemui.Prefs; import com.android.systemui.SysuiTestCase; import org.junit.After; @@ -46,6 +49,8 @@ public class MenuViewTest extends SysuiTestCase { private int mNightMode; private UiModeManager mUiModeManager; private MenuView mMenuView; + private String mLastPosition; + private MenuViewAppearance mStubMenuViewAppearance; @Before public void setUp() throws Exception { @@ -54,9 +59,10 @@ public class MenuViewTest extends SysuiTestCase { mUiModeManager.setNightMode(MODE_NIGHT_YES); final MenuViewModel stubMenuViewModel = new MenuViewModel(mContext); final WindowManager stubWindowManager = mContext.getSystemService(WindowManager.class); - final MenuViewAppearance stubMenuViewAppearance = new MenuViewAppearance(mContext, - stubWindowManager); - mMenuView = spy(new MenuView(mContext, stubMenuViewModel, stubMenuViewAppearance)); + mStubMenuViewAppearance = new MenuViewAppearance(mContext, stubWindowManager); + mMenuView = spy(new MenuView(mContext, stubMenuViewModel, mStubMenuViewAppearance)); + mLastPosition = Prefs.getString(mContext, + Prefs.Key.ACCESSIBILITY_FLOATING_MENU_POSITION, /* defaultValue= */ null); } @Test @@ -77,8 +83,58 @@ public class MenuViewTest extends SysuiTestCase { assertThat(areInsetsMatched).isTrue(); } + @Test + public void onDraggingStart_matchInsets() { + mMenuView.onDraggingStart(); + final InstantInsetLayerDrawable insetLayerDrawable = + (InstantInsetLayerDrawable) mMenuView.getBackground(); + + assertThat(insetLayerDrawable.getLayerInsetLeft(INDEX_MENU_ITEM)).isEqualTo(0); + assertThat(insetLayerDrawable.getLayerInsetTop(INDEX_MENU_ITEM)).isEqualTo(0); + assertThat(insetLayerDrawable.getLayerInsetRight(INDEX_MENU_ITEM)).isEqualTo(0); + assertThat(insetLayerDrawable.getLayerInsetBottom(INDEX_MENU_ITEM)).isEqualTo(0); + } + + @Test + public void onAnimationend_updatePositionForSharedPreference() { + final float percentageX = 0.0f; + final float percentageY = 0.5f; + + mMenuView.persistPositionAndUpdateEdge(new Position(percentageX, percentageY)); + final String positionString = Prefs.getString(mContext, + Prefs.Key.ACCESSIBILITY_FLOATING_MENU_POSITION, /* defaultValue= */ null); + final Position position = Position.fromString(positionString); + + assertThat(position.getPercentageX()).isEqualTo(percentageX); + assertThat(position.getPercentageY()).isEqualTo(percentageY); + } + + @Test + public void onEdgeChangedIfNeeded_moveToLeftEdge_matchRadii() { + final Rect draggableBounds = mStubMenuViewAppearance.getMenuDraggableBounds(); + mMenuView.setTranslationX(draggableBounds.right); + + mMenuView.setTranslationX(draggableBounds.left); + mMenuView.onEdgeChangedIfNeeded(); + final float[] radii = getMenuViewGradient().getCornerRadii(); + + assertThat(radii[0]).isEqualTo(0.0f); + assertThat(radii[1]).isEqualTo(0.0f); + assertThat(radii[6]).isEqualTo(0.0f); + assertThat(radii[7]).isEqualTo(0.0f); + } + + private InstantInsetLayerDrawable getMenuViewInsetLayer() { + return (InstantInsetLayerDrawable) mMenuView.getBackground(); + } + + private GradientDrawable getMenuViewGradient() { + return (GradientDrawable) getMenuViewInsetLayer().getDrawable(INDEX_MENU_ITEM); + } + @After public void tearDown() throws Exception { mUiModeManager.setNightMode(mNightMode); + Prefs.putString(mContext, Prefs.Key.ACCESSIBILITY_FLOATING_MENU_POSITION, mLastPosition); } }