From b51d99a7ea62769eb5a86fe98f2b29fafd3c52db Mon Sep 17 00:00:00 2001 From: Joshua Tsuji Date: Thu, 30 Jan 2020 17:58:14 -0500 Subject: [PATCH] Adds the FloatingContentCoordinator. This class helps PIP, Bubbles, and future additions to the floating family peacefully co-exist. Design doc: go/pip-bubbles-unification#heading=h.ip7f8yd5i199 This is the initial CL - a followup will add the permanent/temporary move logic, which restores floating content's original positions if a move is aborted. Bug: 138115889 Test: atest SystemUITests Change-Id: I762ce25700523ff581e1dc827de3bb9128ea97a2 --- .../util/FloatingContentCoordinator.kt | 320 ++++++++++++++++++ .../util/FloatingContentCoordinatorTest.kt | 218 ++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/util/FloatingContentCoordinator.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/util/FloatingContentCoordinatorTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/util/FloatingContentCoordinator.kt b/packages/SystemUI/src/com/android/systemui/util/FloatingContentCoordinator.kt new file mode 100644 index 0000000000000..70bcc21426b14 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/FloatingContentCoordinator.kt @@ -0,0 +1,320 @@ +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 { + return findAreaForContentVertically( + getFloatingBoundsOnScreen(), + overlappingContentBounds, + otherContentBounds, + getAllowedFloatingBoundsRegion()) + } + } + + /** The bounds of all pieces of floating content added to the coordinator. */ + private val allContentBounds: MutableMap = 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 { + 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, + 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, + 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) + } + } +} \ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/FloatingContentCoordinatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/FloatingContentCoordinatorTest.kt new file mode 100644 index 0000000000000..8eecde1f4f7c6 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/util/FloatingContentCoordinatorTest.kt @@ -0,0 +1,218 @@ +package com.android.systemui.util + +import android.graphics.Rect +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@TestableLooper.RunWithLooper +@RunWith(AndroidTestingRunner::class) +@SmallTest +class FloatingContentCoordinatorTest : SysuiTestCase() { + + private val screenBounds = Rect(0, 0, 1000, 1000) + + private val rect100px = Rect() + private val rect100pxFloating = FloatingRect(rect100px) + + private val rect200px = Rect() + private val rect200pxFloating = FloatingRect(rect200px) + + private val rect300px = Rect() + private val rect300pxFloating = FloatingRect(rect300px) + + private val floatingCoordinator = FloatingContentCoordinator() + + @Before + fun setup() { + rect100px.set(0, 0, 100, 100) + rect200px.set(0, 0, 200, 200) + rect300px.set(0, 0, 300, 300) + } + + @After + fun tearDown() { + // We need to remove this stuff since it's a singleton object and it'll be there for the + // next test. + floatingCoordinator.onContentRemoved(rect100pxFloating) + floatingCoordinator.onContentRemoved(rect200pxFloating) + floatingCoordinator.onContentRemoved(rect300pxFloating) + } + + @Test + fun testOnContentAdded() { + // Add rect1, and verify that the coordinator didn't move it. + floatingCoordinator.onContentAdded(rect100pxFloating) + assertEquals(rect100px.top, 0) + + // Add rect2, which intersects rect1. Verify that rect2 was not moved, since newly added + // content is allowed to remain where it is. rect1 should have been moved below rect2 + // since it was in the way. + floatingCoordinator.onContentAdded(rect200pxFloating) + assertEquals(rect200px.top, 0) + assertEquals(rect100px.top, 200) + + verifyRectSizes() + } + + @Test + fun testOnContentRemoved() { + // Add rect1, and remove it. Then add rect2. Since rect1 was removed before that, it should + // no longer be considered in the way, so it shouldn't move when rect2 is added. + floatingCoordinator.onContentAdded(rect100pxFloating) + floatingCoordinator.onContentRemoved(rect100pxFloating) + floatingCoordinator.onContentAdded(rect200pxFloating) + + assertEquals(rect100px.top, 0) + assertEquals(rect200px.top, 0) + + verifyRectSizes() + } + + @Test + fun testOnContentMoved_twoRects() { + // Add rect1, which is at y = 0. + floatingCoordinator.onContentAdded(rect100pxFloating) + + // Move rect2 down to 500px, where it won't conflict with rect1. + rect200px.offsetTo(0, 500) + floatingCoordinator.onContentAdded(rect200pxFloating) + + // Then, move it to 0px where it will absolutely conflict with rect1. + rect200px.offsetTo(0, 0) + floatingCoordinator.onContentMoved(rect200pxFloating) + + // The coordinator should have left rect2 alone, and moved rect1 below it. rect1 should now + // be at y = 200. + assertEquals(rect200px.top, 0) + assertEquals(rect100px.top, 200) + + verifyRectSizes() + + // Move rect2 to y = 275px. Since this puts it at the bottom half of rect1, it should push + // rect1 upward and leave rect2 alone. + rect200px.offsetTo(0, 275) + floatingCoordinator.onContentMoved(rect200pxFloating) + + assertEquals(rect200px.top, 275) + assertEquals(rect100px.top, 175) + + verifyRectSizes() + + // Move rect2 to y = 110px. This makes it intersect rect1 again, but above its center of + // mass. That means rect1 should be pushed downward. + rect200px.offsetTo(0, 110) + floatingCoordinator.onContentMoved(rect200pxFloating) + + assertEquals(rect200px.top, 110) + assertEquals(rect100px.top, 310) + + verifyRectSizes() + } + + @Test + fun testOnContentMoved_threeRects() { + floatingCoordinator.onContentAdded(rect100pxFloating) + + // Add rect2, which should displace rect1 to y = 200 + floatingCoordinator.onContentAdded(rect200pxFloating) + assertEquals(rect200px.top, 0) + assertEquals(rect100px.top, 200) + + // Add rect3, which should completely cover both rect1 and rect2. That should cause them to + // move away. The order in which they do so is non-deterministic, so just make sure none of + // the three Rects intersect. + floatingCoordinator.onContentAdded(rect300pxFloating) + + assertFalse(Rect.intersects(rect100px, rect200px)) + assertFalse(Rect.intersects(rect100px, rect300px)) + assertFalse(Rect.intersects(rect200px, rect300px)) + + // Move rect2 to intersect both rect1 and rect3. + rect200px.offsetTo(0, 150) + floatingCoordinator.onContentMoved(rect200pxFloating) + + assertFalse(Rect.intersects(rect100px, rect200px)) + assertFalse(Rect.intersects(rect100px, rect300px)) + assertFalse(Rect.intersects(rect200px, rect300px)) + } + + @Test + fun testOnContentMoved_respectsUpperBounds() { + // Add rect1, which is at y = 0. + floatingCoordinator.onContentAdded(rect100pxFloating) + + // Move rect2 down to 500px, where it won't conflict with rect1. + rect200px.offsetTo(0, 500) + floatingCoordinator.onContentAdded(rect200pxFloating) + + // Then, move it to 90px where it will conflict with rect1, but with a center of mass below + // that of rect1's. This would normally mean that rect1 moves upward. However, since it's at + // the top of the screen, it should go downward instead. + rect200px.offsetTo(0, 90) + floatingCoordinator.onContentMoved(rect200pxFloating) + + // rect2 should have been left alone, rect1 is now below rect2 at y = 290px even though it + // was intersected from below. + assertEquals(rect200px.top, 90) + assertEquals(rect100px.top, 290) + } + + @Test + fun testOnContentMoved_respectsLowerBounds() { + // Put rect1 at the bottom of the screen and add it. + rect100px.offsetTo(0, screenBounds.bottom - 100) + floatingCoordinator.onContentAdded(rect100pxFloating) + + // Put rect2 at the bottom as well. Since its center of mass is above rect1's, rect1 would + // normally move downward. Since it's at the bottom of the screen, it should go upward + // instead. + rect200px.offsetTo(0, 800) + floatingCoordinator.onContentAdded(rect200pxFloating) + + assertEquals(rect200px.top, 800) + assertEquals(rect100px.top, 700) + } + + /** + * Tests that the rect sizes didn't change when the coordinator manipulated them. This allows us + * to assert only the value of rect.top in tests, since if top, width, and height are correct, + * that means top/left/right/bottom are all correct. + */ + private fun verifyRectSizes() { + assertEquals(100, rect100px.width()) + assertEquals(200, rect200px.width()) + assertEquals(300, rect300px.width()) + + assertEquals(100, rect100px.height()) + assertEquals(200, rect200px.height()) + assertEquals(300, rect300px.height()) + } + + /** + * Helper class that uses [floatingCoordinator.findAreaForContentVertically] to move a + * Rect when needed. + */ + inner class FloatingRect( + private val underlyingRect: Rect + ) : FloatingContentCoordinator.FloatingContent { + override fun moveToBounds(bounds: Rect) { + underlyingRect.set(bounds) + } + + override fun getAllowedFloatingBoundsRegion(): Rect { + return screenBounds + } + + override fun getFloatingBoundsOnScreen(): Rect { + return underlyingRect + } + } +} \ No newline at end of file