• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.accessibility.floatingmenu;
18 
19 import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
20 import static android.util.MathUtils.constrain;
21 import static android.util.MathUtils.sq;
22 import static android.view.WindowInsets.Type.displayCutout;
23 import static android.view.WindowInsets.Type.ime;
24 import static android.view.WindowInsets.Type.systemBars;
25 import static android.view.WindowManager.LayoutParams.PRIVATE_FLAG_EXCLUDE_FROM_SCREEN_MAGNIFICATION;
26 
27 import static java.util.Objects.requireNonNull;
28 
29 import android.animation.Animator;
30 import android.animation.AnimatorListenerAdapter;
31 import android.animation.ValueAnimator;
32 import android.annotation.FloatRange;
33 import android.annotation.IntDef;
34 import android.content.Context;
35 import android.content.pm.ActivityInfo;
36 import android.content.res.Configuration;
37 import android.content.res.Resources;
38 import android.graphics.Insets;
39 import android.graphics.PixelFormat;
40 import android.graphics.Rect;
41 import android.graphics.drawable.Drawable;
42 import android.graphics.drawable.GradientDrawable;
43 import android.graphics.drawable.LayerDrawable;
44 import android.os.Handler;
45 import android.os.Looper;
46 import android.view.Gravity;
47 import android.view.MotionEvent;
48 import android.view.ViewConfiguration;
49 import android.view.ViewGroup;
50 import android.view.WindowInsets;
51 import android.view.WindowManager;
52 import android.view.WindowMetrics;
53 import android.view.animation.Animation;
54 import android.view.animation.OvershootInterpolator;
55 import android.view.animation.TranslateAnimation;
56 import android.widget.FrameLayout;
57 
58 import androidx.annotation.DimenRes;
59 import androidx.annotation.NonNull;
60 import androidx.core.view.AccessibilityDelegateCompat;
61 import androidx.recyclerview.widget.LinearLayoutManager;
62 import androidx.recyclerview.widget.RecyclerView;
63 import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate;
64 
65 import com.android.internal.accessibility.dialog.AccessibilityTarget;
66 import com.android.internal.annotations.VisibleForTesting;
67 import com.android.systemui.R;
68 
69 import java.lang.annotation.Retention;
70 import java.lang.annotation.RetentionPolicy;
71 import java.util.ArrayList;
72 import java.util.Collections;
73 import java.util.List;
74 import java.util.Optional;
75 
76 /**
77  * Accessibility floating menu is used for the actions of accessibility features, it's also the
78  * action set.
79  *
80  * <p>The number of items would depend on strings key
81  * {@link android.provider.Settings.Secure#ACCESSIBILITY_BUTTON_TARGETS}.
82  */
83 public class AccessibilityFloatingMenuView extends FrameLayout
84         implements RecyclerView.OnItemTouchListener {
85     private static final int INDEX_MENU_ITEM = 0;
86     private static final int FADE_OUT_DURATION_MS = 1000;
87     private static final int FADE_EFFECT_DURATION_MS = 3000;
88     private static final int SNAP_TO_LOCATION_DURATION_MS = 150;
89     private static final int MIN_WINDOW_Y = 0;
90 
91     private static final int ANIMATION_START_OFFSET = 600;
92     private static final int ANIMATION_DURATION_MS = 600;
93     private static final float ANIMATION_TO_X_VALUE = 0.5f;
94 
95     private boolean mIsFadeEffectEnabled;
96     private boolean mIsShowing;
97     private boolean mIsDownInEnlargedTouchArea;
98     private boolean mIsDragging = false;
99     @Alignment
100     private int mAlignment;
101     @SizeType
102     private int mSizeType = SizeType.SMALL;
103     @VisibleForTesting
104     @ShapeType
105     int mShapeType = ShapeType.OVAL;
106     private int mTemporaryShapeType;
107     @RadiusType
108     private int mRadiusType;
109     private int mMargin;
110     private int mPadding;
111     // The display width excludes the window insets of the system bar and display cutout.
112     private int mDisplayHeight;
113     // The display Height excludes the window insets of the system bar and display cutout.
114     private int mDisplayWidth;
115     private int mIconWidth;
116     private int mIconHeight;
117     private int mInset;
118     private int mDownX;
119     private int mDownY;
120     private int mRelativeToPointerDownX;
121     private int mRelativeToPointerDownY;
122     private float mRadius;
123     private final Rect mDisplayInsetsRect = new Rect();
124     private final Rect mImeInsetsRect = new Rect();
125     private final Position mPosition;
126     private float mSquareScaledTouchSlop;
127     private final Configuration mLastConfiguration;
128     private Optional<OnDragEndListener> mOnDragEndListener = Optional.empty();
129     private final RecyclerView mListView;
130     private final AccessibilityTargetAdapter mAdapter;
131     private float mFadeOutValue;
132     private final ValueAnimator mFadeOutAnimator;
133     @VisibleForTesting
134     final ValueAnimator mDragAnimator;
135     private final Handler mUiHandler;
136     @VisibleForTesting
137     final WindowManager.LayoutParams mCurrentLayoutParams;
138     private final WindowManager mWindowManager;
139     private final List<AccessibilityTarget> mTargets = new ArrayList<>();
140 
141     @IntDef({
142             SizeType.SMALL,
143             SizeType.LARGE
144     })
145     @Retention(RetentionPolicy.SOURCE)
146     @interface SizeType {
147         int SMALL = 0;
148         int LARGE = 1;
149     }
150 
151     @IntDef({
152             ShapeType.OVAL,
153             ShapeType.HALF_OVAL
154     })
155     @Retention(RetentionPolicy.SOURCE)
156     @interface ShapeType {
157         int OVAL = 0;
158         int HALF_OVAL = 1;
159     }
160 
161     @IntDef({
162             RadiusType.LEFT_HALF_OVAL,
163             RadiusType.OVAL,
164             RadiusType.RIGHT_HALF_OVAL
165     })
166     @Retention(RetentionPolicy.SOURCE)
167     @interface RadiusType {
168         int LEFT_HALF_OVAL = 0;
169         int OVAL = 1;
170         int RIGHT_HALF_OVAL = 2;
171     }
172 
173     @IntDef({
174             Alignment.LEFT,
175             Alignment.RIGHT
176     })
177     @Retention(RetentionPolicy.SOURCE)
178     @interface Alignment {
179         int LEFT = 0;
180         int RIGHT = 1;
181     }
182 
183     /**
184      * Interface for a callback to be invoked when the floating menu was dragging.
185      */
186     interface OnDragEndListener {
187 
188         /**
189          * Called when a drag is completed.
190          *
191          * @param position Stores information about the position
192          */
onDragEnd(Position position)193         void onDragEnd(Position position);
194     }
195 
AccessibilityFloatingMenuView(Context context, @NonNull Position position)196     public AccessibilityFloatingMenuView(Context context, @NonNull Position position) {
197         this(context, position, new RecyclerView(context));
198     }
199 
200     @VisibleForTesting
AccessibilityFloatingMenuView(Context context, @NonNull Position position, RecyclerView listView)201     AccessibilityFloatingMenuView(Context context, @NonNull Position position,
202             RecyclerView listView) {
203         super(context);
204 
205         mListView = listView;
206         mWindowManager = context.getSystemService(WindowManager.class);
207         mLastConfiguration = new Configuration(getResources().getConfiguration());
208         mAdapter = new AccessibilityTargetAdapter(mTargets);
209         mUiHandler = createUiHandler();
210         mPosition = position;
211         mAlignment = transformToAlignment(mPosition.getPercentageX());
212         mRadiusType = (mAlignment == Alignment.RIGHT)
213                 ? RadiusType.LEFT_HALF_OVAL
214                 : RadiusType.RIGHT_HALF_OVAL;
215 
216         updateDimensions();
217 
218         mCurrentLayoutParams = createDefaultLayoutParams();
219 
220         mFadeOutAnimator = ValueAnimator.ofFloat(1.0f, mFadeOutValue);
221         mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS);
222         mFadeOutAnimator.addUpdateListener(
223                 (animation) -> setAlpha((float) animation.getAnimatedValue()));
224 
225         mDragAnimator = ValueAnimator.ofFloat(0.0f, 1.0f);
226         mDragAnimator.setDuration(SNAP_TO_LOCATION_DURATION_MS);
227         mDragAnimator.setInterpolator(new OvershootInterpolator());
228         mDragAnimator.addListener(new AnimatorListenerAdapter() {
229             @Override
230             public void onAnimationEnd(Animator animation) {
231                 mPosition.update(transformCurrentPercentageXToEdge(),
232                         calculateCurrentPercentageY());
233                 mAlignment = transformToAlignment(mPosition.getPercentageX());
234 
235                 updateLocationWith(mPosition);
236 
237                 updateInsetWith(getResources().getConfiguration().uiMode, mAlignment);
238 
239                 mRadiusType = (mAlignment == Alignment.RIGHT)
240                         ? RadiusType.LEFT_HALF_OVAL
241                         : RadiusType.RIGHT_HALF_OVAL;
242                 updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
243 
244                 fadeOut();
245 
246                 mOnDragEndListener.ifPresent(
247                         onDragEndListener -> onDragEndListener.onDragEnd(mPosition));
248             }
249         });
250 
251 
252         initListView();
253         updateStrokeWith(getResources().getConfiguration().uiMode, mAlignment);
254     }
255 
256     @Override
onInterceptTouchEvent(@onNull RecyclerView recyclerView, @NonNull MotionEvent event)257     public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView,
258             @NonNull MotionEvent event) {
259         final int currentRawX = (int) event.getRawX();
260         final int currentRawY = (int) event.getRawY();
261 
262         switch (event.getAction()) {
263             case MotionEvent.ACTION_DOWN:
264                 fadeIn();
265 
266                 mDownX = currentRawX;
267                 mDownY = currentRawY;
268                 mRelativeToPointerDownX = mCurrentLayoutParams.x - mDownX;
269                 mRelativeToPointerDownY = mCurrentLayoutParams.y - mDownY;
270                 mListView.animate().translationX(0);
271                 break;
272             case MotionEvent.ACTION_MOVE:
273                 if (mIsDragging
274                         || hasExceededTouchSlop(mDownX, mDownY, currentRawX, currentRawY)) {
275                     if (!mIsDragging) {
276                         mIsDragging = true;
277                         setRadius(mRadius, RadiusType.OVAL);
278                         setInset(0, 0);
279                     }
280 
281                     mTemporaryShapeType =
282                             isMovingTowardsScreenEdge(mAlignment, currentRawX, mDownX)
283                                     ? ShapeType.HALF_OVAL
284                                     : ShapeType.OVAL;
285                     final int newWindowX = currentRawX + mRelativeToPointerDownX;
286                     final int newWindowY = currentRawY + mRelativeToPointerDownY;
287                     mCurrentLayoutParams.x =
288                             constrain(newWindowX, getMinWindowX(), getMaxWindowX());
289                     mCurrentLayoutParams.y = constrain(newWindowY, MIN_WINDOW_Y, getMaxWindowY());
290                     mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
291                 }
292                 break;
293             case MotionEvent.ACTION_UP:
294             case MotionEvent.ACTION_CANCEL:
295                 if (mIsDragging) {
296                     mIsDragging = false;
297 
298                     final int minX = getMinWindowX();
299                     final int maxX = getMaxWindowX();
300                     final int endX = mCurrentLayoutParams.x > ((minX + maxX) / 2)
301                             ? maxX : minX;
302                     final int endY = mCurrentLayoutParams.y;
303                     snapToLocation(endX, endY);
304 
305                     setShapeType(mTemporaryShapeType);
306 
307                     // Avoid triggering the listener of the item.
308                     return true;
309                 }
310 
311                 // Must switch the oval shape type before tapping the corresponding item in the
312                 // list view, otherwise it can't work on it.
313                 if (!isOvalShape()) {
314                     setShapeType(ShapeType.OVAL);
315 
316                     return true;
317                 }
318 
319                 fadeOut();
320                 break;
321             default: // Do nothing
322         }
323 
324         // not consume all the events here because keeping the scroll behavior of list view.
325         return false;
326     }
327 
328     @Override
onTouchEvent(@onNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent)329     public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
330         // Do Nothing
331     }
332 
333     @Override
onRequestDisallowInterceptTouchEvent(boolean b)334     public void onRequestDisallowInterceptTouchEvent(boolean b) {
335         // Do Nothing
336     }
337 
show()338     void show() {
339         if (isShowing()) {
340             return;
341         }
342 
343         mIsShowing = true;
344         mWindowManager.addView(this, mCurrentLayoutParams);
345 
346         setOnApplyWindowInsetsListener((view, insets) -> onWindowInsetsApplied(insets));
347         setSystemGestureExclusion();
348     }
349 
hide()350     void hide() {
351         if (!isShowing()) {
352             return;
353         }
354 
355         mIsShowing = false;
356         mDragAnimator.cancel();
357         mWindowManager.removeView(this);
358 
359         setOnApplyWindowInsetsListener(null);
360         setSystemGestureExclusion();
361     }
362 
isShowing()363     boolean isShowing() {
364         return mIsShowing;
365     }
366 
isOvalShape()367     boolean isOvalShape() {
368         return mShapeType == ShapeType.OVAL;
369     }
370 
onTargetsChanged(List<AccessibilityTarget> newTargets)371     void onTargetsChanged(List<AccessibilityTarget> newTargets) {
372         fadeIn();
373 
374         mTargets.clear();
375         mTargets.addAll(newTargets);
376         onEnabledFeaturesChanged();
377 
378         updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
379         updateScrollModeWith(hasExceededMaxLayoutHeight());
380         setSystemGestureExclusion();
381 
382         fadeOut();
383     }
384 
setSizeType(@izeType int newSizeType)385     void setSizeType(@SizeType int newSizeType) {
386         fadeIn();
387 
388         mSizeType = newSizeType;
389 
390         updateItemViewWith(newSizeType);
391         updateRadiusWith(newSizeType, mRadiusType, mTargets.size());
392 
393         // When the icon sized changed, the menu size and location will be impacted.
394         updateLocationWith(mPosition);
395         updateScrollModeWith(hasExceededMaxLayoutHeight());
396         updateOffsetWith(mShapeType, mAlignment);
397         setSystemGestureExclusion();
398 
399         fadeOut();
400     }
401 
setShapeType(@hapeType int newShapeType)402     void setShapeType(@ShapeType int newShapeType) {
403         fadeIn();
404 
405         mShapeType = newShapeType;
406 
407         updateOffsetWith(newShapeType, mAlignment);
408 
409         setOnTouchListener(
410                 newShapeType == ShapeType.OVAL
411                         ? null
412                         : (view, event) -> onTouched(event));
413 
414         fadeOut();
415     }
416 
setOnDragEndListener(OnDragEndListener onDragEndListener)417     public void setOnDragEndListener(OnDragEndListener onDragEndListener) {
418         mOnDragEndListener = Optional.ofNullable(onDragEndListener);
419     }
420 
startTranslateXAnimation()421     void startTranslateXAnimation() {
422         fadeIn();
423 
424         final float toXValue = (mAlignment == Alignment.RIGHT)
425                 ? ANIMATION_TO_X_VALUE
426                 : -ANIMATION_TO_X_VALUE;
427         final TranslateAnimation animation =
428                 new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0,
429                         Animation.RELATIVE_TO_SELF, toXValue,
430                         Animation.RELATIVE_TO_SELF, 0,
431                         Animation.RELATIVE_TO_SELF, 0);
432         animation.setDuration(ANIMATION_DURATION_MS);
433         animation.setRepeatMode(Animation.REVERSE);
434         animation.setInterpolator(new OvershootInterpolator());
435         animation.setRepeatCount(Animation.INFINITE);
436         animation.setStartOffset(ANIMATION_START_OFFSET);
437         mListView.startAnimation(animation);
438     }
439 
stopTranslateXAnimation()440     void stopTranslateXAnimation() {
441         mListView.clearAnimation();
442 
443         fadeOut();
444     }
445 
getWindowLocationOnScreen()446     Rect getWindowLocationOnScreen() {
447         final int left = mCurrentLayoutParams.x;
448         final int top = mCurrentLayoutParams.y;
449         return new Rect(left, top, left + getWindowWidth(), top + getWindowHeight());
450     }
451 
updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue)452     void updateOpacityWith(boolean isFadeEffectEnabled, float newOpacityValue) {
453         mIsFadeEffectEnabled = isFadeEffectEnabled;
454         mFadeOutValue = newOpacityValue;
455 
456         mFadeOutAnimator.cancel();
457         mFadeOutAnimator.setFloatValues(1.0f, mFadeOutValue);
458         setAlpha(mIsFadeEffectEnabled ? mFadeOutValue : /* completely opaque */ 1.0f);
459     }
460 
onEnabledFeaturesChanged()461     void onEnabledFeaturesChanged() {
462         mAdapter.notifyDataSetChanged();
463     }
464 
465     @VisibleForTesting
fadeIn()466     void fadeIn() {
467         if (!mIsFadeEffectEnabled) {
468             return;
469         }
470 
471         mFadeOutAnimator.cancel();
472         mUiHandler.removeCallbacksAndMessages(null);
473         mUiHandler.post(() -> setAlpha(/* completely opaque */ 1.0f));
474     }
475 
476     @VisibleForTesting
fadeOut()477     void fadeOut() {
478         if (!mIsFadeEffectEnabled) {
479             return;
480         }
481 
482         mUiHandler.postDelayed(() -> mFadeOutAnimator.start(), FADE_EFFECT_DURATION_MS);
483     }
484 
onTouched(MotionEvent event)485     private boolean onTouched(MotionEvent event) {
486         final int action = event.getAction();
487         final int currentX = (int) event.getX();
488         final int currentY = (int) event.getY();
489 
490         final int marginStartEnd = getMarginStartEndWith(mLastConfiguration);
491         final Rect touchDelegateBounds =
492                 new Rect(marginStartEnd, mMargin, marginStartEnd + getLayoutWidth(),
493                         mMargin + getLayoutHeight());
494         if (action == MotionEvent.ACTION_DOWN
495                 && touchDelegateBounds.contains(currentX, currentY)) {
496             mIsDownInEnlargedTouchArea = true;
497         }
498 
499         if (!mIsDownInEnlargedTouchArea) {
500             return false;
501         }
502 
503         if (action == MotionEvent.ACTION_UP
504                 || action == MotionEvent.ACTION_CANCEL) {
505             mIsDownInEnlargedTouchArea = false;
506         }
507 
508         // In order to correspond to the correct item of list view.
509         event.setLocation(currentX - mMargin, currentY - mMargin);
510         return mListView.dispatchTouchEvent(event);
511     }
512 
onWindowInsetsApplied(WindowInsets insets)513     private WindowInsets onWindowInsetsApplied(WindowInsets insets) {
514         final WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics();
515         final Rect displayWindowInsetsRect = getDisplayInsets(windowMetrics).toRect();
516         if (!displayWindowInsetsRect.equals(mDisplayInsetsRect)) {
517             updateDisplaySizeWith(windowMetrics);
518             updateLocationWith(mPosition);
519         }
520 
521         final Rect imeInsetsRect = windowMetrics.getWindowInsets().getInsets(ime()).toRect();
522         if (!imeInsetsRect.equals(mImeInsetsRect)) {
523             if (isImeVisible(imeInsetsRect)) {
524                 mImeInsetsRect.set(imeInsetsRect);
525             } else {
526                 mImeInsetsRect.setEmpty();
527             }
528 
529             updateLocationWith(mPosition);
530         }
531 
532         return insets;
533     }
534 
isMovingTowardsScreenEdge(@lignment int side, int currentRawX, int downX)535     private boolean isMovingTowardsScreenEdge(@Alignment int side, int currentRawX, int downX) {
536         return (side == Alignment.RIGHT && currentRawX > downX)
537                 || (side == Alignment.LEFT && downX > currentRawX);
538     }
539 
isImeVisible(Rect imeInsetsRect)540     private boolean isImeVisible(Rect imeInsetsRect) {
541         return imeInsetsRect.left != 0 || imeInsetsRect.top != 0 || imeInsetsRect.right != 0
542                 || imeInsetsRect.bottom != 0;
543     }
544 
hasExceededTouchSlop(int startX, int startY, int endX, int endY)545     private boolean hasExceededTouchSlop(int startX, int startY, int endX, int endY) {
546         return (sq(endX - startX) + sq(endY - startY)) > mSquareScaledTouchSlop;
547     }
548 
setRadius(float radius, @RadiusType int type)549     private void setRadius(float radius, @RadiusType int type) {
550         getMenuGradientDrawable().setCornerRadii(createRadii(radius, type));
551     }
552 
createRadii(float radius, @RadiusType int type)553     private float[] createRadii(float radius, @RadiusType int type) {
554         if (type == RadiusType.LEFT_HALF_OVAL) {
555             return new float[]{radius, radius, 0.0f, 0.0f, 0.0f, 0.0f, radius, radius};
556         }
557 
558         if (type == RadiusType.RIGHT_HALF_OVAL) {
559             return new float[]{0.0f, 0.0f, radius, radius, radius, radius, 0.0f, 0.0f};
560         }
561 
562         return new float[]{radius, radius, radius, radius, radius, radius, radius, radius};
563     }
564 
createUiHandler()565     private Handler createUiHandler() {
566         return new Handler(requireNonNull(Looper.myLooper(), "looper must not be null"));
567     }
568 
updateDimensions()569     private void updateDimensions() {
570         final Resources res = getResources();
571 
572         updateDisplaySizeWith(mWindowManager.getCurrentWindowMetrics());
573 
574         mMargin =
575                 res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_margin);
576         mInset =
577                 res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_inset);
578 
579         mSquareScaledTouchSlop =
580                 sq(ViewConfiguration.get(getContext()).getScaledTouchSlop());
581 
582         updateItemViewDimensionsWith(mSizeType);
583     }
584 
updateDisplaySizeWith(WindowMetrics metrics)585     private void updateDisplaySizeWith(WindowMetrics metrics) {
586         final Rect displayBounds = metrics.getBounds();
587         final Insets displayInsets = getDisplayInsets(metrics);
588         mDisplayInsetsRect.set(displayInsets.toRect());
589         displayBounds.inset(displayInsets);
590         mDisplayWidth = displayBounds.width();
591         mDisplayHeight = displayBounds.height();
592     }
593 
updateItemViewDimensionsWith(@izeType int sizeType)594     private void updateItemViewDimensionsWith(@SizeType int sizeType) {
595         final Resources res = getResources();
596         final int paddingResId =
597                 sizeType == SizeType.SMALL
598                         ? R.dimen.accessibility_floating_menu_small_padding
599                         : R.dimen.accessibility_floating_menu_large_padding;
600         mPadding = res.getDimensionPixelSize(paddingResId);
601 
602         final int iconResId =
603                 sizeType == SizeType.SMALL
604                         ? R.dimen.accessibility_floating_menu_small_width_height
605                         : R.dimen.accessibility_floating_menu_large_width_height;
606         mIconWidth = res.getDimensionPixelSize(iconResId);
607         mIconHeight = mIconWidth;
608     }
609 
updateItemViewWith(@izeType int sizeType)610     private void updateItemViewWith(@SizeType int sizeType) {
611         updateItemViewDimensionsWith(sizeType);
612 
613         mAdapter.setItemPadding(mPadding);
614         mAdapter.setIconWidthHeight(mIconWidth);
615         mAdapter.notifyDataSetChanged();
616     }
617 
initListView()618     private void initListView() {
619         final Drawable background =
620                 getContext().getDrawable(R.drawable.accessibility_floating_menu_background);
621         final LinearLayoutManager layoutManager = new LinearLayoutManager(getContext());
622         final LayoutParams layoutParams =
623                 new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
624                         ViewGroup.LayoutParams.WRAP_CONTENT);
625         mListView.setLayoutParams(layoutParams);
626         final InstantInsetLayerDrawable layerDrawable =
627                 new InstantInsetLayerDrawable(new Drawable[]{background});
628         mListView.setBackground(layerDrawable);
629         mListView.setAdapter(mAdapter);
630         mListView.setLayoutManager(layoutManager);
631         mListView.addOnItemTouchListener(this);
632         mListView.animate().setInterpolator(new OvershootInterpolator());
633         mListView.setAccessibilityDelegateCompat(new RecyclerViewAccessibilityDelegate(mListView) {
634             @NonNull
635             @Override
636             public AccessibilityDelegateCompat getItemDelegate() {
637                 return new ItemDelegateCompat(this,
638                         AccessibilityFloatingMenuView.this);
639             }
640         });
641 
642         updateListViewWith(mLastConfiguration);
643 
644         addView(mListView);
645     }
646 
updateListViewWith(Configuration configuration)647     private void updateListViewWith(Configuration configuration) {
648         updateMarginWith(configuration);
649 
650         final int elevation =
651                 getResources().getDimensionPixelSize(R.dimen.accessibility_floating_menu_elevation);
652         mListView.setElevation(elevation);
653     }
654 
createDefaultLayoutParams()655     private WindowManager.LayoutParams createDefaultLayoutParams() {
656         final WindowManager.LayoutParams params = new WindowManager.LayoutParams(
657                 WindowManager.LayoutParams.WRAP_CONTENT,
658                 WindowManager.LayoutParams.WRAP_CONTENT,
659                 WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL,
660                 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
661                         | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
662                 PixelFormat.TRANSLUCENT);
663         params.receiveInsetsIgnoringZOrder = true;
664         params.privateFlags |= PRIVATE_FLAG_EXCLUDE_FROM_SCREEN_MAGNIFICATION;
665         params.windowAnimations = android.R.style.Animation_Translucent;
666         params.gravity = Gravity.START | Gravity.TOP;
667         params.x = (mAlignment == Alignment.RIGHT) ? getMaxWindowX() : getMinWindowX();
668 //        params.y = (int) (mPosition.getPercentageY() * getMaxWindowY());
669         final int currentLayoutY = (int) (mPosition.getPercentageY() * getMaxWindowY());
670         params.y = Math.max(MIN_WINDOW_Y, currentLayoutY - getInterval());
671         updateAccessibilityTitle(params);
672         return params;
673     }
674 
675     @Override
onConfigurationChanged(Configuration newConfig)676     protected void onConfigurationChanged(Configuration newConfig) {
677         super.onConfigurationChanged(newConfig);
678         mLastConfiguration.setTo(newConfig);
679 
680         final int diff = newConfig.diff(mLastConfiguration);
681         if ((diff & ActivityInfo.CONFIG_LOCALE) != 0) {
682             updateAccessibilityTitle(mCurrentLayoutParams);
683         }
684 
685         updateDimensions();
686         updateListViewWith(newConfig);
687         updateItemViewWith(mSizeType);
688         updateColor();
689         updateStrokeWith(newConfig.uiMode, mAlignment);
690         updateLocationWith(mPosition);
691         updateRadiusWith(mSizeType, mRadiusType, mTargets.size());
692         updateScrollModeWith(hasExceededMaxLayoutHeight());
693         setSystemGestureExclusion();
694     }
695 
696     @VisibleForTesting
snapToLocation(int endX, int endY)697     void snapToLocation(int endX, int endY) {
698         mDragAnimator.cancel();
699         mDragAnimator.removeAllUpdateListeners();
700         mDragAnimator.addUpdateListener(anim -> onDragAnimationUpdate(anim, endX, endY));
701         mDragAnimator.start();
702     }
703 
onDragAnimationUpdate(ValueAnimator animator, int endX, int endY)704     private void onDragAnimationUpdate(ValueAnimator animator, int endX, int endY) {
705         float value = (float) animator.getAnimatedValue();
706         final int newX = (int) (((1 - value) * mCurrentLayoutParams.x) + (value * endX));
707         final int newY = (int) (((1 - value) * mCurrentLayoutParams.y) + (value * endY));
708 
709         mCurrentLayoutParams.x = newX;
710         mCurrentLayoutParams.y = newY;
711         mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
712     }
713 
getMinWindowX()714     private int getMinWindowX() {
715         return -getMarginStartEndWith(mLastConfiguration);
716     }
717 
getMaxWindowX()718     private int getMaxWindowX() {
719         return mDisplayWidth - getMarginStartEndWith(mLastConfiguration) - getLayoutWidth();
720     }
721 
getMaxWindowY()722     private int getMaxWindowY() {
723         return mDisplayHeight - getWindowHeight();
724     }
725 
getMenuLayerDrawable()726     private InstantInsetLayerDrawable getMenuLayerDrawable() {
727         return (InstantInsetLayerDrawable) mListView.getBackground();
728     }
729 
getMenuGradientDrawable()730     private GradientDrawable getMenuGradientDrawable() {
731         return (GradientDrawable) getMenuLayerDrawable().getDrawable(INDEX_MENU_ITEM);
732     }
733 
getDisplayInsets(WindowMetrics metrics)734     private Insets getDisplayInsets(WindowMetrics metrics) {
735         return metrics.getWindowInsets().getInsetsIgnoringVisibility(
736                 systemBars() | displayCutout());
737     }
738 
739     /**
740      * Updates the floating menu to be fixed at the side of the display.
741      */
updateLocationWith(Position position)742     private void updateLocationWith(Position position) {
743         final @Alignment int alignment = transformToAlignment(position.getPercentageX());
744         mCurrentLayoutParams.x = (alignment == Alignment.RIGHT) ? getMaxWindowX() : getMinWindowX();
745         final int currentLayoutY = (int) (position.getPercentageY() * getMaxWindowY());
746         mCurrentLayoutParams.y = Math.max(MIN_WINDOW_Y, currentLayoutY - getInterval());
747         mWindowManager.updateViewLayout(this, mCurrentLayoutParams);
748     }
749 
750     /**
751      * Gets the moving interval to not overlap between the keyboard and menu view.
752      *
753      * @return the moving interval if they overlap each other, otherwise 0.
754      */
getInterval()755     private int getInterval() {
756         final int currentLayoutY = (int) (mPosition.getPercentageY() * getMaxWindowY());
757         final int imeY = mDisplayHeight - mImeInsetsRect.bottom;
758         final int layoutBottomY = currentLayoutY + getWindowHeight();
759 
760         return layoutBottomY > imeY ? (layoutBottomY - imeY) : 0;
761     }
762 
updateMarginWith(Configuration configuration)763     private void updateMarginWith(Configuration configuration) {
764         // Avoid overlapping with system bars under landscape mode, update the margins of the menu
765         // to align the edge of system bars.
766         final int marginStartEnd = getMarginStartEndWith(configuration);
767         final LayoutParams layoutParams = (FrameLayout.LayoutParams) mListView.getLayoutParams();
768         layoutParams.setMargins(marginStartEnd, mMargin, marginStartEnd, mMargin);
769         mListView.setLayoutParams(layoutParams);
770     }
771 
updateOffsetWith(@hapeType int shapeType, @Alignment int side)772     private void updateOffsetWith(@ShapeType int shapeType, @Alignment int side) {
773         final float halfWidth = getLayoutWidth() / 2.0f;
774         final float offset = (shapeType == ShapeType.OVAL) ? 0 : halfWidth;
775         mListView.animate().translationX(side == Alignment.RIGHT ? offset : -offset);
776     }
777 
updateScrollModeWith(boolean hasExceededMaxLayoutHeight)778     private void updateScrollModeWith(boolean hasExceededMaxLayoutHeight) {
779         mListView.setOverScrollMode(hasExceededMaxLayoutHeight
780                 ? OVER_SCROLL_ALWAYS
781                 : OVER_SCROLL_NEVER);
782     }
783 
updateColor()784     private void updateColor() {
785         final int menuColorResId = R.color.accessibility_floating_menu_background;
786         getMenuGradientDrawable().setColor(getResources().getColor(menuColorResId));
787     }
788 
updateStrokeWith(int uiMode, @Alignment int side)789     private void updateStrokeWith(int uiMode, @Alignment int side) {
790         updateInsetWith(uiMode, side);
791 
792         final boolean isNightMode =
793                 (uiMode & Configuration.UI_MODE_NIGHT_MASK)
794                         == Configuration.UI_MODE_NIGHT_YES;
795         final Resources res = getResources();
796         final int width =
797                 res.getDimensionPixelSize(R.dimen.accessibility_floating_menu_stroke_width);
798         final int strokeWidth = isNightMode ? width : 0;
799         final int strokeColor = res.getColor(R.color.accessibility_floating_menu_stroke_dark);
800         getMenuGradientDrawable().setStroke(strokeWidth, strokeColor);
801     }
802 
updateRadiusWith(@izeType int sizeType, @RadiusType int radiusType, int itemCount)803     private void updateRadiusWith(@SizeType int sizeType, @RadiusType int radiusType,
804             int itemCount) {
805         mRadius =
806                 getResources().getDimensionPixelSize(getRadiusResId(sizeType, itemCount));
807         setRadius(mRadius, radiusType);
808     }
809 
updateInsetWith(int uiMode, @Alignment int side)810     private void updateInsetWith(int uiMode, @Alignment int side) {
811         final boolean isNightMode =
812                 (uiMode & Configuration.UI_MODE_NIGHT_MASK)
813                         == Configuration.UI_MODE_NIGHT_YES;
814 
815         final int layerInset = isNightMode ? mInset : 0;
816         final int insetLeft = (side == Alignment.LEFT) ? layerInset : 0;
817         final int insetRight = (side == Alignment.RIGHT) ? layerInset : 0;
818         setInset(insetLeft, insetRight);
819     }
820 
updateAccessibilityTitle(WindowManager.LayoutParams params)821     private void updateAccessibilityTitle(WindowManager.LayoutParams params) {
822         params.accessibilityTitle = getResources().getString(
823                 com.android.internal.R.string.accessibility_select_shortcut_menu_title);
824     }
825 
setInset(int left, int right)826     private void setInset(int left, int right) {
827         final LayerDrawable layerDrawable = getMenuLayerDrawable();
828         if (layerDrawable.getLayerInsetLeft(INDEX_MENU_ITEM) == left
829                 && layerDrawable.getLayerInsetRight(INDEX_MENU_ITEM) == right) {
830             return;
831         }
832 
833         layerDrawable.setLayerInset(INDEX_MENU_ITEM, left, 0, right, 0);
834     }
835 
836     @VisibleForTesting
hasExceededMaxLayoutHeight()837     boolean hasExceededMaxLayoutHeight() {
838         return calculateActualLayoutHeight() > getMaxLayoutHeight();
839     }
840 
841     @Alignment
transformToAlignment(@loatRangefrom = 0.0, to = 1.0) float percentageX)842     private int transformToAlignment(@FloatRange(from = 0.0, to = 1.0) float percentageX) {
843         return (percentageX < 0.5f) ? Alignment.LEFT : Alignment.RIGHT;
844     }
845 
transformCurrentPercentageXToEdge()846     private float transformCurrentPercentageXToEdge() {
847         final float percentageX = calculateCurrentPercentageX();
848         return (percentageX < 0.5) ? 0.0f : 1.0f;
849     }
850 
calculateCurrentPercentageX()851     private float calculateCurrentPercentageX() {
852         return mCurrentLayoutParams.x / (float) getMaxWindowX();
853     }
854 
calculateCurrentPercentageY()855     private float calculateCurrentPercentageY() {
856         return mCurrentLayoutParams.y / (float) getMaxWindowY();
857     }
858 
calculateActualLayoutHeight()859     private int calculateActualLayoutHeight() {
860         return (mPadding + mIconHeight) * mTargets.size() + mPadding;
861     }
862 
getMarginStartEndWith(Configuration configuration)863     private int getMarginStartEndWith(Configuration configuration) {
864         return configuration != null
865                 && configuration.orientation == ORIENTATION_PORTRAIT
866                 ? mMargin : 0;
867     }
868 
getRadiusResId(@izeType int sizeType, int itemCount)869     private @DimenRes int getRadiusResId(@SizeType int sizeType, int itemCount) {
870         return sizeType == SizeType.SMALL
871                 ? getSmallSizeResIdWith(itemCount)
872                 : getLargeSizeResIdWith(itemCount);
873     }
874 
getSmallSizeResIdWith(int itemCount)875     private int getSmallSizeResIdWith(int itemCount) {
876         return itemCount > 1
877                 ? R.dimen.accessibility_floating_menu_small_multiple_radius
878                 : R.dimen.accessibility_floating_menu_small_single_radius;
879     }
880 
getLargeSizeResIdWith(int itemCount)881     private int getLargeSizeResIdWith(int itemCount) {
882         return itemCount > 1
883                 ? R.dimen.accessibility_floating_menu_large_multiple_radius
884                 : R.dimen.accessibility_floating_menu_large_single_radius;
885     }
886 
887     @VisibleForTesting
getAvailableBounds()888     Rect getAvailableBounds() {
889         return new Rect(0, 0, mDisplayWidth - getWindowWidth(),
890                 mDisplayHeight - getWindowHeight());
891     }
892 
getMaxLayoutHeight()893     private int getMaxLayoutHeight() {
894         return mDisplayHeight - mMargin * 2;
895     }
896 
getLayoutWidth()897     private int getLayoutWidth() {
898         return mPadding * 2 + mIconWidth;
899     }
900 
getLayoutHeight()901     private int getLayoutHeight() {
902         return Math.min(getMaxLayoutHeight(), calculateActualLayoutHeight());
903     }
904 
getWindowWidth()905     private int getWindowWidth() {
906         return getMarginStartEndWith(mLastConfiguration) * 2 + getLayoutWidth();
907     }
908 
getWindowHeight()909     private int getWindowHeight() {
910         return Math.min(mDisplayHeight, mMargin * 2 + getLayoutHeight());
911     }
912 
setSystemGestureExclusion()913     private void setSystemGestureExclusion() {
914         final Rect excludeZone =
915                 new Rect(0, 0, getWindowWidth(), getWindowHeight());
916         post(() -> setSystemGestureExclusionRects(
917                 mIsShowing
918                         ? Collections.singletonList(excludeZone)
919                         : Collections.emptyList()));
920     }
921 }
922