Merge "Allow drag/drop of display blocks" into main

This commit is contained in:
Matthew DeVore
2025-01-03 10:56:31 -08:00
committed by Android (Google) Code Review
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_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<Int, RectF>, 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<Pair<Int, RectF>>)
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<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() {
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
}
}

View File

@@ -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<Context>()
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<DisplayBlock> =
(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(
/* 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)
}
}