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