From d1510f4beefe578c01a0af71953e1b24921659bf Mon Sep 17 00:00:00 2001 From: Matthew DeVore Date: Fri, 13 Dec 2024 19:53:31 +0000 Subject: [PATCH] Allow drag/drop of display blocks After dropping, apply the new topology to the DisplayManager. We assume the new topology is immediately written and read it back. We don't yet respond to updates of the topology from other apps or components; this will come in a follow-up patch soon. Flag: com.android.settings.flags.display_topology_pane_in_display_list Bug: b/352650922 Test: drag a display when there is only one in the topology Test: drag a display when there are two in the topology Test: close and re-open settings to verify a topology is persisted Test: atest DisplayTopologyPreferenceTest.kt Change-Id: I26aa7325570c5fd3e8b5fb60cb6e1196f8657b80 --- .../display/DisplayTopology.kt | 204 ++++++++++++++---- .../display/DisplayTopologyPreferenceTest.kt | 130 +++++++++-- 2 files changed, 276 insertions(+), 58 deletions(-) diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt index 9cac7727a7f..d77aa80a774 100644 --- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt +++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt @@ -33,6 +33,7 @@ import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT import android.hardware.display.DisplayTopology.TreeNode.POSITION_RIGHT import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP import android.util.Log +import android.view.MotionEvent import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.Button @@ -161,9 +162,41 @@ class TopologyScale( const val PREFERENCE_KEY = "display_topology_preference" -/** dp of padding on each side of a display block. */ +/** Padding in pane coordinate pixels on each side of a display block. */ const val BLOCK_PADDING = 2 +/** Represents a draggable block in the topology pane. */ +class DisplayBlock(context : Context) : Button(context) { + init { + isScrollContainer = false + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + } + + /** Sets position of the block given unpadded coordinates. */ + fun place(topLeft : Point) { + x = (topLeft.x + BLOCK_PADDING).toFloat() + y = (topLeft.y + BLOCK_PADDING).toFloat() + } + + val unpaddedX : Int + get() = (x - BLOCK_PADDING).toInt() + + val unpaddedY : Int + get() = (y - BLOCK_PADDING).toInt() + + /** Sets position and size of the block given unpadded bounds. */ + fun placeAndSize(bounds : RectF, scale : TopologyScale) { + val topLeft = scale.displayToPaneCoor(PointF(bounds.left, bounds.top)) + val bottomRight = scale.displayToPaneCoor(PointF(bounds.right, bounds.bottom)) + val layout = layoutParams + layout.width = bottomRight.x - topLeft.x - BLOCK_PADDING * 2 + layout.height = bottomRight.y - topLeft.y - BLOCK_PADDING * 2 + layoutParams = layout + place(topLeft) + } +} + /** * DisplayTopologyPreference allows the user to change the display topology * when there is one or more extended display attached. @@ -190,7 +223,7 @@ class DisplayTopologyPreference(context : Context) key = PREFERENCE_KEY - injector = Injector() + injector = Injector(context) } override fun onBindViewHolder(holder: PreferenceViewHolder) { @@ -223,74 +256,157 @@ class DisplayTopologyPreference(context : Context) } } - open class Injector { - open fun displayTopology(context : Context) : DisplayTopology? { - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - return displayManager.displayTopology + open class Injector(val context : Context) { + /** + * Lazy property for Display Manager, to prevent eagerly getting the service in unit tests. + */ + private val displayManager : DisplayManager by lazy { + context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager } - open fun wallpaper(context : Context) : Drawable { - return WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK) - } + open var displayTopology : DisplayTopology? + get() = displayManager.displayTopology + set(value) { displayManager.displayTopology = value } + + open val wallpaper : Drawable + get() = WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK) } - private fun calcAbsRects( - dest : MutableMap, n : DisplayTopology.TreeNode, x : Float, y : Float) { - dest.put(n.displayId, RectF(x, y, x + n.width, y + n.height)) + /** + * Holds information about the current system topology. + * @param positions list of displays comprised of the display ID and position + */ + private data class TopologyInfo( + val topology: DisplayTopology, val scaling: TopologyScale, + val positions: List>) - for (c in n.children) { - val (xoff, yoff) = when (c.position) { - POSITION_LEFT -> Pair(-c.width, +c.offset) - POSITION_RIGHT -> Pair(+n.width, +c.offset) - POSITION_TOP -> Pair(+c.offset, -c.height) - POSITION_BOTTOM -> Pair(+c.offset, +n.height) - else -> throw IllegalStateException("invalid position for display: ${c}") - } - calcAbsRects(dest, c, x + xoff, y + yoff) + /** + * Holds information about the current drag operation. + * @param stationaryDisps ID and position of displays that are not moving + * @param display View that is currently being dragged + * @param displayId ID of display being dragged + * @param displayWidth width of display being dragged in actual (not View) coordinates + * @param displayHeight height of display being dragged in actual (not View) coordinates + * @param dragOffsetX difference between event rawX coordinate and X of the display in the pane + * @param dragOffsetY difference between event rawY coordinate and Y of the display in the pane + */ + private data class BlockDrag( + val stationaryDisps : List>, + val display: DisplayBlock, val displayId: Int, + val displayWidth: Float, val displayHeight: Float, + val dragOffsetX: Float, val dragOffsetY: Float) + + private var mTopologyInfo : TopologyInfo? = null + private var mDrag : BlockDrag? = null + + @VisibleForTesting fun refreshPane() { + val recycleableBlocks = ArrayDeque() + for (i in 0..mPaneContent.childCount-1) { + recycleableBlocks.add(mPaneContent.getChildAt(i) as DisplayBlock) } - } - private fun refreshPane() { - mPaneContent.removeAllViews() - - val root = injector.displayTopology(context)?.root - if (root == null) { + val topology = injector.displayTopology + if (topology == null) { // This occurs when no topology is active. // TODO(b/352648432): show main display or mirrored displays rather than an empty pane. mTopologyHint.text = "" + mPaneContent.removeAllViews() + mTopologyInfo = null return } mTopologyHint.text = context.getString(R.string.external_display_topology_hint) - val blocksPos = buildMap { calcAbsRects(this, root, x = 0f, y = 0f) } + val blocksPos = buildList { + val bounds = topology.absoluteBounds + (0..bounds.size()-1).forEach { + add(Pair(bounds.keyAt(it), bounds.valueAt(it))) + } + } val scaling = TopologyScale( - mPaneContent.width, minEdgeLength = 60, maxBlockRatio = 0.12f, blocksPos.values) + mPaneContent.width, minEdgeLength = 60, maxBlockRatio = 0.12f, + blocksPos.map { it.second }.toList()) mPaneHolder.layoutParams.let { if (it.height != scaling.paneHeight) { it.height = scaling.paneHeight mPaneHolder.layoutParams = it } } - val wallpaper = injector.wallpaper(context) - blocksPos.values.forEach { p -> - Button(context).apply { - isScrollContainer = false - isVerticalScrollBarEnabled = false - isHorizontalScrollBarEnabled = false - background = wallpaper - val topLeft = scaling.displayToPaneCoor(PointF(p.left, p.top)) - val bottomRight = scaling.displayToPaneCoor(PointF(p.right, p.bottom)) + + blocksPos.forEach { (id, pos) -> + val block = recycleableBlocks.removeFirstOrNull() ?: DisplayBlock(context).apply { + // We need a separate wallpaper Drawable for each display block, since each needs to + // be drawn at a separate size. + background = injector.wallpaper mPaneContent.addView(this) + } - val layout = layoutParams - layout.width = bottomRight.x - topLeft.x - BLOCK_PADDING * 2 - layout.height = bottomRight.y - topLeft.y - BLOCK_PADDING * 2 - layoutParams = layout - x = (topLeft.x + BLOCK_PADDING).toFloat() - y = (topLeft.y + BLOCK_PADDING).toFloat() + block.placeAndSize(pos, scaling) + block.setOnTouchListener { view, ev -> + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> onBlockTouchDown(id, pos, block, ev) + MotionEvent.ACTION_MOVE -> onBlockTouchMove(ev) + MotionEvent.ACTION_UP -> onBlockTouchUp() + else -> false + } } } + mPaneContent.removeViews(blocksPos.size, recycleableBlocks.size) + + mTopologyInfo = TopologyInfo(topology, scaling, blocksPos) + } + + private fun onBlockTouchDown( + displayId: Int, displayPos: RectF, block: DisplayBlock, ev: MotionEvent): Boolean { + val stationaryDisps = (mTopologyInfo ?: return false) + .positions.filter { it.first != displayId } + + // We have to use rawX and rawY for the coordinates since the view receiving the event is + // also the view that is moving. We need coordinates relative to something that isn't + // moving, and the raw coordinates are relative to the screen. + mDrag = BlockDrag( + stationaryDisps.toList(), block, displayId, displayPos.width(), displayPos.height(), + ev.rawX - block.unpaddedX, ev.rawY - block.unpaddedY) + + // Prevents a container of this view from intercepting the touch events in the case the + // pointer moves outside of the display block or the pane. + mPaneContent.requestDisallowInterceptTouchEvent(true) + return true + } + + private fun onBlockTouchMove(ev: MotionEvent): Boolean { + val drag = mDrag ?: return false + val topology = mTopologyInfo ?: return false + val dispDragCoor = topology.scaling.paneToDisplayCoor(Point( + (ev.rawX - drag.dragOffsetX).toInt(), + (ev.rawY - drag.dragOffsetY).toInt())) + val dispDragRect = RectF( + dispDragCoor.x, dispDragCoor.y, + dispDragCoor.x + drag.displayWidth, dispDragCoor.y + drag.displayHeight) + val snapRect = clampPosition(drag.stationaryDisps.map { it.second }, dispDragRect) + + drag.display.place(topology.scaling.displayToPaneCoor(PointF(snapRect.left, snapRect.top))) + + return true + } + + private fun onBlockTouchUp(): Boolean { + val drag = mDrag ?: return false + val topology = mTopologyInfo ?: return false + mPaneContent.requestDisallowInterceptTouchEvent(false) + + val newCoor = topology.scaling.paneToDisplayCoor( + Point(drag.display.unpaddedX, drag.display.unpaddedY)) + val newTopology = topology.topology.copy() + val newPositions = drag.stationaryDisps.map { (id, pos) -> id to PointF(pos.left, pos.top) } + .plus(drag.displayId to newCoor) + + val arr = hashMapOf(*newPositions.toTypedArray()) + newTopology.rearrange(arr) + injector.displayTopology = newTopology + + refreshPane() + return true } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt index ad633cc8c8a..33cdb3eb92d 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt +++ b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt @@ -16,6 +16,7 @@ package com.android.settings.connecteddevice.display +import android.hardware.display.DisplayTopology.TreeNode.POSITION_BOTTOM import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT import android.content.Context @@ -23,10 +24,12 @@ import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.ColorDrawable import android.hardware.display.DisplayTopology +import android.view.MotionEvent import android.view.View import android.widget.FrameLayout import androidx.preference.PreferenceViewHolder import androidx.test.core.app.ApplicationProvider +import androidx.test.core.view.MotionEventBuilder import com.android.settings.R import com.google.common.truth.Truth.assertThat @@ -39,7 +42,7 @@ import org.robolectric.RobolectricTestRunner class DisplayTopologyPreferenceTest { val context = ApplicationProvider.getApplicationContext() val preference = DisplayTopologyPreference(context) - val injector = TestInjector() + val injector = TestInjector(context) val rootView = View.inflate(context, preference.layoutResource, /*parent=*/ null) val holder = PreferenceViewHolder.createInstanceForTests(rootView) val wallpaper = ColorDrawable(Color.MAGENTA) @@ -50,13 +53,16 @@ class DisplayTopologyPreferenceTest { preference.onBindViewHolder(holder) } - class TestInjector : DisplayTopologyPreference.Injector() { + class TestInjector(context : Context) : DisplayTopologyPreference.Injector(context) { var topology : DisplayTopology? = null var systemWallpaper : Drawable? = null - override fun displayTopology(context : Context) : DisplayTopology? { return topology } + override var displayTopology : DisplayTopology? + get() = topology + set(value) { topology = value } - override fun wallpaper(context : Context) : Drawable { return systemWallpaper!! } + override val wallpaper : Drawable + get() = systemWallpaper!! } @Test @@ -70,8 +76,16 @@ class DisplayTopologyPreferenceTest { assertThat(preference.mTopologyHint.text).isEqualTo("") } - @Test - fun twoDisplaysGenerateBlocks() { + private fun getPaneChildren(): List = + (0..preference.mPaneContent.childCount-1) + .map { preference.mPaneContent.getChildAt(it) as DisplayBlock } + .toList() + + /** + * Sets up a simple topology in the pane with two displays. Returns the left-hand display and + * right-hand display in order in a list. The right-hand display is the root. + */ + fun setupTwoDisplays(): List { val child = DisplayTopology.TreeNode( /* displayId= */ 42, /* width= */ 100f, /* height= */ 80f, POSITION_LEFT, /* offset= */ 42f) @@ -93,15 +107,19 @@ class DisplayTopologyPreferenceTest { preference.onAttached() preference.onGlobalLayout() - assertThat(preference.mPaneContent.childCount).isEqualTo(2) - val block0 = preference.mPaneContent.getChildAt(0) - val block1 = preference.mPaneContent.getChildAt(1) + val paneChildren = getPaneChildren() + assertThat(paneChildren).hasSize(2) // Block of child display is on the left. - val (childBlock, rootBlock) = if (block0.x < block1.x) - listOf(block0, block1) + return if (paneChildren[0].x < paneChildren[1].x) + paneChildren else - listOf(block1, block0) + paneChildren.reversed() + } + + @Test + fun twoDisplaysGenerateBlocks() { + val (childBlock, rootBlock) = setupTwoDisplays() // After accounting for padding, child should be half the length of root in each dimension. assertThat(childBlock.layoutParams.width + BLOCK_PADDING) @@ -109,12 +127,96 @@ class DisplayTopologyPreferenceTest { assertThat(childBlock.layoutParams.height + BLOCK_PADDING) .isEqualTo(rootBlock.layoutParams.height / 2) assertThat(childBlock.y).isGreaterThan(rootBlock.y) - assertThat(block0.background).isEqualTo(wallpaper) - assertThat(block1.background).isEqualTo(wallpaper) + assertThat(childBlock.background).isEqualTo(wallpaper) + assertThat(rootBlock.background).isEqualTo(wallpaper) assertThat(rootBlock.x - BLOCK_PADDING * 2) .isEqualTo(childBlock.x + childBlock.layoutParams.width) assertThat(preference.mTopologyHint.text) .isEqualTo(context.getString(R.string.external_display_topology_hint)) } + + @Test + fun dragDisplayDownward() { + val (leftBlock, rightBlock) = setupTwoDisplays() + + val downEvent = MotionEventBuilder.newBuilder() + .setPointer(0f, 0f) + .setAction(MotionEvent.ACTION_DOWN) + .build() + + // Move the left block half of its height downward. This is 40 pixels in display + // coordinates. The original offset is 42, so the new offset will be 42 + 40. + val moveEvent = MotionEventBuilder.newBuilder() + .setAction(MotionEvent.ACTION_MOVE) + .setPointer(0f, leftBlock.layoutParams.height / 2f + BLOCK_PADDING) + .build() + val upEvent = MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_UP).build() + + leftBlock.dispatchTouchEvent(downEvent) + leftBlock.dispatchTouchEvent(moveEvent) + leftBlock.dispatchTouchEvent(upEvent) + + val rootChildren = injector.topology!!.root!!.children + assertThat(rootChildren).hasSize(1) + val child = rootChildren[0] + assertThat(child.position).isEqualTo(POSITION_LEFT) + assertThat(child.offset).isWithin(1f).of(82f) + } + + @Test + fun dragRootDisplayToNewSide() { + val (leftBlock, rightBlock) = setupTwoDisplays() + + val downEvent = MotionEventBuilder.newBuilder() + .setAction(MotionEvent.ACTION_DOWN) + .setPointer(0f, 0f) + .build() + + // Move the right block left and upward. We won't move it into exactly the correct position, + // relying on the clamp algorithm to choose the correct side and offset. + val moveEvent = MotionEventBuilder.newBuilder() + .setAction(MotionEvent.ACTION_MOVE) + .setPointer( + -leftBlock.layoutParams.width - 2f * BLOCK_PADDING, + -leftBlock.layoutParams.height / 2f) + .build() + + val upEvent = MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_UP).build() + + assertThat(leftBlock.y).isGreaterThan(rightBlock.y) + + rightBlock.dispatchTouchEvent(downEvent) + rightBlock.dispatchTouchEvent(moveEvent) + rightBlock.dispatchTouchEvent(upEvent) + + val rootChildren = injector.topology!!.root!!.children + assertThat(rootChildren).hasSize(1) + val child = rootChildren[0] + assertThat(child.position).isEqualTo(POSITION_BOTTOM) + assertThat(child.offset).isWithin(1f).of(0f) + + // After rearranging blocks, the original block views should still be present. + val paneChildren = getPaneChildren() + assertThat(paneChildren.indexOf(leftBlock)).isNotEqualTo(-1) + assertThat(paneChildren.indexOf(rightBlock)).isNotEqualTo(-1) + + // Left edge of both blocks should be aligned after dragging. + assertThat(paneChildren[0].x) + .isWithin(1f) + .of(paneChildren[1].x) + } + + @Test + fun keepOriginalViewsWhenAddingMore() { + setupTwoDisplays() + val childrenBefore = getPaneChildren() + injector.topology!!.addDisplay(/* displayId= */ 101, 320f, 240f) + preference.refreshPane() + val childrenAfter = getPaneChildren() + + assertThat(childrenBefore).hasSize(2) + assertThat(childrenAfter).hasSize(3) + assertThat(childrenAfter.subList(0, 2)).isEqualTo(childrenBefore) + } }