1 /*
2  * Copyright (C) 2016 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.internal.policy;
18 
19 import android.content.Context;
20 import android.content.res.Configuration;
21 import android.content.res.Resources;
22 import android.graphics.Point;
23 import android.graphics.PointF;
24 import android.graphics.Rect;
25 import android.util.Size;
26 import android.view.Gravity;
27 import android.view.ViewConfiguration;
28 import android.widget.Scroller;
29 
30 import java.io.PrintWriter;
31 import java.util.ArrayList;
32 
33 /**
34  * Calculates the snap targets and the snap position for the PIP given a position and a velocity.
35  * All bounds are relative to the display top/left.
36  */
37 public class PipSnapAlgorithm {
38 
39     // The below SNAP_MODE_* constants correspond to the config resource value
40     // config_pictureInPictureSnapMode and should not be changed independently.
41     // Allows snapping to the four corners
42     private static final int SNAP_MODE_CORNERS_ONLY = 0;
43     // Allows snapping to the four corners and the mid-points on the long edge in each orientation
44     private static final int SNAP_MODE_CORNERS_AND_SIDES = 1;
45     // Allows snapping to anywhere along the edge of the screen
46     private static final int SNAP_MODE_EDGE = 2;
47     // Allows snapping anywhere along the edge of the screen and magnets towards corners
48     private static final int SNAP_MODE_EDGE_MAGNET_CORNERS = 3;
49     // Allows snapping on the long edge in each orientation and magnets towards corners
50     private static final int SNAP_MODE_LONG_EDGE_MAGNET_CORNERS = 4;
51 
52     // The friction multiplier to control how slippery the PIP is when flung
53     private static final float SCROLL_FRICTION_MULTIPLIER = 8f;
54 
55     // Threshold to magnet to a corner
56     private static final float CORNER_MAGNET_THRESHOLD = 0.3f;
57 
58     private final Context mContext;
59 
60     private final ArrayList<Integer> mSnapGravities = new ArrayList<>();
61     private final int mDefaultSnapMode = SNAP_MODE_EDGE_MAGNET_CORNERS;
62     private int mSnapMode = mDefaultSnapMode;
63 
64     private final float mDefaultSizePercent;
65     private final float mMinAspectRatioForMinSize;
66     private final float mMaxAspectRatioForMinSize;
67 
68     private Scroller mScroller;
69     private int mOrientation = Configuration.ORIENTATION_UNDEFINED;
70 
71     private final int mMinimizedVisibleSize;
72     private boolean mIsMinimized;
73 
PipSnapAlgorithm(Context context)74     public PipSnapAlgorithm(Context context) {
75         Resources res = context.getResources();
76         mContext = context;
77         mMinimizedVisibleSize = res.getDimensionPixelSize(
78                 com.android.internal.R.dimen.pip_minimized_visible_size);
79         mDefaultSizePercent = res.getFloat(
80                 com.android.internal.R.dimen.config_pictureInPictureDefaultSizePercent);
81         mMaxAspectRatioForMinSize = res.getFloat(
82                 com.android.internal.R.dimen.config_pictureInPictureAspectRatioLimitForMinSize);
83         mMinAspectRatioForMinSize = 1f / mMaxAspectRatioForMinSize;
84         onConfigurationChanged();
85     }
86 
87     /**
88      * Updates the snap algorithm when the configuration changes.
89      */
onConfigurationChanged()90     public void onConfigurationChanged() {
91         Resources res = mContext.getResources();
92         mOrientation = res.getConfiguration().orientation;
93         mSnapMode = res.getInteger(com.android.internal.R.integer.config_pictureInPictureSnapMode);
94         calculateSnapTargets();
95     }
96 
97     /**
98      * Sets the PIP's minimized state.
99      */
setMinimized(boolean isMinimized)100     public void setMinimized(boolean isMinimized) {
101         mIsMinimized = isMinimized;
102     }
103 
104     /**
105      * @return the closest absolute snap stack bounds for the given {@param stackBounds} moving at
106      * the given {@param velocityX} and {@param velocityY}.  The {@param movementBounds} should be
107      * those for the given {@param stackBounds}.
108      */
findClosestSnapBounds(Rect movementBounds, Rect stackBounds, float velocityX, float velocityY)109     public Rect findClosestSnapBounds(Rect movementBounds, Rect stackBounds, float velocityX,
110             float velocityY) {
111         final Rect finalStackBounds = new Rect(stackBounds);
112         if (mScroller == null) {
113             final ViewConfiguration viewConfig = ViewConfiguration.get(mContext);
114             mScroller = new Scroller(mContext);
115             mScroller.setFriction(viewConfig.getScrollFriction() * SCROLL_FRICTION_MULTIPLIER);
116         }
117         mScroller.fling(stackBounds.left, stackBounds.top,
118                 (int) velocityX, (int) velocityY,
119                 movementBounds.left, movementBounds.right,
120                 movementBounds.top, movementBounds.bottom);
121         finalStackBounds.offsetTo(mScroller.getFinalX(), mScroller.getFinalY());
122         mScroller.abortAnimation();
123         return findClosestSnapBounds(movementBounds, finalStackBounds);
124     }
125 
126     /**
127      * @return the closest absolute snap stack bounds for the given {@param stackBounds}.  The
128      * {@param movementBounds} should be those for the given {@param stackBounds}.
129      */
findClosestSnapBounds(Rect movementBounds, Rect stackBounds)130     public Rect findClosestSnapBounds(Rect movementBounds, Rect stackBounds) {
131         final Rect pipBounds = new Rect(movementBounds.left, movementBounds.top,
132                 movementBounds.right + stackBounds.width(),
133                 movementBounds.bottom + stackBounds.height());
134         final Rect newBounds = new Rect(stackBounds);
135         if (mSnapMode == SNAP_MODE_LONG_EDGE_MAGNET_CORNERS
136                 || mSnapMode == SNAP_MODE_EDGE_MAGNET_CORNERS) {
137             final Rect tmpBounds = new Rect();
138             final Point[] snapTargets = new Point[mSnapGravities.size()];
139             for (int i = 0; i < mSnapGravities.size(); i++) {
140                 Gravity.apply(mSnapGravities.get(i), stackBounds.width(), stackBounds.height(),
141                         pipBounds, 0, 0, tmpBounds);
142                 snapTargets[i] = new Point(tmpBounds.left, tmpBounds.top);
143             }
144             Point snapTarget = findClosestPoint(stackBounds.left, stackBounds.top, snapTargets);
145             float distance = distanceToPoint(snapTarget, stackBounds.left, stackBounds.top);
146             final float thresh = Math.max(stackBounds.width(), stackBounds.height())
147                     * CORNER_MAGNET_THRESHOLD;
148             if (distance < thresh) {
149                 newBounds.offsetTo(snapTarget.x, snapTarget.y);
150             } else {
151                 snapRectToClosestEdge(stackBounds, movementBounds, newBounds);
152             }
153         } else if (mSnapMode == SNAP_MODE_EDGE) {
154             // Find the closest edge to the given stack bounds and snap to it
155             snapRectToClosestEdge(stackBounds, movementBounds, newBounds);
156         } else {
157             // Find the closest snap point
158             final Rect tmpBounds = new Rect();
159             final Point[] snapTargets = new Point[mSnapGravities.size()];
160             for (int i = 0; i < mSnapGravities.size(); i++) {
161                 Gravity.apply(mSnapGravities.get(i), stackBounds.width(), stackBounds.height(),
162                         pipBounds, 0, 0, tmpBounds);
163                 snapTargets[i] = new Point(tmpBounds.left, tmpBounds.top);
164             }
165             Point snapTarget = findClosestPoint(stackBounds.left, stackBounds.top, snapTargets);
166             newBounds.offsetTo(snapTarget.x, snapTarget.y);
167         }
168         return newBounds;
169     }
170 
171     /**
172      * Applies the offset to the {@param stackBounds} to adjust it to a minimized state.
173      */
applyMinimizedOffset(Rect stackBounds, Rect movementBounds, Point displaySize, Rect stableInsets)174     public void applyMinimizedOffset(Rect stackBounds, Rect movementBounds, Point displaySize,
175             Rect stableInsets) {
176         if (stackBounds.left <= movementBounds.centerX()) {
177             stackBounds.offsetTo(stableInsets.left + mMinimizedVisibleSize - stackBounds.width(),
178                     stackBounds.top);
179         } else {
180             stackBounds.offsetTo(displaySize.x - stableInsets.right - mMinimizedVisibleSize,
181                     stackBounds.top);
182         }
183     }
184 
185     /**
186      * @return returns a fraction that describes where along the {@param movementBounds} the
187      *         {@param stackBounds} are. If the {@param stackBounds} are not currently on the
188      *         {@param movementBounds} exactly, then they will be snapped to the movement bounds.
189      *
190      *         The fraction is defined in a clockwise fashion against the {@param movementBounds}:
191      *
192      *            0   1
193      *          4 +---+ 1
194      *            |   |
195      *          3 +---+ 2
196      *            3   2
197      */
getSnapFraction(Rect stackBounds, Rect movementBounds)198     public float getSnapFraction(Rect stackBounds, Rect movementBounds) {
199         final Rect tmpBounds = new Rect();
200         snapRectToClosestEdge(stackBounds, movementBounds, tmpBounds);
201         final float widthFraction = (float) (tmpBounds.left - movementBounds.left) /
202                 movementBounds.width();
203         final float heightFraction = (float) (tmpBounds.top - movementBounds.top) /
204                 movementBounds.height();
205         if (tmpBounds.top == movementBounds.top) {
206             return widthFraction;
207         } else if (tmpBounds.left == movementBounds.right) {
208             return 1f + heightFraction;
209         } else if (tmpBounds.top == movementBounds.bottom) {
210             return 2f + (1f - widthFraction);
211         } else {
212             return 3f + (1f - heightFraction);
213         }
214     }
215 
216     /**
217      * Moves the {@param stackBounds} along the {@param movementBounds} to the given snap fraction.
218      * See {@link #getSnapFraction(Rect, Rect)}.
219      *
220      * The fraction is define in a clockwise fashion against the {@param movementBounds}:
221      *
222      *    0   1
223      *  4 +---+ 1
224      *    |   |
225      *  3 +---+ 2
226      *    3   2
227      */
applySnapFraction(Rect stackBounds, Rect movementBounds, float snapFraction)228     public void applySnapFraction(Rect stackBounds, Rect movementBounds, float snapFraction) {
229         if (snapFraction < 1f) {
230             int offset = movementBounds.left + (int) (snapFraction * movementBounds.width());
231             stackBounds.offsetTo(offset, movementBounds.top);
232         } else if (snapFraction < 2f) {
233             snapFraction -= 1f;
234             int offset = movementBounds.top + (int) (snapFraction * movementBounds.height());
235             stackBounds.offsetTo(movementBounds.right, offset);
236         } else if (snapFraction < 3f) {
237             snapFraction -= 2f;
238             int offset = movementBounds.left + (int) ((1f - snapFraction) * movementBounds.width());
239             stackBounds.offsetTo(offset, movementBounds.bottom);
240         } else {
241             snapFraction -= 3f;
242             int offset = movementBounds.top + (int) ((1f - snapFraction) * movementBounds.height());
243             stackBounds.offsetTo(movementBounds.left, offset);
244         }
245     }
246 
247     /**
248      * Adjusts {@param movementBoundsOut} so that it is the movement bounds for the given
249      * {@param stackBounds}.
250      */
getMovementBounds(Rect stackBounds, Rect insetBounds, Rect movementBoundsOut, int imeHeight)251     public void getMovementBounds(Rect stackBounds, Rect insetBounds, Rect movementBoundsOut,
252             int imeHeight) {
253         // Adjust the right/bottom to ensure the stack bounds never goes offscreen
254         movementBoundsOut.set(insetBounds);
255         movementBoundsOut.right = Math.max(insetBounds.left, insetBounds.right -
256                 stackBounds.width());
257         movementBoundsOut.bottom = Math.max(insetBounds.top, insetBounds.bottom -
258                 stackBounds.height());
259         movementBoundsOut.bottom -= imeHeight;
260     }
261 
262     /**
263      * @return the size of the PiP at the given {@param aspectRatio}, ensuring that the minimum edge
264      * is at least {@param minEdgeSize}.
265      */
getSizeForAspectRatio(float aspectRatio, float minEdgeSize, int displayWidth, int displayHeight)266     public Size getSizeForAspectRatio(float aspectRatio, float minEdgeSize, int displayWidth,
267             int displayHeight) {
268         final int smallestDisplaySize = Math.min(displayWidth, displayHeight);
269         final int minSize = (int) Math.max(minEdgeSize, smallestDisplaySize * mDefaultSizePercent);
270 
271         final int width;
272         final int height;
273         if (aspectRatio <= mMinAspectRatioForMinSize || aspectRatio > mMaxAspectRatioForMinSize) {
274             // Beyond these points, we can just use the min size as the shorter edge
275             if (aspectRatio <= 1) {
276                 // Portrait, width is the minimum size
277                 width = minSize;
278                 height = Math.round(width / aspectRatio);
279             } else {
280                 // Landscape, height is the minimum size
281                 height = minSize;
282                 width = Math.round(height * aspectRatio);
283             }
284         } else {
285             // Within these points, we ensure that the bounds fit within the radius of the limits
286             // at the points
287             final float widthAtMaxAspectRatioForMinSize = mMaxAspectRatioForMinSize * minSize;
288             final float radius = PointF.length(widthAtMaxAspectRatioForMinSize, minSize);
289             height = (int) Math.round(Math.sqrt((radius * radius) /
290                     (aspectRatio * aspectRatio + 1)));
291             width = Math.round(height * aspectRatio);
292         }
293         return new Size(width, height);
294     }
295 
296     /**
297      * @return the closest point in {@param points} to the given {@param x} and {@param y}.
298      */
findClosestPoint(int x, int y, Point[] points)299     private Point findClosestPoint(int x, int y, Point[] points) {
300         Point closestPoint = null;
301         float minDistance = Float.MAX_VALUE;
302         for (Point p : points) {
303             float distance = distanceToPoint(p, x, y);
304             if (distance < minDistance) {
305                 closestPoint = p;
306                 minDistance = distance;
307             }
308         }
309         return closestPoint;
310     }
311 
312     /**
313      * Snaps the {@param stackBounds} to the closest edge of the {@param movementBounds} and writes
314      * the new bounds out to {@param boundsOut}.
315      */
snapRectToClosestEdge(Rect stackBounds, Rect movementBounds, Rect boundsOut)316     private void snapRectToClosestEdge(Rect stackBounds, Rect movementBounds, Rect boundsOut) {
317         // If the stackBounds are minimized, then it should only be snapped back horizontally
318         final int boundedLeft = Math.max(movementBounds.left, Math.min(movementBounds.right,
319                 stackBounds.left));
320         final int boundedTop = Math.max(movementBounds.top, Math.min(movementBounds.bottom,
321                 stackBounds.top));
322         boundsOut.set(stackBounds);
323         if (mIsMinimized) {
324             boundsOut.offsetTo(boundedLeft, boundedTop);
325             return;
326         }
327 
328         // Otherwise, just find the closest edge
329         final int fromLeft = Math.abs(stackBounds.left - movementBounds.left);
330         final int fromTop = Math.abs(stackBounds.top - movementBounds.top);
331         final int fromRight = Math.abs(movementBounds.right - stackBounds.left);
332         final int fromBottom = Math.abs(movementBounds.bottom - stackBounds.top);
333         int shortest;
334         if (mSnapMode == SNAP_MODE_LONG_EDGE_MAGNET_CORNERS) {
335             // Only check longest edges
336             shortest = (mOrientation == Configuration.ORIENTATION_LANDSCAPE)
337                     ? Math.min(fromTop, fromBottom)
338                     : Math.min(fromLeft, fromRight);
339         } else {
340             shortest = Math.min(Math.min(fromLeft, fromRight), Math.min(fromTop, fromBottom));
341         }
342         if (shortest == fromLeft) {
343             boundsOut.offsetTo(movementBounds.left, boundedTop);
344         } else if (shortest == fromTop) {
345             boundsOut.offsetTo(boundedLeft, movementBounds.top);
346         } else if (shortest == fromRight) {
347             boundsOut.offsetTo(movementBounds.right, boundedTop);
348         } else {
349             boundsOut.offsetTo(boundedLeft, movementBounds.bottom);
350         }
351     }
352 
353     /**
354      * @return the distance between point {@param p} and the given {@param x} and {@param y}.
355      */
distanceToPoint(Point p, int x, int y)356     private float distanceToPoint(Point p, int x, int y) {
357         return PointF.length(p.x - x, p.y - y);
358     }
359 
360     /**
361      * Calculate the snap targets for the discrete snap modes.
362      */
calculateSnapTargets()363     private void calculateSnapTargets() {
364         mSnapGravities.clear();
365         switch (mSnapMode) {
366             case SNAP_MODE_CORNERS_AND_SIDES:
367                 if (mOrientation == Configuration.ORIENTATION_LANDSCAPE) {
368                     mSnapGravities.add(Gravity.TOP | Gravity.CENTER_HORIZONTAL);
369                     mSnapGravities.add(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
370                 } else {
371                     mSnapGravities.add(Gravity.CENTER_VERTICAL | Gravity.LEFT);
372                     mSnapGravities.add(Gravity.CENTER_VERTICAL | Gravity.RIGHT);
373                 }
374                 // Fall through
375             case SNAP_MODE_CORNERS_ONLY:
376             case SNAP_MODE_EDGE_MAGNET_CORNERS:
377             case SNAP_MODE_LONG_EDGE_MAGNET_CORNERS:
378                 mSnapGravities.add(Gravity.TOP | Gravity.LEFT);
379                 mSnapGravities.add(Gravity.TOP | Gravity.RIGHT);
380                 mSnapGravities.add(Gravity.BOTTOM | Gravity.LEFT);
381                 mSnapGravities.add(Gravity.BOTTOM | Gravity.RIGHT);
382                 break;
383             default:
384                 // Skip otherwise
385                 break;
386         }
387     }
388 
dump(PrintWriter pw, String prefix)389     public void dump(PrintWriter pw, String prefix) {
390         final String innerPrefix = prefix + "  ";
391         pw.println(prefix + PipSnapAlgorithm.class.getSimpleName());
392         pw.println(innerPrefix + "mSnapMode=" + mSnapMode);
393         pw.println(innerPrefix + "mOrientation=" + mOrientation);
394         pw.println(innerPrefix + "mMinimizedVisibleSize=" + mMinimizedVisibleSize);
395         pw.println(innerPrefix + "mIsMinimized=" + mIsMinimized);
396     }
397 }
398