| package com.android.systemui.util |
| |
| import android.graphics.Rect |
| import android.util.Log |
| import com.android.systemui.util.FloatingContentCoordinator.FloatingContent |
| import java.util.HashMap |
| import javax.inject.Inject |
| import javax.inject.Singleton |
| |
| /** Tag for debug logging. */ |
| private const val TAG = "FloatingCoordinator" |
| |
| /** |
| * Coordinates the positions and movement of floating content, such as PIP and Bubbles, to ensure |
| * that they don't overlap. If content does overlap due to content appearing or moving, the |
| * coordinator will ask content to move to resolve the conflict. |
| * |
| * After implementing [FloatingContent], content should call [onContentAdded] to begin coordination. |
| * Subsequently, call [onContentMoved] whenever the content moves, and the coordinator will move |
| * other content out of the way. [onContentRemoved] should be called when the content is removed or |
| * no longer visible. |
| */ |
| @Singleton |
| class FloatingContentCoordinator @Inject constructor() { |
| |
| /** |
| * Represents a piece of floating content, such as PIP or the Bubbles stack. Provides methods |
| * that allow the [FloatingContentCoordinator] to determine the current location of the content, |
| * as well as the ability to ask it to move out of the way of other content. |
| * |
| * The default implementation of [calculateNewBoundsOnOverlap] moves the content up or down, |
| * depending on the position of the conflicting content. You can override this method if you |
| * want your own custom conflict resolution logic. |
| */ |
| interface FloatingContent { |
| |
| /** |
| * Return the bounds claimed by this content. This should include the bounds occupied by the |
| * content itself, as well as any padding, if desired. The coordinator will ensure that no |
| * other content is located within these bounds. |
| * |
| * If the content is animating, this method should return the bounds to which the content is |
| * animating. If that animation is cancelled, or updated, be sure that your implementation |
| * of this method returns the appropriate bounds, and call [onContentMoved] so that the |
| * coordinator moves other content out of the way. |
| */ |
| fun getFloatingBoundsOnScreen(): Rect |
| |
| /** |
| * Return the area within which this floating content is allowed to move. When resolving |
| * conflicts, the coordinator will never ask your content to move to a position where any |
| * part of the content would be out of these bounds. |
| */ |
| fun getAllowedFloatingBoundsRegion(): Rect |
| |
| /** |
| * Called when the coordinator needs this content to move to the given bounds. It's up to |
| * you how to do that. |
| * |
| * Note that if you start an animation to these bounds, [getFloatingBoundsOnScreen] should |
| * return the destination bounds, not the in-progress animated bounds. This is so the |
| * coordinator knows where floating content is going to be and can resolve conflicts |
| * accordingly. |
| */ |
| fun moveToBounds(bounds: Rect) |
| |
| /** |
| * Called by the coordinator when it needs to find a new home for this floating content, |
| * because a new or moving piece of content is now overlapping with it. |
| * |
| * [findAreaForContentVertically] and [findAreaForContentAboveOrBelow] are helpful utility |
| * functions that will find new bounds for your content automatically. Unless you require |
| * specific conflict resolution logic, these should be sufficient. By default, this method |
| * delegates to [findAreaForContentVertically]. |
| * |
| * @param overlappingContentBounds The bounds of the other piece of content, which |
| * necessitated this content's relocation. Your new position must not overlap with these |
| * bounds. |
| * @param otherContentBounds The bounds of any other pieces of floating content. Your new |
| * position must not overlap with any of these either. These bounds are guaranteed to be |
| * non-overlapping. |
| * @return The new bounds for this content. |
| */ |
| @JvmDefault |
| fun calculateNewBoundsOnOverlap( |
| overlappingContentBounds: Rect, |
| otherContentBounds: List<Rect> |
| ): Rect { |
| return findAreaForContentVertically( |
| getFloatingBoundsOnScreen(), |
| overlappingContentBounds, |
| otherContentBounds, |
| getAllowedFloatingBoundsRegion()) |
| } |
| } |
| |
| /** The bounds of all pieces of floating content added to the coordinator. */ |
| private val allContentBounds: MutableMap<FloatingContent, Rect> = HashMap() |
| |
| /** |
| * Makes the coordinator aware of a new piece of floating content, and moves any existing |
| * content out of the way, if necessary. |
| * |
| * If you don't want your new content to move existing content, use [getOccupiedBounds] to find |
| * an unoccupied area, and move the content there before calling this method. |
| */ |
| fun onContentAdded(newContent: FloatingContent) { |
| updateContentBounds() |
| allContentBounds[newContent] = newContent.getFloatingBoundsOnScreen() |
| maybeMoveConflictingContent(newContent) |
| } |
| |
| /** |
| * Called to notify the coordinator that a piece of floating content has moved (or is animating) |
| * to a new position, and that any conflicting floating content should be moved out of the way. |
| * |
| * The coordinator will call [FloatingContent.getFloatingBoundsOnScreen] to find the new bounds |
| * for the moving content. If you're animating the content, be sure that your implementation of |
| * getFloatingBoundsOnScreen returns the bounds to which it's animating, not the content's |
| * current bounds. |
| * |
| * If the animation moving this content is cancelled or updated, you'll need to call this method |
| * again, to ensure that content is moved out of the way of the latest bounds. |
| * |
| * @param content The content that has moved. |
| */ |
| @JvmOverloads |
| fun onContentMoved(content: FloatingContent) { |
| if (!allContentBounds.containsKey(content)) { |
| Log.wtf(TAG, "Received onContentMoved call before onContentAdded! " + |
| "This should never happen.") |
| return |
| } |
| |
| updateContentBounds() |
| maybeMoveConflictingContent(content) |
| } |
| |
| /** |
| * Called to notify the coordinator that a piece of floating content has been removed or is no |
| * longer visible. |
| */ |
| fun onContentRemoved(removedContent: FloatingContent) { |
| allContentBounds.remove(removedContent) |
| } |
| |
| /** |
| * Returns a set of Rects that represent the bounds of all of the floating content on the |
| * screen. |
| * |
| * [onContentAdded] will move existing content out of the way if the added content intersects |
| * existing content. That's fine - but if your specific starting position is not important, you |
| * can use this function to find unoccupied space for your content before calling |
| * [onContentAdded], so that moving existing content isn't necessary. |
| */ |
| fun getOccupiedBounds(): Collection<Rect> { |
| return allContentBounds.values |
| } |
| |
| /** |
| * Identifies any pieces of content that are now overlapping with the given content, and asks |
| * them to move out of the way. |
| */ |
| private fun maybeMoveConflictingContent(fromContent: FloatingContent) { |
| val conflictingNewBounds = allContentBounds[fromContent]!! |
| allContentBounds |
| // Filter to content that intersects with the new bounds. That's content that needs |
| // to move. |
| .filter { (content, bounds) -> |
| content != fromContent && Rect.intersects(conflictingNewBounds, bounds) } |
| // Tell that content to get out of the way, and save the bounds it says it's moving |
| // (or animating) to. |
| .forEach { (content, bounds) -> |
| content.moveToBounds( |
| content.calculateNewBoundsOnOverlap( |
| conflictingNewBounds, |
| // Pass all of the content bounds except the bounds of the |
| // content we're asking to move, and the conflicting new bounds |
| // (since those are passed separately). |
| otherContentBounds = allContentBounds.values |
| .minus(bounds) |
| .minus(conflictingNewBounds))) |
| allContentBounds[content] = content.getFloatingBoundsOnScreen() |
| } |
| } |
| |
| /** |
| * Update [allContentBounds] by calling [FloatingContent.getFloatingBoundsOnScreen] for all |
| * content and saving the result. |
| */ |
| private fun updateContentBounds() { |
| allContentBounds.keys.forEach { allContentBounds[it] = it.getFloatingBoundsOnScreen() } |
| } |
| |
| companion object { |
| /** |
| * Finds new bounds for the given content, either above or below its current position. The |
| * new bounds won't intersect with the newly overlapping rect or the exclusion rects, and |
| * will be within the allowed bounds unless no possible position exists. |
| * |
| * You can use this method to help find a new position for your content when the coordinator |
| * calls [FloatingContent.moveToAreaExcluding]. |
| * |
| * @param contentRect The bounds of the content for which we're finding a new home. |
| * @param newlyOverlappingRect The bounds of the content that forced this relocation by |
| * intersecting with the content we now need to move. If the overlapping content is |
| * overlapping the top half of this content, we'll try to move this content downward if |
| * possible (since the other content is 'pushing' it down), and vice versa. |
| * @param exclusionRects Any other areas that we need to avoid when finding a new home for |
| * the content. These areas must be non-overlapping with each other. |
| * @param allowedBounds The area within which we're allowed to find new bounds for the |
| * content. |
| * @return New bounds for the content that don't intersect the exclusion rects or the |
| * newly overlapping rect, and that is within bounds unless no possible in-bounds position |
| * exists. |
| */ |
| @JvmStatic |
| fun findAreaForContentVertically( |
| contentRect: Rect, |
| newlyOverlappingRect: Rect, |
| exclusionRects: Collection<Rect>, |
| allowedBounds: Rect |
| ): Rect { |
| // If the newly overlapping Rect's center is above the content's center, we'll prefer to |
| // find a space for this content that is below the overlapping content, since it's |
| // 'pushing' it down. This may not be possible due to to screen bounds, in which case |
| // we'll find space in the other direction. |
| val overlappingContentPushingDown = |
| newlyOverlappingRect.centerY() < contentRect.centerY() |
| |
| // Filter to exclusion rects that are above or below the content that we're finding a |
| // place for. Then, split into two lists - rects above the content, and rects below it. |
| var (rectsToAvoidAbove, rectsToAvoidBelow) = exclusionRects |
| .filter { rectToAvoid -> rectsIntersectVertically(rectToAvoid, contentRect) } |
| .partition { rectToAvoid -> rectToAvoid.top < contentRect.top } |
| |
| // Lazily calculate the closest possible new tops for the content, above and below its |
| // current location. |
| val newContentBoundsAbove by lazy { findAreaForContentAboveOrBelow( |
| contentRect, |
| exclusionRects = rectsToAvoidAbove.plus(newlyOverlappingRect), |
| findAbove = true) } |
| val newContentBoundsBelow by lazy { findAreaForContentAboveOrBelow( |
| contentRect, |
| exclusionRects = rectsToAvoidBelow.plus(newlyOverlappingRect), |
| findAbove = false) } |
| |
| val positionAboveInBounds by lazy { allowedBounds.contains(newContentBoundsAbove) } |
| val positionBelowInBounds by lazy { allowedBounds.contains(newContentBoundsBelow) } |
| |
| // Use the 'below' position if the content is being overlapped from the top, unless it's |
| // out of bounds. Also use it if the content is being overlapped from the bottom, but |
| // the 'above' position is out of bounds. Otherwise, use the 'above' position. |
| val usePositionBelow = |
| overlappingContentPushingDown && positionBelowInBounds || |
| !overlappingContentPushingDown && !positionAboveInBounds |
| |
| // Return the content rect, but offset to reflect the new position. |
| return if (usePositionBelow) newContentBoundsBelow else newContentBoundsAbove |
| } |
| |
| /** |
| * Finds a new position for the given content, either above or below its current position |
| * depending on whether [findAbove] is true or false, respectively. This new position will |
| * not intersect with any of the [exclusionRects]. |
| * |
| * This method is useful as a helper method for implementing your own conflict resolution |
| * logic. Otherwise, you'd want to use [findAreaForContentVertically], which takes screen |
| * bounds and conflicting bounds' location into account when deciding whether to move to new |
| * bounds above or below the current bounds. |
| * |
| * @param contentRect The content we're finding an area for. |
| * @param exclusionRects The areas we need to avoid when finding a new area for the content. |
| * These areas must be non-overlapping with each other. |
| * @param findAbove Whether we are finding an area above the content's current position, |
| * rather than an area below it. |
| */ |
| fun findAreaForContentAboveOrBelow( |
| contentRect: Rect, |
| exclusionRects: Collection<Rect>, |
| findAbove: Boolean |
| ): Rect { |
| // Sort the rects, since we want to move the content as little as possible. We'll |
| // start with the rects closest to the content and move outward. If we're finding an |
| // area above the content, that means we sort in reverse order to search the rects |
| // from highest to lowest y-value. |
| val sortedExclusionRects = |
| exclusionRects.sortedBy { if (findAbove) -it.top else it.top } |
| |
| val proposedNewBounds = Rect(contentRect) |
| for (exclusionRect in sortedExclusionRects) { |
| // If the proposed new bounds don't intersect with this exclusion rect, that |
| // means there's room for the content here. We know this because the rects are |
| // sorted and non-overlapping, so any subsequent exclusion rects would be higher |
| // (or lower) than this one and can't possibly intersect if this one doesn't. |
| if (!Rect.intersects(proposedNewBounds, exclusionRect)) { |
| break |
| } else { |
| // Otherwise, we need to keep searching for new bounds. If we're finding an |
| // area above, propose new bounds that place the content just above the |
| // exclusion rect. If we're finding an area below, propose new bounds that |
| // place the content just below the exclusion rect. |
| val verticalOffset = |
| if (findAbove) -contentRect.height() else exclusionRect.height() |
| proposedNewBounds.offsetTo( |
| proposedNewBounds.left, |
| exclusionRect.top + verticalOffset) |
| } |
| } |
| |
| return proposedNewBounds |
| } |
| |
| /** Returns whether or not the two Rects share any of the same space on the X axis. */ |
| private fun rectsIntersectVertically(r1: Rect, r2: Rect): Boolean { |
| return (r1.left >= r2.left && r1.left <= r2.right) || |
| (r1.right <= r2.right && r1.right >= r2.left) |
| } |
| } |
| } |