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
This commit is contained in:
Matthew DeVore
2024-12-13 19:53:31 +00:00
parent b33674eb4f
commit d1510f4bee
2 changed files with 276 additions and 58 deletions

View File

@@ -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_RIGHT
import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP
import android.util.Log import android.util.Log
import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.Button import android.widget.Button
@@ -161,9 +162,41 @@ class TopologyScale(
const val PREFERENCE_KEY = "display_topology_preference" 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 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 * DisplayTopologyPreference allows the user to change the display topology
* when there is one or more extended display attached. * when there is one or more extended display attached.
@@ -190,7 +223,7 @@ class DisplayTopologyPreference(context : Context)
key = PREFERENCE_KEY key = PREFERENCE_KEY
injector = Injector() injector = Injector(context)
} }
override fun onBindViewHolder(holder: PreferenceViewHolder) { override fun onBindViewHolder(holder: PreferenceViewHolder) {
@@ -223,74 +256,157 @@ class DisplayTopologyPreference(context : Context)
} }
} }
open class Injector { open class Injector(val context : Context) {
open fun displayTopology(context : Context) : DisplayTopology? { /**
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager * Lazy property for Display Manager, to prevent eagerly getting the service in unit tests.
return displayManager.displayTopology */
private val displayManager : DisplayManager by lazy {
context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
} }
open fun wallpaper(context : Context) : Drawable { open var displayTopology : DisplayTopology?
return WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK) 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<Int, RectF>, n : DisplayTopology.TreeNode, x : Float, y : Float) { * Holds information about the current system topology.
dest.put(n.displayId, RectF(x, y, x + n.width, y + n.height)) * @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<Pair<Int, RectF>>)
for (c in n.children) { /**
val (xoff, yoff) = when (c.position) { * Holds information about the current drag operation.
POSITION_LEFT -> Pair(-c.width, +c.offset) * @param stationaryDisps ID and position of displays that are not moving
POSITION_RIGHT -> Pair(+n.width, +c.offset) * @param display View that is currently being dragged
POSITION_TOP -> Pair(+c.offset, -c.height) * @param displayId ID of display being dragged
POSITION_BOTTOM -> Pair(+c.offset, +n.height) * @param displayWidth width of display being dragged in actual (not View) coordinates
else -> throw IllegalStateException("invalid position for display: ${c}") * @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
calcAbsRects(dest, c, x + xoff, y + yoff) * @param dragOffsetY difference between event rawY coordinate and Y of the display in the pane
} */
private data class BlockDrag(
val stationaryDisps : List<Pair<Int, RectF>>,
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<DisplayBlock>()
for (i in 0..mPaneContent.childCount-1) {
recycleableBlocks.add(mPaneContent.getChildAt(i) as DisplayBlock)
} }
private fun refreshPane() { val topology = injector.displayTopology
mPaneContent.removeAllViews() if (topology == null) {
val root = injector.displayTopology(context)?.root
if (root == null) {
// This occurs when no topology is active. // This occurs when no topology is active.
// TODO(b/352648432): show main display or mirrored displays rather than an empty pane. // TODO(b/352648432): show main display or mirrored displays rather than an empty pane.
mTopologyHint.text = "" mTopologyHint.text = ""
mPaneContent.removeAllViews()
mTopologyInfo = null
return return
} }
mTopologyHint.text = context.getString(R.string.external_display_topology_hint) 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( 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 { mPaneHolder.layoutParams.let {
if (it.height != scaling.paneHeight) { if (it.height != scaling.paneHeight) {
it.height = scaling.paneHeight it.height = scaling.paneHeight
mPaneHolder.layoutParams = it mPaneHolder.layoutParams = it
} }
} }
val wallpaper = injector.wallpaper(context)
blocksPos.values.forEach { p -> blocksPos.forEach { (id, pos) ->
Button(context).apply { val block = recycleableBlocks.removeFirstOrNull() ?: DisplayBlock(context).apply {
isScrollContainer = false // We need a separate wallpaper Drawable for each display block, since each needs to
isVerticalScrollBarEnabled = false // be drawn at a separate size.
isHorizontalScrollBarEnabled = false background = injector.wallpaper
background = wallpaper
val topLeft = scaling.displayToPaneCoor(PointF(p.left, p.top))
val bottomRight = scaling.displayToPaneCoor(PointF(p.right, p.bottom))
mPaneContent.addView(this) mPaneContent.addView(this)
}
val layout = layoutParams block.placeAndSize(pos, scaling)
layout.width = bottomRight.x - topLeft.x - BLOCK_PADDING * 2 block.setOnTouchListener { view, ev ->
layout.height = bottomRight.y - topLeft.y - BLOCK_PADDING * 2 when (ev.actionMasked) {
layoutParams = layout MotionEvent.ACTION_DOWN -> onBlockTouchDown(id, pos, block, ev)
x = (topLeft.x + BLOCK_PADDING).toFloat() MotionEvent.ACTION_MOVE -> onBlockTouchMove(ev)
y = (topLeft.y + BLOCK_PADDING).toFloat() 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
}
} }

View File

@@ -16,6 +16,7 @@
package com.android.settings.connecteddevice.display package com.android.settings.connecteddevice.display
import android.hardware.display.DisplayTopology.TreeNode.POSITION_BOTTOM
import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT
import android.content.Context import android.content.Context
@@ -23,10 +24,12 @@ import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.hardware.display.DisplayTopology import android.hardware.display.DisplayTopology
import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.preference.PreferenceViewHolder import androidx.preference.PreferenceViewHolder
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.core.view.MotionEventBuilder
import com.android.settings.R import com.android.settings.R
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@@ -39,7 +42,7 @@ import org.robolectric.RobolectricTestRunner
class DisplayTopologyPreferenceTest { class DisplayTopologyPreferenceTest {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
val preference = DisplayTopologyPreference(context) val preference = DisplayTopologyPreference(context)
val injector = TestInjector() val injector = TestInjector(context)
val rootView = View.inflate(context, preference.layoutResource, /*parent=*/ null) val rootView = View.inflate(context, preference.layoutResource, /*parent=*/ null)
val holder = PreferenceViewHolder.createInstanceForTests(rootView) val holder = PreferenceViewHolder.createInstanceForTests(rootView)
val wallpaper = ColorDrawable(Color.MAGENTA) val wallpaper = ColorDrawable(Color.MAGENTA)
@@ -50,13 +53,16 @@ class DisplayTopologyPreferenceTest {
preference.onBindViewHolder(holder) preference.onBindViewHolder(holder)
} }
class TestInjector : DisplayTopologyPreference.Injector() { class TestInjector(context : Context) : DisplayTopologyPreference.Injector(context) {
var topology : DisplayTopology? = null var topology : DisplayTopology? = null
var systemWallpaper : Drawable? = 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 @Test
@@ -70,8 +76,16 @@ class DisplayTopologyPreferenceTest {
assertThat(preference.mTopologyHint.text).isEqualTo("") assertThat(preference.mTopologyHint.text).isEqualTo("")
} }
@Test private fun getPaneChildren(): List<DisplayBlock> =
fun twoDisplaysGenerateBlocks() { (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<DisplayBlock> {
val child = DisplayTopology.TreeNode( val child = DisplayTopology.TreeNode(
/* displayId= */ 42, /* width= */ 100f, /* height= */ 80f, /* displayId= */ 42, /* width= */ 100f, /* height= */ 80f,
POSITION_LEFT, /* offset= */ 42f) POSITION_LEFT, /* offset= */ 42f)
@@ -93,15 +107,19 @@ class DisplayTopologyPreferenceTest {
preference.onAttached() preference.onAttached()
preference.onGlobalLayout() preference.onGlobalLayout()
assertThat(preference.mPaneContent.childCount).isEqualTo(2) val paneChildren = getPaneChildren()
val block0 = preference.mPaneContent.getChildAt(0) assertThat(paneChildren).hasSize(2)
val block1 = preference.mPaneContent.getChildAt(1)
// Block of child display is on the left. // Block of child display is on the left.
val (childBlock, rootBlock) = if (block0.x < block1.x) return if (paneChildren[0].x < paneChildren[1].x)
listOf(block0, block1) paneChildren
else 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. // After accounting for padding, child should be half the length of root in each dimension.
assertThat(childBlock.layoutParams.width + BLOCK_PADDING) assertThat(childBlock.layoutParams.width + BLOCK_PADDING)
@@ -109,12 +127,96 @@ class DisplayTopologyPreferenceTest {
assertThat(childBlock.layoutParams.height + BLOCK_PADDING) assertThat(childBlock.layoutParams.height + BLOCK_PADDING)
.isEqualTo(rootBlock.layoutParams.height / 2) .isEqualTo(rootBlock.layoutParams.height / 2)
assertThat(childBlock.y).isGreaterThan(rootBlock.y) assertThat(childBlock.y).isGreaterThan(rootBlock.y)
assertThat(block0.background).isEqualTo(wallpaper) assertThat(childBlock.background).isEqualTo(wallpaper)
assertThat(block1.background).isEqualTo(wallpaper) assertThat(rootBlock.background).isEqualTo(wallpaper)
assertThat(rootBlock.x - BLOCK_PADDING * 2) assertThat(rootBlock.x - BLOCK_PADDING * 2)
.isEqualTo(childBlock.x + childBlock.layoutParams.width) .isEqualTo(childBlock.x + childBlock.layoutParams.width)
assertThat(preference.mTopologyHint.text) assertThat(preference.mTopologyHint.text)
.isEqualTo(context.getString(R.string.external_display_topology_hint)) .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)
}
} }