1 /*
2  * Copyright (C) 2020 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.wm.shell.common.split;
18 
19 import static android.view.PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
20 import static android.view.PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
21 import static android.view.WindowManager.LayoutParams.FLAG_SLIPPERY;
22 
23 import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.CURSOR_HOVER_STATES_ENABLED;
24 
25 import android.animation.Animator;
26 import android.animation.AnimatorListenerAdapter;
27 import android.animation.ObjectAnimator;
28 import android.content.Context;
29 import android.graphics.Canvas;
30 import android.graphics.Paint;
31 import android.graphics.Rect;
32 import android.os.Bundle;
33 import android.provider.DeviceConfig;
34 import android.util.AttributeSet;
35 import android.util.Property;
36 import android.view.GestureDetector;
37 import android.view.InsetsController;
38 import android.view.InsetsSource;
39 import android.view.InsetsState;
40 import android.view.MotionEvent;
41 import android.view.PointerIcon;
42 import android.view.SurfaceControlViewHost;
43 import android.view.VelocityTracker;
44 import android.view.View;
45 import android.view.ViewConfiguration;
46 import android.view.ViewGroup;
47 import android.view.WindowInsets;
48 import android.view.WindowManager;
49 import android.view.accessibility.AccessibilityNodeInfo;
50 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
51 import android.widget.FrameLayout;
52 
53 import androidx.annotation.NonNull;
54 import androidx.annotation.Nullable;
55 
56 import com.android.internal.annotations.VisibleForTesting;
57 import com.android.internal.protolog.common.ProtoLog;
58 import com.android.wm.shell.R;
59 import com.android.wm.shell.animation.Interpolators;
60 import com.android.wm.shell.protolog.ShellProtoLogGroup;
61 
62 /**
63  * Divider for multi window splits.
64  */
65 public class DividerView extends FrameLayout implements View.OnTouchListener {
66     public static final long TOUCH_ANIMATION_DURATION = 150;
67     public static final long TOUCH_RELEASE_ANIMATION_DURATION = 200;
68 
69     private final Paint mPaint = new Paint();
70     private final Rect mBackgroundRect = new Rect();
71     private final int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
72 
73     private SplitLayout mSplitLayout;
74     private SplitWindowManager mSplitWindowManager;
75     private SurfaceControlViewHost mViewHost;
76     private DividerHandleView mHandle;
77     private DividerRoundedCorner mCorners;
78     private int mTouchElevation;
79 
80     private VelocityTracker mVelocityTracker;
81     private boolean mMoving;
82     private int mStartPos;
83     private GestureDetector mDoubleTapDetector;
84     private boolean mInteractive;
85     private boolean mHideHandle;
86     private boolean mSetTouchRegion = true;
87     private int mLastDraggingPosition;
88     private int mHandleRegionWidth;
89     private int mHandleRegionHeight;
90 
91     /**
92      * Tracks divider bar visible bounds in screen-based coordination. Used to calculate with
93      * insets.
94      */
95     private final Rect mDividerBounds = new Rect();
96     private final Rect mTempRect = new Rect();
97     private FrameLayout mDividerBar;
98 
99     static final Property<DividerView, Integer> DIVIDER_HEIGHT_PROPERTY =
100             new Property<DividerView, Integer>(Integer.class, "height") {
101                 @Override
102                 public Integer get(DividerView object) {
103                     return object.mDividerBar.getLayoutParams().height;
104                 }
105 
106                 @Override
107                 public void set(DividerView object, Integer value) {
108                     ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams)
109                             object.mDividerBar.getLayoutParams();
110                     lp.height = value;
111                     object.mDividerBar.setLayoutParams(lp);
112                 }
113             };
114 
115     private AnimatorListenerAdapter mAnimatorListener = new AnimatorListenerAdapter() {
116         @Override
117         public void onAnimationEnd(Animator animation) {
118             mSetTouchRegion = true;
119         }
120 
121         @Override
122         public void onAnimationCancel(Animator animation) {
123             mSetTouchRegion = true;
124         }
125     };
126 
127     private final AccessibilityDelegate mHandleDelegate = new AccessibilityDelegate() {
128         @Override
129         public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
130             super.onInitializeAccessibilityNodeInfo(host, info);
131             final DividerSnapAlgorithm snapAlgorithm = mSplitLayout.mDividerSnapAlgorithm;
132             if (mSplitLayout.isLeftRightSplit()) {
133                 info.addAction(new AccessibilityAction(R.id.action_move_tl_full,
134                         mContext.getString(R.string.accessibility_action_divider_left_full)));
135                 if (snapAlgorithm.isFirstSplitTargetAvailable()) {
136                     info.addAction(new AccessibilityAction(R.id.action_move_tl_70,
137                             mContext.getString(R.string.accessibility_action_divider_left_70)));
138                 }
139                 if (snapAlgorithm.showMiddleSplitTargetForAccessibility()) {
140                     // Only show the middle target if there are more than 1 split target
141                     info.addAction(new AccessibilityAction(R.id.action_move_tl_50,
142                             mContext.getString(R.string.accessibility_action_divider_left_50)));
143                 }
144                 if (snapAlgorithm.isLastSplitTargetAvailable()) {
145                     info.addAction(new AccessibilityAction(R.id.action_move_tl_30,
146                             mContext.getString(R.string.accessibility_action_divider_left_30)));
147                 }
148                 info.addAction(new AccessibilityAction(R.id.action_move_rb_full,
149                         mContext.getString(R.string.accessibility_action_divider_right_full)));
150             } else {
151                 info.addAction(new AccessibilityAction(R.id.action_move_tl_full,
152                         mContext.getString(R.string.accessibility_action_divider_top_full)));
153                 if (snapAlgorithm.isFirstSplitTargetAvailable()) {
154                     info.addAction(new AccessibilityAction(R.id.action_move_tl_70,
155                             mContext.getString(R.string.accessibility_action_divider_top_70)));
156                 }
157                 if (snapAlgorithm.showMiddleSplitTargetForAccessibility()) {
158                     // Only show the middle target if there are more than 1 split target
159                     info.addAction(new AccessibilityAction(R.id.action_move_tl_50,
160                             mContext.getString(R.string.accessibility_action_divider_top_50)));
161                 }
162                 if (snapAlgorithm.isLastSplitTargetAvailable()) {
163                     info.addAction(new AccessibilityAction(R.id.action_move_tl_30,
164                             mContext.getString(R.string.accessibility_action_divider_top_30)));
165                 }
166                 info.addAction(new AccessibilityAction(R.id.action_move_rb_full,
167                         mContext.getString(R.string.accessibility_action_divider_bottom_full)));
168             }
169         }
170 
171         @Override
172         public boolean performAccessibilityAction(@NonNull View host, int action,
173                 @Nullable Bundle args) {
174             DividerSnapAlgorithm.SnapTarget nextTarget = null;
175             DividerSnapAlgorithm snapAlgorithm = mSplitLayout.mDividerSnapAlgorithm;
176             if (action == R.id.action_move_tl_full) {
177                 nextTarget = snapAlgorithm.getDismissEndTarget();
178             } else if (action == R.id.action_move_tl_70) {
179                 nextTarget = snapAlgorithm.getLastSplitTarget();
180             } else if (action == R.id.action_move_tl_50) {
181                 nextTarget = snapAlgorithm.getMiddleTarget();
182             } else if (action == R.id.action_move_tl_30) {
183                 nextTarget = snapAlgorithm.getFirstSplitTarget();
184             } else if (action == R.id.action_move_rb_full) {
185                 nextTarget = snapAlgorithm.getDismissStartTarget();
186             }
187             if (nextTarget != null) {
188                 mSplitLayout.snapToTarget(mSplitLayout.getDividerPosition(), nextTarget);
189                 return true;
190             }
191             return super.performAccessibilityAction(host, action, args);
192         }
193     };
194 
DividerView(@onNull Context context)195     public DividerView(@NonNull Context context) {
196         super(context);
197     }
198 
DividerView(@onNull Context context, @Nullable AttributeSet attrs)199     public DividerView(@NonNull Context context,
200             @Nullable AttributeSet attrs) {
201         super(context, attrs);
202     }
203 
DividerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)204     public DividerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
205         super(context, attrs, defStyleAttr);
206     }
207 
DividerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)208     public DividerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
209             int defStyleRes) {
210         super(context, attrs, defStyleAttr, defStyleRes);
211     }
212 
213     /** Sets up essential dependencies of the divider bar. */
setup(SplitLayout layout, SplitWindowManager splitWindowManager, SurfaceControlViewHost viewHost, InsetsState insetsState)214     public void setup(SplitLayout layout, SplitWindowManager splitWindowManager,
215             SurfaceControlViewHost viewHost, InsetsState insetsState) {
216         mSplitLayout = layout;
217         mSplitWindowManager = splitWindowManager;
218         mViewHost = viewHost;
219         layout.getDividerBounds(mDividerBounds);
220         onInsetsChanged(insetsState, false /* animate */);
221 
222         final boolean isLeftRightSplit = mSplitLayout.isLeftRightSplit();
223         mHandle.setIsLeftRightSplit(isLeftRightSplit);
224         mCorners.setIsLeftRightSplit(isLeftRightSplit);
225 
226         mHandleRegionWidth = getResources().getDimensionPixelSize(isLeftRightSplit
227                 ? R.dimen.split_divider_handle_region_height
228                 : R.dimen.split_divider_handle_region_width);
229         mHandleRegionHeight = getResources().getDimensionPixelSize(isLeftRightSplit
230                 ? R.dimen.split_divider_handle_region_width
231                 : R.dimen.split_divider_handle_region_height);
232     }
233 
onInsetsChanged(InsetsState insetsState, boolean animate)234     void onInsetsChanged(InsetsState insetsState, boolean animate) {
235         mSplitLayout.getDividerBounds(mTempRect);
236         // Only insets the divider bar with task bar when it's expanded so that the rounded corners
237         // will be drawn against task bar.
238         // But there is no need to do it when IME showing because there are no rounded corners at
239         // the bottom. This also avoids the problem of task bar height not changing when IME
240         // floating.
241         if (!insetsState.isSourceOrDefaultVisible(InsetsSource.ID_IME, WindowInsets.Type.ime())) {
242             for (int i = insetsState.sourceSize() - 1; i >= 0; i--) {
243                 final InsetsSource source = insetsState.sourceAt(i);
244                 if (source.getType() == WindowInsets.Type.navigationBars()
245                         && source.hasFlags(InsetsSource.FLAG_INSETS_ROUNDED_CORNER)) {
246                     mTempRect.inset(source.calculateVisibleInsets(mTempRect));
247                 }
248             }
249         }
250 
251         if (!mTempRect.equals(mDividerBounds)) {
252             if (animate) {
253                 ObjectAnimator animator = ObjectAnimator.ofInt(this,
254                         DIVIDER_HEIGHT_PROPERTY, mDividerBounds.height(), mTempRect.height());
255                 animator.setInterpolator(InsetsController.RESIZE_INTERPOLATOR);
256                 animator.setDuration(InsetsController.ANIMATION_DURATION_RESIZE);
257                 animator.addListener(mAnimatorListener);
258                 animator.start();
259             } else {
260                 DIVIDER_HEIGHT_PROPERTY.set(this, mTempRect.height());
261                 mSetTouchRegion = true;
262             }
263             mDividerBounds.set(mTempRect);
264         }
265     }
266 
267     @Override
onFinishInflate()268     protected void onFinishInflate() {
269         super.onFinishInflate();
270         mDividerBar = findViewById(R.id.divider_bar);
271         mHandle = findViewById(R.id.docked_divider_handle);
272         mCorners = findViewById(R.id.docked_divider_rounded_corner);
273         mTouchElevation = getResources().getDimensionPixelSize(
274                 R.dimen.docked_stack_divider_lift_elevation);
275         mDoubleTapDetector = new GestureDetector(getContext(), new DoubleTapListener());
276         mInteractive = true;
277         mHideHandle = false;
278         setOnTouchListener(this);
279         mHandle.setAccessibilityDelegate(mHandleDelegate);
280         setWillNotDraw(false);
281         mPaint.setColor(getResources().getColor(R.color.split_divider_background, null));
282         mPaint.setAntiAlias(true);
283         mPaint.setStyle(Paint.Style.FILL);
284     }
285 
286     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)287     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
288         super.onLayout(changed, left, top, right, bottom);
289         if (mSetTouchRegion) {
290             int startX = (mDividerBounds.width() - mHandleRegionWidth) / 2;
291             int startY = (mDividerBounds.height() - mHandleRegionHeight) / 2;
292             mTempRect.set(startX, startY, startX + mHandleRegionWidth,
293                     startY + mHandleRegionHeight);
294             mSplitWindowManager.setTouchRegion(mTempRect);
295             mSetTouchRegion = false;
296         }
297 
298         if (changed) {
299             boolean isHorizontalSplit = mSplitLayout.isLeftRightSplit();
300             int dividerSize = getResources().getDimensionPixelSize(R.dimen.split_divider_bar_width);
301             left = isHorizontalSplit ? (getWidth() - dividerSize) / 2 : 0;
302             top = isHorizontalSplit ? 0 : (getHeight() - dividerSize) / 2;
303             right = isHorizontalSplit ? left + dividerSize : getWidth();
304             bottom = isHorizontalSplit ? getHeight() : top + dividerSize;
305             mBackgroundRect.set(left, top, right, bottom);
306         }
307     }
308 
309     @Override
onResolvePointerIcon(MotionEvent event, int pointerIndex)310     public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
311         return PointerIcon.getSystemIcon(getContext(),
312                 mSplitLayout.isLeftRightSplit() ? TYPE_HORIZONTAL_DOUBLE_ARROW
313                         : TYPE_VERTICAL_DOUBLE_ARROW);
314     }
315 
316     @Override
onTouch(View v, MotionEvent event)317     public boolean onTouch(View v, MotionEvent event) {
318         if (mSplitLayout == null || !mInteractive) {
319             return false;
320         }
321 
322         if (mDoubleTapDetector.onTouchEvent(event)) {
323             return true;
324         }
325 
326         // Convert to use screen-based coordinates to prevent lost track of motion events while
327         // moving divider bar and calculating dragging velocity.
328         event.setLocation(event.getRawX(), event.getRawY());
329         final int action = event.getAction() & MotionEvent.ACTION_MASK;
330         final boolean isLeftRightSplit = mSplitLayout.isLeftRightSplit();
331         final int touchPos = (int) (isLeftRightSplit ? event.getX() : event.getY());
332         switch (action) {
333             case MotionEvent.ACTION_DOWN:
334                 mVelocityTracker = VelocityTracker.obtain();
335                 mVelocityTracker.addMovement(event);
336                 setTouching();
337                 mStartPos = touchPos;
338                 mMoving = false;
339                 // This triggers initialization of things like the resize veil in preparation for
340                 // showing it when the user moves the divider past the slop, and has to be done
341                 // before onStartDragging() which starts the jank interaction tracing
342                 mSplitLayout.updateDividerBounds(mSplitLayout.getDividerPosition(),
343                         false /* shouldUseParallaxEffect */);
344                 mSplitLayout.onStartDragging();
345                 break;
346             case MotionEvent.ACTION_MOVE:
347                 mVelocityTracker.addMovement(event);
348                 if (!mMoving && Math.abs(touchPos - mStartPos) > mTouchSlop) {
349                     mStartPos = touchPos;
350                     mMoving = true;
351                 }
352                 if (mMoving) {
353                     final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos;
354                     mLastDraggingPosition = position;
355                     mSplitLayout.updateDividerBounds(position, true /* shouldUseParallaxEffect */);
356                 }
357                 break;
358             case MotionEvent.ACTION_UP:
359             case MotionEvent.ACTION_CANCEL:
360                 releaseTouching();
361                 if (!mMoving) {
362                     mSplitLayout.onDraggingCancelled();
363                     break;
364                 }
365 
366                 mVelocityTracker.addMovement(event);
367                 mVelocityTracker.computeCurrentVelocity(1000 /* units */);
368                 final float velocity = isLeftRightSplit
369                         ? mVelocityTracker.getXVelocity()
370                         : mVelocityTracker.getYVelocity();
371                 final int position = mSplitLayout.getDividerPosition() + touchPos - mStartPos;
372                 final DividerSnapAlgorithm.SnapTarget snapTarget =
373                         mSplitLayout.findSnapTarget(position, velocity, false /* hardDismiss */);
374                 mSplitLayout.snapToTarget(position, snapTarget);
375                 mMoving = false;
376                 break;
377         }
378 
379         return true;
380     }
381 
setTouching()382     private void setTouching() {
383         setSlippery(false);
384         mHandle.setTouching(true, true);
385         // Lift handle as well so it doesn't get behind the background, even though it doesn't
386         // cast shadow.
387         mHandle.animate()
388                 .setInterpolator(Interpolators.TOUCH_RESPONSE)
389                 .setDuration(TOUCH_ANIMATION_DURATION)
390                 .translationZ(mTouchElevation)
391                 .start();
392     }
393 
releaseTouching()394     private void releaseTouching() {
395         setSlippery(true);
396         mHandle.setTouching(false, true);
397         mHandle.animate()
398                 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
399                 .setDuration(TOUCH_RELEASE_ANIMATION_DURATION)
400                 .translationZ(0)
401                 .start();
402     }
403 
setSlippery(boolean slippery)404     private void setSlippery(boolean slippery) {
405         if (mViewHost == null) {
406             return;
407         }
408 
409         final WindowManager.LayoutParams lp = (WindowManager.LayoutParams) getLayoutParams();
410         final boolean isSlippery = (lp.flags & FLAG_SLIPPERY) != 0;
411         if (isSlippery == slippery) {
412             return;
413         }
414 
415         if (slippery) {
416             lp.flags |= FLAG_SLIPPERY;
417         } else {
418             lp.flags &= ~FLAG_SLIPPERY;
419         }
420         mViewHost.relayout(lp);
421     }
422 
423     @Override
onHoverEvent(MotionEvent event)424     public boolean onHoverEvent(MotionEvent event) {
425         if (!DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI, CURSOR_HOVER_STATES_ENABLED,
426                 /* defaultValue = */ false)) {
427             return false;
428         }
429 
430         if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) {
431             setHovering();
432             return true;
433         } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) {
434             releaseHovering();
435             return true;
436         }
437         return false;
438     }
439 
440     @VisibleForTesting
setHovering()441     void setHovering() {
442         mHandle.setHovering(true, true);
443         mHandle.animate()
444                 .setInterpolator(Interpolators.TOUCH_RESPONSE)
445                 .setDuration(TOUCH_ANIMATION_DURATION)
446                 .translationZ(mTouchElevation)
447                 .start();
448     }
449 
450     @Override
onDraw(@onNull Canvas canvas)451     protected void onDraw(@NonNull Canvas canvas) {
452         canvas.drawRect(mBackgroundRect, mPaint);
453     }
454 
455     @VisibleForTesting
releaseHovering()456     void releaseHovering() {
457         mHandle.setHovering(false, true);
458         mHandle.animate()
459                 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
460                 .setDuration(TOUCH_RELEASE_ANIMATION_DURATION)
461                 .translationZ(0)
462                 .start();
463     }
464 
465     /**
466      * Set divider should interactive to user or not.
467      *
468      * @param interactive divider interactive.
469      * @param hideHandle divider handle hidden or not, only work when interactive is false.
470      * @param from caller from where.
471      */
setInteractive(boolean interactive, boolean hideHandle, String from)472     void setInteractive(boolean interactive, boolean hideHandle, String from) {
473         if (interactive == mInteractive) return;
474         ProtoLog.d(ShellProtoLogGroup.WM_SHELL_SPLIT_SCREEN,
475                 "Set divider bar %s hide handle=%b from %s",
476                 interactive ? "interactive" : "non-interactive", hideHandle, from);
477         mInteractive = interactive;
478         mHideHandle = hideHandle;
479         if (!mInteractive && mHideHandle && mMoving) {
480             final int position = mSplitLayout.getDividerPosition();
481             mSplitLayout.flingDividerPosition(
482                     mLastDraggingPosition,
483                     position,
484                     mSplitLayout.FLING_RESIZE_DURATION,
485                     () -> mSplitLayout.setDividerPosition(position, true /* applyLayoutChange */));
486             mMoving = false;
487         }
488         releaseTouching();
489         mHandle.setVisibility(!mInteractive && mHideHandle ? View.INVISIBLE : View.VISIBLE);
490     }
491 
isInteractive()492     boolean isInteractive() {
493         return mInteractive;
494     }
495 
isHandleHidden()496     boolean isHandleHidden() {
497         return mHideHandle;
498     }
499 
500     private class DoubleTapListener extends GestureDetector.SimpleOnGestureListener {
501         @Override
onDoubleTap(MotionEvent e)502         public boolean onDoubleTap(MotionEvent e) {
503             if (mSplitLayout != null) {
504                 mSplitLayout.onDoubleTappedDivider();
505             }
506             return true;
507         }
508 
509         @Override
onDoubleTapEvent(@onNull MotionEvent e)510         public boolean onDoubleTapEvent(@NonNull MotionEvent e) {
511             return true;
512         }
513     }
514 }
515