Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 4e781513 authored by Matthew DeVore's avatar Matthew DeVore Committed by Android (Google) Code Review
Browse files

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

parents 57e649ae d1510f4b
Loading
Loading
Loading
Loading
+161 −45
Original line number Diff line number Diff line
@@ -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
    }
}
+116 −14
Original line number Diff line number Diff line
@@ -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)
    }
}