1 /*
2  * Copyright (C) 2023 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.WindowManager.DOCKED_INVALID;
20 import static android.view.WindowManager.DOCKED_LEFT;
21 import static android.view.WindowManager.DOCKED_RIGHT;
22 
23 import android.content.Context;
24 import android.content.res.Configuration;
25 import android.content.res.Resources;
26 import android.graphics.Rect;
27 import android.hardware.display.DisplayManager;
28 import android.view.Display;
29 import android.view.DisplayInfo;
30 
31 import java.util.ArrayList;
32 
33 /**
34  * Calculates the snap targets and the snap position given a position and a velocity. All positions
35  * here are to be interpreted as the left/top edge of the divider rectangle.
36  *
37  * @hide
38  */
39 public class DividerSnapAlgorithm {
40 
41     private static final int MIN_FLING_VELOCITY_DP_PER_SECOND = 400;
42     private static final int MIN_DISMISS_VELOCITY_DP_PER_SECOND = 600;
43 
44     /**
45      * 3 snap targets: left/top has 16:9 ratio (for videos), 1:1, and right/bottom has 16:9 ratio
46      */
47     private static final int SNAP_MODE_16_9 = 0;
48 
49     /**
50      * 3 snap targets: fixed ratio, 1:1, (1 - fixed ratio)
51      */
52     private static final int SNAP_FIXED_RATIO = 1;
53 
54     /**
55      * 1 snap target: 1:1
56      */
57     private static final int SNAP_ONLY_1_1 = 2;
58 
59     /**
60      * 1 snap target: minimized height, (1 - minimized height)
61      */
62     private static final int SNAP_MODE_MINIMIZED = 3;
63 
64     private final float mMinFlingVelocityPxPerSecond;
65     private final float mMinDismissVelocityPxPerSecond;
66     private final int mDisplayWidth;
67     private final int mDisplayHeight;
68     private final int mDividerSize;
69     private final ArrayList<SnapTarget> mTargets = new ArrayList<>();
70     private final Rect mInsets = new Rect();
71     private final int mSnapMode;
72     private final boolean mFreeSnapMode;
73     private final int mMinimalSizeResizableTask;
74     private final int mTaskHeightInMinimizedMode;
75     private final float mFixedRatio;
76     private boolean mIsHorizontalDivision;
77 
78     /** The first target which is still splitting the screen */
79     private final SnapTarget mFirstSplitTarget;
80 
81     /** The last target which is still splitting the screen */
82     private final SnapTarget mLastSplitTarget;
83 
84     private final SnapTarget mDismissStartTarget;
85     private final SnapTarget mDismissEndTarget;
86     private final SnapTarget mMiddleTarget;
87 
create(Context ctx, Rect insets)88     public static DividerSnapAlgorithm create(Context ctx, Rect insets) {
89         DisplayInfo displayInfo = new DisplayInfo();
90         ctx.getSystemService(DisplayManager.class).getDisplay(
91                 Display.DEFAULT_DISPLAY).getDisplayInfo(displayInfo);
92         int dividerWindowWidth = ctx.getResources().getDimensionPixelSize(
93                 com.android.internal.R.dimen.docked_stack_divider_thickness);
94         int dividerInsets = ctx.getResources().getDimensionPixelSize(
95                 com.android.internal.R.dimen.docked_stack_divider_insets);
96         return new DividerSnapAlgorithm(ctx.getResources(),
97                 displayInfo.logicalWidth, displayInfo.logicalHeight,
98                 dividerWindowWidth - 2 * dividerInsets,
99                 ctx.getApplicationContext().getResources().getConfiguration().orientation
100                         == Configuration.ORIENTATION_PORTRAIT,
101                 insets);
102     }
103 
DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isHorizontalDivision, Rect insets)104     public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
105             boolean isHorizontalDivision, Rect insets) {
106         this(res, displayWidth, displayHeight, dividerSize, isHorizontalDivision, insets,
107                 DOCKED_INVALID, false /* minimized */, true /* resizable */);
108     }
109 
DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isHorizontalDivision, Rect insets, int dockSide)110     public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
111         boolean isHorizontalDivision, Rect insets, int dockSide) {
112         this(res, displayWidth, displayHeight, dividerSize, isHorizontalDivision, insets,
113             dockSide, false /* minimized */, true /* resizable */);
114     }
115 
DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isHorizontalDivision, Rect insets, int dockSide, boolean isMinimizedMode, boolean isHomeResizable)116     public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
117             boolean isHorizontalDivision, Rect insets, int dockSide, boolean isMinimizedMode,
118             boolean isHomeResizable) {
119         mMinFlingVelocityPxPerSecond =
120                 MIN_FLING_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density;
121         mMinDismissVelocityPxPerSecond =
122                 MIN_DISMISS_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density;
123         mDividerSize = dividerSize;
124         mDisplayWidth = displayWidth;
125         mDisplayHeight = displayHeight;
126         mIsHorizontalDivision = isHorizontalDivision;
127         mInsets.set(insets);
128         mSnapMode = isMinimizedMode ? SNAP_MODE_MINIMIZED :
129                 res.getInteger(com.android.internal.R.integer.config_dockedStackDividerSnapMode);
130         mFreeSnapMode = res.getBoolean(
131                 com.android.internal.R.bool.config_dockedStackDividerFreeSnapMode);
132         mFixedRatio = res.getFraction(
133                 com.android.internal.R.fraction.docked_stack_divider_fixed_ratio, 1, 1);
134         mMinimalSizeResizableTask = res.getDimensionPixelSize(
135                 com.android.internal.R.dimen.default_minimal_size_resizable_task);
136         mTaskHeightInMinimizedMode = isHomeResizable ? res.getDimensionPixelSize(
137                 com.android.internal.R.dimen.task_height_of_minimized_mode) : 0;
138         calculateTargets(isHorizontalDivision, dockSide);
139         mFirstSplitTarget = mTargets.get(1);
140         mLastSplitTarget = mTargets.get(mTargets.size() - 2);
141         mDismissStartTarget = mTargets.get(0);
142         mDismissEndTarget = mTargets.get(mTargets.size() - 1);
143         mMiddleTarget = mTargets.get(mTargets.size() / 2);
144         mMiddleTarget.isMiddleTarget = true;
145     }
146 
147     /**
148      * @return whether it's feasible to enable split screen in the current configuration, i.e. when
149      *         snapping in the middle both tasks are larger than the minimal task size.
150      */
isSplitScreenFeasible()151     public boolean isSplitScreenFeasible() {
152         int statusBarSize = mInsets.top;
153         int navBarSize = mIsHorizontalDivision ? mInsets.bottom : mInsets.right;
154         int size = mIsHorizontalDivision
155                 ? mDisplayHeight
156                 : mDisplayWidth;
157         int availableSpace = size - navBarSize - statusBarSize - mDividerSize;
158         return availableSpace / 2 >= mMinimalSizeResizableTask;
159     }
160 
calculateSnapTarget(int position, float velocity)161     public SnapTarget calculateSnapTarget(int position, float velocity) {
162         return calculateSnapTarget(position, velocity, true /* hardDismiss */);
163     }
164 
165     /**
166      * @param position the top/left position of the divider
167      * @param velocity current dragging velocity
168      * @param hardDismiss if set, make it a bit harder to get reach the dismiss targets
169      */
calculateSnapTarget(int position, float velocity, boolean hardDismiss)170     public SnapTarget calculateSnapTarget(int position, float velocity, boolean hardDismiss) {
171         if (position < mFirstSplitTarget.position && velocity < -mMinDismissVelocityPxPerSecond) {
172             return mDismissStartTarget;
173         }
174         if (position > mLastSplitTarget.position && velocity > mMinDismissVelocityPxPerSecond) {
175             return mDismissEndTarget;
176         }
177         if (Math.abs(velocity) < mMinFlingVelocityPxPerSecond) {
178             return snap(position, hardDismiss);
179         }
180         if (velocity < 0) {
181             return mFirstSplitTarget;
182         } else {
183             return mLastSplitTarget;
184         }
185     }
186 
calculateNonDismissingSnapTarget(int position)187     public SnapTarget calculateNonDismissingSnapTarget(int position) {
188         SnapTarget target = snap(position, false /* hardDismiss */);
189         if (target == mDismissStartTarget) {
190             return mFirstSplitTarget;
191         } else if (target == mDismissEndTarget) {
192             return mLastSplitTarget;
193         } else {
194             return target;
195         }
196     }
197 
calculateDismissingFraction(int position)198     public float calculateDismissingFraction(int position) {
199         if (position < mFirstSplitTarget.position) {
200             return 1f - (float) (position - getStartInset())
201                     / (mFirstSplitTarget.position - getStartInset());
202         } else if (position > mLastSplitTarget.position) {
203             return (float) (position - mLastSplitTarget.position)
204                     / (mDismissEndTarget.position - mLastSplitTarget.position - mDividerSize);
205         }
206         return 0f;
207     }
208 
getClosestDismissTarget(int position)209     public SnapTarget getClosestDismissTarget(int position) {
210         if (position < mFirstSplitTarget.position) {
211             return mDismissStartTarget;
212         } else if (position > mLastSplitTarget.position) {
213             return mDismissEndTarget;
214         } else if (position - mDismissStartTarget.position
215                 < mDismissEndTarget.position - position) {
216             return mDismissStartTarget;
217         } else {
218             return mDismissEndTarget;
219         }
220     }
221 
getFirstSplitTarget()222     public SnapTarget getFirstSplitTarget() {
223         return mFirstSplitTarget;
224     }
225 
getLastSplitTarget()226     public SnapTarget getLastSplitTarget() {
227         return mLastSplitTarget;
228     }
229 
getDismissStartTarget()230     public SnapTarget getDismissStartTarget() {
231         return mDismissStartTarget;
232     }
233 
getDismissEndTarget()234     public SnapTarget getDismissEndTarget() {
235         return mDismissEndTarget;
236     }
237 
getStartInset()238     private int getStartInset() {
239         if (mIsHorizontalDivision) {
240             return mInsets.top;
241         } else {
242             return mInsets.left;
243         }
244     }
245 
getEndInset()246     private int getEndInset() {
247         if (mIsHorizontalDivision) {
248             return mInsets.bottom;
249         } else {
250             return mInsets.right;
251         }
252     }
253 
shouldApplyFreeSnapMode(int position)254     private boolean shouldApplyFreeSnapMode(int position) {
255         if (!mFreeSnapMode) {
256             return false;
257         }
258         if (!isFirstSplitTargetAvailable() || !isLastSplitTargetAvailable()) {
259             return false;
260         }
261         return mFirstSplitTarget.position < position && position < mLastSplitTarget.position;
262     }
263 
snap(int position, boolean hardDismiss)264     private SnapTarget snap(int position, boolean hardDismiss) {
265         if (shouldApplyFreeSnapMode(position)) {
266             return new SnapTarget(position, position, SnapTarget.FLAG_NONE);
267         }
268         int minIndex = -1;
269         float minDistance = Float.MAX_VALUE;
270         int size = mTargets.size();
271         for (int i = 0; i < size; i++) {
272             SnapTarget target = mTargets.get(i);
273             float distance = Math.abs(position - target.position);
274             if (hardDismiss) {
275                 distance /= target.distanceMultiplier;
276             }
277             if (distance < minDistance) {
278                 minIndex = i;
279                 minDistance = distance;
280             }
281         }
282         return mTargets.get(minIndex);
283     }
284 
calculateTargets(boolean isHorizontalDivision, int dockedSide)285     private void calculateTargets(boolean isHorizontalDivision, int dockedSide) {
286         mTargets.clear();
287         int dividerMax = isHorizontalDivision
288                 ? mDisplayHeight
289                 : mDisplayWidth;
290         int startPos = -mDividerSize;
291         if (dockedSide == DOCKED_RIGHT) {
292             startPos += mInsets.left;
293         }
294         mTargets.add(new SnapTarget(startPos, startPos, SnapTarget.FLAG_DISMISS_START,
295                 0.35f));
296         switch (mSnapMode) {
297             case SNAP_MODE_16_9:
298                 addRatio16_9Targets(isHorizontalDivision, dividerMax);
299                 break;
300             case SNAP_FIXED_RATIO:
301                 addFixedDivisionTargets(isHorizontalDivision, dividerMax);
302                 break;
303             case SNAP_ONLY_1_1:
304                 addMiddleTarget(isHorizontalDivision);
305                 break;
306             case SNAP_MODE_MINIMIZED:
307                 addMinimizedTarget(isHorizontalDivision, dockedSide);
308                 break;
309         }
310         mTargets.add(new SnapTarget(dividerMax, dividerMax, SnapTarget.FLAG_DISMISS_END, 0.35f));
311     }
312 
addNonDismissingTargets(boolean isHorizontalDivision, int topPosition, int bottomPosition, int dividerMax)313     private void addNonDismissingTargets(boolean isHorizontalDivision, int topPosition,
314             int bottomPosition, int dividerMax) {
315         maybeAddTarget(topPosition, topPosition - getStartInset());
316         addMiddleTarget(isHorizontalDivision);
317         maybeAddTarget(bottomPosition,
318                 dividerMax - getEndInset() - (bottomPosition + mDividerSize));
319     }
320 
addFixedDivisionTargets(boolean isHorizontalDivision, int dividerMax)321     private void addFixedDivisionTargets(boolean isHorizontalDivision, int dividerMax) {
322         int start = isHorizontalDivision ? mInsets.top : mInsets.left;
323         int end = isHorizontalDivision
324                 ? mDisplayHeight - mInsets.bottom
325                 : mDisplayWidth - mInsets.right;
326         int size = (int) (mFixedRatio * (end - start)) - mDividerSize / 2;
327         int topPosition = start + size;
328         int bottomPosition = end - size - mDividerSize;
329         addNonDismissingTargets(isHorizontalDivision, topPosition, bottomPosition, dividerMax);
330     }
331 
addRatio16_9Targets(boolean isHorizontalDivision, int dividerMax)332     private void addRatio16_9Targets(boolean isHorizontalDivision, int dividerMax) {
333         int start = isHorizontalDivision ? mInsets.top : mInsets.left;
334         int end = isHorizontalDivision
335                 ? mDisplayHeight - mInsets.bottom
336                 : mDisplayWidth - mInsets.right;
337         int startOther = isHorizontalDivision ? mInsets.left : mInsets.top;
338         int endOther = isHorizontalDivision
339                 ? mDisplayWidth - mInsets.right
340                 : mDisplayHeight - mInsets.bottom;
341         float size = 9.0f / 16.0f * (endOther - startOther);
342         int sizeInt = (int) Math.floor(size);
343         int topPosition = start + sizeInt;
344         int bottomPosition = end - sizeInt - mDividerSize;
345         addNonDismissingTargets(isHorizontalDivision, topPosition, bottomPosition, dividerMax);
346     }
347 
348     /**
349      * Adds a target at {@param position} but only if the area with size of {@param smallerSize}
350      * meets the minimal size requirement.
351      */
maybeAddTarget(int position, int smallerSize)352     private void maybeAddTarget(int position, int smallerSize) {
353         if (smallerSize >= mMinimalSizeResizableTask) {
354             mTargets.add(new SnapTarget(position, position, SnapTarget.FLAG_NONE));
355         }
356     }
357 
addMiddleTarget(boolean isHorizontalDivision)358     private void addMiddleTarget(boolean isHorizontalDivision) {
359         int position = DockedDividerUtils.calculateMiddlePosition(isHorizontalDivision,
360                 mInsets, mDisplayWidth, mDisplayHeight, mDividerSize);
361         mTargets.add(new SnapTarget(position, position, SnapTarget.FLAG_NONE));
362     }
363 
addMinimizedTarget(boolean isHorizontalDivision, int dockedSide)364     private void addMinimizedTarget(boolean isHorizontalDivision, int dockedSide) {
365         // In portrait offset the position by the statusbar height, in landscape add the statusbar
366         // height as well to match portrait offset
367         int position = mTaskHeightInMinimizedMode + mInsets.top;
368         if (!isHorizontalDivision) {
369             if (dockedSide == DOCKED_LEFT) {
370                 position += mInsets.left;
371             } else if (dockedSide == DOCKED_RIGHT) {
372                 position = mDisplayWidth - position - mInsets.right - mDividerSize;
373             }
374         }
375         mTargets.add(new SnapTarget(position, position, SnapTarget.FLAG_NONE));
376     }
377 
getMiddleTarget()378     public SnapTarget getMiddleTarget() {
379         return mMiddleTarget;
380     }
381 
getNextTarget(SnapTarget snapTarget)382     public SnapTarget getNextTarget(SnapTarget snapTarget) {
383         int index = mTargets.indexOf(snapTarget);
384         if (index != -1 && index < mTargets.size() - 1) {
385             return mTargets.get(index + 1);
386         }
387         return snapTarget;
388     }
389 
getPreviousTarget(SnapTarget snapTarget)390     public SnapTarget getPreviousTarget(SnapTarget snapTarget) {
391         int index = mTargets.indexOf(snapTarget);
392         if (index != -1 && index > 0) {
393             return mTargets.get(index - 1);
394         }
395         return snapTarget;
396     }
397 
398     /**
399      * @return whether or not there are more than 1 split targets that do not include the two
400      * dismiss targets, used in deciding to display the middle target for accessibility
401      */
showMiddleSplitTargetForAccessibility()402     public boolean showMiddleSplitTargetForAccessibility() {
403         return (mTargets.size() - 2) > 1;
404     }
405 
isFirstSplitTargetAvailable()406     public boolean isFirstSplitTargetAvailable() {
407         return mFirstSplitTarget != mMiddleTarget;
408     }
409 
isLastSplitTargetAvailable()410     public boolean isLastSplitTargetAvailable() {
411         return mLastSplitTarget != mMiddleTarget;
412     }
413 
414     /**
415      * Cycles through all non-dismiss targets with a stepping of {@param increment}. It moves left
416      * if {@param increment} is negative and moves right otherwise.
417      */
cycleNonDismissTarget(SnapTarget snapTarget, int increment)418     public SnapTarget cycleNonDismissTarget(SnapTarget snapTarget, int increment) {
419         int index = mTargets.indexOf(snapTarget);
420         if (index != -1) {
421             SnapTarget newTarget = mTargets.get((index + mTargets.size() + increment)
422                     % mTargets.size());
423             if (newTarget == mDismissStartTarget) {
424                 return mLastSplitTarget;
425             } else if (newTarget == mDismissEndTarget) {
426                 return mFirstSplitTarget;
427             } else {
428                 return newTarget;
429             }
430         }
431         return snapTarget;
432     }
433 
434     /**
435      * Represents a snap target for the divider.
436      */
437     public static class SnapTarget {
438         public static final int FLAG_NONE = 0;
439 
440         /** If the divider reaches this value, the left/top task should be dismissed. */
441         public static final int FLAG_DISMISS_START = 1;
442 
443         /** If the divider reaches this value, the right/bottom task should be dismissed */
444         public static final int FLAG_DISMISS_END = 2;
445 
446         /** Position of this snap target. The right/bottom edge of the top/left task snaps here. */
447         public final int position;
448 
449         /**
450          * Like {@link #position}, but used to calculate the task bounds which might be different
451          * from the stack bounds.
452          */
453         public final int taskPosition;
454 
455         public final int flag;
456 
457         public boolean isMiddleTarget;
458 
459         /**
460          * Multiplier used to calculate distance to snap position. The lower this value, the harder
461          * it's to snap on this target
462          */
463         private final float distanceMultiplier;
464 
SnapTarget(int position, int taskPosition, int flag)465         public SnapTarget(int position, int taskPosition, int flag) {
466             this(position, taskPosition, flag, 1f);
467         }
468 
SnapTarget(int position, int taskPosition, int flag, float distanceMultiplier)469         public SnapTarget(int position, int taskPosition, int flag, float distanceMultiplier) {
470             this.position = position;
471             this.taskPosition = taskPosition;
472             this.flag = flag;
473             this.distanceMultiplier = distanceMultiplier;
474         }
475     }
476 }
477