<lambda>null1 package com.android.systemui.util
2
3 import android.graphics.Rect
4 import android.util.Log
5 import com.android.systemui.util.FloatingContentCoordinator.FloatingContent
6 import java.util.HashMap
7 import javax.inject.Inject
8 import javax.inject.Singleton
9
10 /** Tag for debug logging. */
11 private const val TAG = "FloatingCoordinator"
12
13 /**
14 * Coordinates the positions and movement of floating content, such as PIP and Bubbles, to ensure
15 * that they don't overlap. If content does overlap due to content appearing or moving, the
16 * coordinator will ask content to move to resolve the conflict.
17 *
18 * After implementing [FloatingContent], content should call [onContentAdded] to begin coordination.
19 * Subsequently, call [onContentMoved] whenever the content moves, and the coordinator will move
20 * other content out of the way. [onContentRemoved] should be called when the content is removed or
21 * no longer visible.
22 */
23 @Singleton
24 class FloatingContentCoordinator @Inject constructor() {
25
26 /**
27 * Represents a piece of floating content, such as PIP or the Bubbles stack. Provides methods
28 * that allow the [FloatingContentCoordinator] to determine the current location of the content,
29 * as well as the ability to ask it to move out of the way of other content.
30 *
31 * The default implementation of [calculateNewBoundsOnOverlap] moves the content up or down,
32 * depending on the position of the conflicting content. You can override this method if you
33 * want your own custom conflict resolution logic.
34 */
35 interface FloatingContent {
36
37 /**
38 * Return the bounds claimed by this content. This should include the bounds occupied by the
39 * content itself, as well as any padding, if desired. The coordinator will ensure that no
40 * other content is located within these bounds.
41 *
42 * If the content is animating, this method should return the bounds to which the content is
43 * animating. If that animation is cancelled, or updated, be sure that your implementation
44 * of this method returns the appropriate bounds, and call [onContentMoved] so that the
45 * coordinator moves other content out of the way.
46 */
47 fun getFloatingBoundsOnScreen(): Rect
48
49 /**
50 * Return the area within which this floating content is allowed to move. When resolving
51 * conflicts, the coordinator will never ask your content to move to a position where any
52 * part of the content would be out of these bounds.
53 */
54 fun getAllowedFloatingBoundsRegion(): Rect
55
56 /**
57 * Called when the coordinator needs this content to move to the given bounds. It's up to
58 * you how to do that.
59 *
60 * Note that if you start an animation to these bounds, [getFloatingBoundsOnScreen] should
61 * return the destination bounds, not the in-progress animated bounds. This is so the
62 * coordinator knows where floating content is going to be and can resolve conflicts
63 * accordingly.
64 */
65 fun moveToBounds(bounds: Rect)
66
67 /**
68 * Called by the coordinator when it needs to find a new home for this floating content,
69 * because a new or moving piece of content is now overlapping with it.
70 *
71 * [findAreaForContentVertically] and [findAreaForContentAboveOrBelow] are helpful utility
72 * functions that will find new bounds for your content automatically. Unless you require
73 * specific conflict resolution logic, these should be sufficient. By default, this method
74 * delegates to [findAreaForContentVertically].
75 *
76 * @param overlappingContentBounds The bounds of the other piece of content, which
77 * necessitated this content's relocation. Your new position must not overlap with these
78 * bounds.
79 * @param otherContentBounds The bounds of any other pieces of floating content. Your new
80 * position must not overlap with any of these either. These bounds are guaranteed to be
81 * non-overlapping.
82 * @return The new bounds for this content.
83 */
84 @JvmDefault
85 fun calculateNewBoundsOnOverlap(
86 overlappingContentBounds: Rect,
87 otherContentBounds: List<Rect>
88 ): Rect {
89 return findAreaForContentVertically(
90 getFloatingBoundsOnScreen(),
91 overlappingContentBounds,
92 otherContentBounds,
93 getAllowedFloatingBoundsRegion())
94 }
95 }
96
97 /** The bounds of all pieces of floating content added to the coordinator. */
98 private val allContentBounds: MutableMap<FloatingContent, Rect> = HashMap()
99
100 /**
101 * Whether we are currently resolving conflicts by asking content to move. If we are, we'll
102 * temporarily ignore calls to [onContentMoved] - those calls are from the content that is
103 * moving to new, conflict-free bounds, so we don't need to perform conflict detection
104 * calculations in response.
105 */
106 private var currentlyResolvingConflicts = false
107
108 /**
109 * Makes the coordinator aware of a new piece of floating content, and moves any existing
110 * content out of the way, if necessary.
111 *
112 * If you don't want your new content to move existing content, use [getOccupiedBounds] to find
113 * an unoccupied area, and move the content there before calling this method.
114 */
115 fun onContentAdded(newContent: FloatingContent) {
116 updateContentBounds()
117 allContentBounds[newContent] = newContent.getFloatingBoundsOnScreen()
118 maybeMoveConflictingContent(newContent)
119 }
120
121 /**
122 * Called to notify the coordinator that a piece of floating content has moved (or is animating)
123 * to a new position, and that any conflicting floating content should be moved out of the way.
124 *
125 * The coordinator will call [FloatingContent.getFloatingBoundsOnScreen] to find the new bounds
126 * for the moving content. If you're animating the content, be sure that your implementation of
127 * getFloatingBoundsOnScreen returns the bounds to which it's animating, not the content's
128 * current bounds.
129 *
130 * If the animation moving this content is cancelled or updated, you'll need to call this method
131 * again, to ensure that content is moved out of the way of the latest bounds.
132 *
133 * @param content The content that has moved.
134 */
135 fun onContentMoved(content: FloatingContent) {
136
137 // Ignore calls when we are currently resolving conflicts, since those calls are from
138 // content that is moving to new, conflict-free bounds.
139 if (currentlyResolvingConflicts) {
140 return
141 }
142
143 if (!allContentBounds.containsKey(content)) {
144 Log.wtf(TAG, "Received onContentMoved call before onContentAdded! " +
145 "This should never happen.")
146 return
147 }
148
149 updateContentBounds()
150 maybeMoveConflictingContent(content)
151 }
152
153 /**
154 * Called to notify the coordinator that a piece of floating content has been removed or is no
155 * longer visible.
156 */
157 fun onContentRemoved(removedContent: FloatingContent) {
158 allContentBounds.remove(removedContent)
159 }
160
161 /**
162 * Returns a set of Rects that represent the bounds of all of the floating content on the
163 * screen.
164 *
165 * [onContentAdded] will move existing content out of the way if the added content intersects
166 * existing content. That's fine - but if your specific starting position is not important, you
167 * can use this function to find unoccupied space for your content before calling
168 * [onContentAdded], so that moving existing content isn't necessary.
169 */
170 fun getOccupiedBounds(): Collection<Rect> {
171 return allContentBounds.values
172 }
173
174 /**
175 * Identifies any pieces of content that are now overlapping with the given content, and asks
176 * them to move out of the way.
177 */
178 private fun maybeMoveConflictingContent(fromContent: FloatingContent) {
179 currentlyResolvingConflicts = true
180
181 val conflictingNewBounds = allContentBounds[fromContent]!!
182 allContentBounds
183 // Filter to content that intersects with the new bounds. That's content that needs
184 // to move.
185 .filter { (content, bounds) ->
186 content != fromContent && Rect.intersects(conflictingNewBounds, bounds) }
187 // Tell that content to get out of the way, and save the bounds it says it's moving
188 // (or animating) to.
189 .forEach { (content, bounds) ->
190 val newBounds = content.calculateNewBoundsOnOverlap(
191 conflictingNewBounds,
192 // Pass all of the content bounds except the bounds of the
193 // content we're asking to move, and the conflicting new bounds
194 // (since those are passed separately).
195 otherContentBounds = allContentBounds.values
196 .minus(bounds)
197 .minus(conflictingNewBounds))
198
199 // If the new bounds are empty, it means there's no non-overlapping position
200 // that is in bounds. Just leave the content where it is. This should normally
201 // not happen, but sometimes content like PIP reports incorrect bounds
202 // temporarily.
203 if (!newBounds.isEmpty) {
204 content.moveToBounds(newBounds)
205 allContentBounds[content] = content.getFloatingBoundsOnScreen()
206 }
207 }
208
209 currentlyResolvingConflicts = false
210 }
211
212 /**
213 * Update [allContentBounds] by calling [FloatingContent.getFloatingBoundsOnScreen] for all
214 * content and saving the result.
215 */
216 private fun updateContentBounds() {
217 allContentBounds.keys.forEach { allContentBounds[it] = it.getFloatingBoundsOnScreen() }
218 }
219
220 companion object {
221 /**
222 * Finds new bounds for the given content, either above or below its current position. The
223 * new bounds won't intersect with the newly overlapping rect or the exclusion rects, and
224 * will be within the allowed bounds unless no possible position exists.
225 *
226 * You can use this method to help find a new position for your content when the coordinator
227 * calls [FloatingContent.moveToAreaExcluding].
228 *
229 * @param contentRect The bounds of the content for which we're finding a new home.
230 * @param newlyOverlappingRect The bounds of the content that forced this relocation by
231 * intersecting with the content we now need to move. If the overlapping content is
232 * overlapping the top half of this content, we'll try to move this content downward if
233 * possible (since the other content is 'pushing' it down), and vice versa.
234 * @param exclusionRects Any other areas that we need to avoid when finding a new home for
235 * the content. These areas must be non-overlapping with each other.
236 * @param allowedBounds The area within which we're allowed to find new bounds for the
237 * content.
238 * @return New bounds for the content that don't intersect the exclusion rects or the
239 * newly overlapping rect, and that is within bounds - or an empty Rect if no in-bounds
240 * position exists.
241 */
242 @JvmStatic
243 fun findAreaForContentVertically(
244 contentRect: Rect,
245 newlyOverlappingRect: Rect,
246 exclusionRects: Collection<Rect>,
247 allowedBounds: Rect
248 ): Rect {
249 // If the newly overlapping Rect's center is above the content's center, we'll prefer to
250 // find a space for this content that is below the overlapping content, since it's
251 // 'pushing' it down. This may not be possible due to to screen bounds, in which case
252 // we'll find space in the other direction.
253 val overlappingContentPushingDown =
254 newlyOverlappingRect.centerY() < contentRect.centerY()
255
256 // Filter to exclusion rects that are above or below the content that we're finding a
257 // place for. Then, split into two lists - rects above the content, and rects below it.
258 var (rectsToAvoidAbove, rectsToAvoidBelow) = exclusionRects
259 .filter { rectToAvoid -> rectsIntersectVertically(rectToAvoid, contentRect) }
260 .partition { rectToAvoid -> rectToAvoid.top < contentRect.top }
261
262 // Lazily calculate the closest possible new tops for the content, above and below its
263 // current location.
264 val newContentBoundsAbove by lazy { findAreaForContentAboveOrBelow(
265 contentRect,
266 exclusionRects = rectsToAvoidAbove.plus(newlyOverlappingRect),
267 findAbove = true) }
268 val newContentBoundsBelow by lazy { findAreaForContentAboveOrBelow(
269 contentRect,
270 exclusionRects = rectsToAvoidBelow.plus(newlyOverlappingRect),
271 findAbove = false) }
272
273 val positionAboveInBounds by lazy { allowedBounds.contains(newContentBoundsAbove) }
274 val positionBelowInBounds by lazy { allowedBounds.contains(newContentBoundsBelow) }
275
276 // Use the 'below' position if the content is being overlapped from the top, unless it's
277 // out of bounds. Also use it if the content is being overlapped from the bottom, but
278 // the 'above' position is out of bounds. Otherwise, use the 'above' position.
279 val usePositionBelow =
280 overlappingContentPushingDown && positionBelowInBounds ||
281 !overlappingContentPushingDown && !positionAboveInBounds
282
283 // Return the content rect, but offset to reflect the new position.
284 val newBounds = if (usePositionBelow) newContentBoundsBelow else newContentBoundsAbove
285
286 // If the new bounds are within the allowed bounds, return them. If not, it means that
287 // there are no legal new bounds. This can happen if the new content's bounds are too
288 // large (for example, full-screen PIP). Since there is no reasonable action to take
289 // here, return an empty Rect and we will just not move the content.
290 return if (allowedBounds.contains(newBounds)) newBounds else Rect()
291 }
292
293 /**
294 * Finds a new position for the given content, either above or below its current position
295 * depending on whether [findAbove] is true or false, respectively. This new position will
296 * not intersect with any of the [exclusionRects].
297 *
298 * This method is useful as a helper method for implementing your own conflict resolution
299 * logic. Otherwise, you'd want to use [findAreaForContentVertically], which takes screen
300 * bounds and conflicting bounds' location into account when deciding whether to move to new
301 * bounds above or below the current bounds.
302 *
303 * @param contentRect The content we're finding an area for.
304 * @param exclusionRects The areas we need to avoid when finding a new area for the content.
305 * These areas must be non-overlapping with each other.
306 * @param findAbove Whether we are finding an area above the content's current position,
307 * rather than an area below it.
308 */
309 fun findAreaForContentAboveOrBelow(
310 contentRect: Rect,
311 exclusionRects: Collection<Rect>,
312 findAbove: Boolean
313 ): Rect {
314 // Sort the rects, since we want to move the content as little as possible. We'll
315 // start with the rects closest to the content and move outward. If we're finding an
316 // area above the content, that means we sort in reverse order to search the rects
317 // from highest to lowest y-value.
318 val sortedExclusionRects =
319 exclusionRects.sortedBy { if (findAbove) -it.top else it.top }
320
321 val proposedNewBounds = Rect(contentRect)
322 for (exclusionRect in sortedExclusionRects) {
323 // If the proposed new bounds don't intersect with this exclusion rect, that
324 // means there's room for the content here. We know this because the rects are
325 // sorted and non-overlapping, so any subsequent exclusion rects would be higher
326 // (or lower) than this one and can't possibly intersect if this one doesn't.
327 if (!Rect.intersects(proposedNewBounds, exclusionRect)) {
328 break
329 } else {
330 // Otherwise, we need to keep searching for new bounds. If we're finding an
331 // area above, propose new bounds that place the content just above the
332 // exclusion rect. If we're finding an area below, propose new bounds that
333 // place the content just below the exclusion rect.
334 val verticalOffset =
335 if (findAbove) -contentRect.height() else exclusionRect.height()
336 proposedNewBounds.offsetTo(
337 proposedNewBounds.left,
338 exclusionRect.top + verticalOffset)
339 }
340 }
341
342 return proposedNewBounds
343 }
344
345 /** Returns whether or not the two Rects share any of the same space on the X axis. */
346 private fun rectsIntersectVertically(r1: Rect, r2: Rect): Boolean {
347 return (r1.left >= r2.left && r1.left <= r2.right) ||
348 (r1.right <= r2.right && r1.right >= r2.left)
349 }
350 }
351 }