From 9da00fbe8843813e418c6bc2eea3f870d93e3e47 Mon Sep 17 00:00:00 2001 From: Nikolay Rykunov Date: Tue, 31 Jan 2023 22:20:32 +0100 Subject: [PATCH] Introduce tests for external drag support https://github.com/JetBrains/compose-jb/issues/222 --- .../compose/ui/dnd/ExternalDrag.desktop.kt | 165 ++++++----- .../compose/ui/dnd/ExternalDragTest.kt | 266 ++++++++++++++++++ 2 files changed, 360 insertions(+), 71 deletions(-) create mode 100644 compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/dnd/ExternalDragTest.kt diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/dnd/ExternalDrag.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/dnd/ExternalDrag.desktop.kt index 4b7eca954de64..736db8cdeeeec 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/dnd/ExternalDrag.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/dnd/ExternalDrag.desktop.kt @@ -142,8 +142,10 @@ fun Modifier.onExternalDrag( * Provides a way to subscribe on external drag for given [window] using [installComponentDragHandler] * * [Window] allows having only one [DropTarget], so this is the main [DropTarget] that handles all the drag subscriptions + * + * @VisibleForTesting */ -private class AwtWindowDropTarget( +internal class AwtWindowDropTarget( private val window: Window ) : DropTarget(window, DnDConstants.ACTION_MOVE, null, true) { private var idsCounter = 0 @@ -154,72 +156,92 @@ private class AwtWindowDropTarget( // drag coordinates used to detect that drag entered/exited components private var windowDragCoordinates: Offset? = null - init { - addDropTargetListener( - AwtWindowDragTargetListener( - window, - // notify components on window border that drag is started. - onDragEnterWindow = { newWindowDragCoordinates -> - for ((_, handler) in handlers) { - val isInside = - isExternalDragInsideComponent(handler.componentCoordinates, newWindowDragCoordinates) - if (isInside) { - handler.onDragStart(calculateOffset(handler.componentCoordinates, newWindowDragCoordinates)) - } - } - windowDragCoordinates = newWindowDragCoordinates - }, - // drag moved inside window, we should calculate whether drag entered/exited components or just moved inside them - onDragInsideWindow = { newWindowDragCoordinates -> - for ((_, handler) in handlers) { - val componentCoordinates = handler.componentCoordinates - val oldDragCoordinates = windowDragCoordinates - - val wasDragInside = isExternalDragInsideComponent(componentCoordinates, oldDragCoordinates) - val newIsDragInside = - isExternalDragInsideComponent(componentCoordinates, newWindowDragCoordinates) - - if (!wasDragInside && newIsDragInside) { - handler.onDragStart(calculateOffset(componentCoordinates, newWindowDragCoordinates)) - } - - if (wasDragInside && !newIsDragInside) { - handler.onDragCancel() - } - - if (newIsDragInside) { - handler.onDrag(componentCoordinates.windowToLocal(newWindowDragCoordinates)) - } - } - windowDragCoordinates = newWindowDragCoordinates - }, - // notify components on window border drag exited window - onDragExit = { - for ((_, handler) in handlers) { - val componentCoordinates = handler.componentCoordinates - val oldDragCoordinates = windowDragCoordinates - val wasDragInside = isExternalDragInsideComponent(componentCoordinates, oldDragCoordinates) - if (wasDragInside) { - handler.onDragCancel() - } - } - windowDragCoordinates = null - }, - // notify all components under the pointer that drop happened - onDrop = { - var anyDrops = false - for ((_, handler) in handlers) { - if (isExternalDragInsideComponent(handler.componentCoordinates, windowDragCoordinates)) { - handler.onDrop(it) - anyDrops = true - } - } - windowDragCoordinates = null - // tell swing whether some components accepted the drop - return@AwtWindowDragTargetListener anyDrops + // @VisibleForTesting + val dragTargetListener = AwtWindowDragTargetListener( + window, + // notify components on window border that drag is started. + onDragEnterWindow = { newWindowDragCoordinates -> + for ((_, handler) in handlers) { + val isInside = + isExternalDragInsideComponent( + handler.componentCoordinates, + newWindowDragCoordinates + ) + if (isInside) { + handler.onDragStart( + calculateOffset( + handler.componentCoordinates, + newWindowDragCoordinates + ) + ) + } + } + windowDragCoordinates = newWindowDragCoordinates + }, + // drag moved inside window, we should calculate whether drag entered/exited components or just moved inside them + onDragInsideWindow = { newWindowDragCoordinates -> + for ((_, handler) in handlers) { + val componentCoordinates = handler.componentCoordinates + val oldDragCoordinates = windowDragCoordinates + + val wasDragInside = + isExternalDragInsideComponent(componentCoordinates, oldDragCoordinates) + val newIsDragInside = + isExternalDragInsideComponent(componentCoordinates, newWindowDragCoordinates) + + if (!wasDragInside && newIsDragInside) { + handler.onDragStart( + calculateOffset( + componentCoordinates, + newWindowDragCoordinates + ) + ) + } + + if (wasDragInside && !newIsDragInside) { + handler.onDragCancel() + } + + if (newIsDragInside) { + handler.onDrag(componentCoordinates.windowToLocal(newWindowDragCoordinates)) + } + } + windowDragCoordinates = newWindowDragCoordinates + }, + // notify components on window border drag exited window + onDragExit = { + for ((_, handler) in handlers) { + val componentCoordinates = handler.componentCoordinates + val oldDragCoordinates = windowDragCoordinates + val wasDragInside = + isExternalDragInsideComponent(componentCoordinates, oldDragCoordinates) + if (wasDragInside) { + handler.onDragCancel() } - ) - ) + } + windowDragCoordinates = null + }, + // notify all components under the pointer that drop happened + onDrop = { + var anyDrops = false + for ((_, handler) in handlers) { + if (isExternalDragInsideComponent( + handler.componentCoordinates, + windowDragCoordinates + ) + ) { + handler.onDrop(it) + anyDrops = true + } + } + windowDragCoordinates = null + // tell swing whether some components accepted the drop + return@AwtWindowDragTargetListener anyDrops + } + ) + + init { + addDropTargetListener(dragTargetListener) } override fun setActive(isActive: Boolean) { @@ -303,12 +325,13 @@ private class AwtWindowDropTarget( } } -private class AwtWindowDragTargetListener( +// @VisibleForTesting +internal class AwtWindowDragTargetListener( private val window: Window, - private val onDragEnterWindow: (Offset) -> Unit, - private val onDragInsideWindow: (Offset) -> Unit, - private val onDragExit: () -> Unit, - private val onDrop: (DropData) -> Boolean, + val onDragEnterWindow: (Offset) -> Unit, + val onDragInsideWindow: (Offset) -> Unit, + val onDragExit: () -> Unit, + val onDrop: (DropData) -> Boolean, ) : DropTargetListener { private val density = window.density.density diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/dnd/ExternalDragTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/dnd/ExternalDragTest.kt new file mode 100644 index 0000000000000..32f7ccdfd8225 --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/dnd/ExternalDragTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.dnd + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.dnd.ExternalDragTest.TestDragEvent.Drag +import androidx.compose.ui.dnd.ExternalDragTest.TestDragEvent.DragCancelled +import androidx.compose.ui.dnd.ExternalDragTest.TestDragEvent.DragStarted +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.density +import androidx.compose.ui.window.launchApplication +import androidx.compose.ui.window.rememberWindowState +import androidx.compose.ui.window.runApplicationTest +import com.google.common.truth.Truth.assertThat +import java.awt.Window +import org.junit.Test + +class ExternalDragTest { + @Test + fun `drag inside component that close to top left corner`() = runApplicationTest { + lateinit var window: ComposeWindow + + val events = mutableListOf() + + launchApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 200.dp, height = 100.dp) + ) { + window = this.window + + Column { + Box( + modifier = Modifier.fillMaxSize() + .saveExternalDragEvents(events) + ) + } + } + } + + awaitIdle() + assertThat(events.size).isEqualTo(0) + + window.dragEvents { + onDragEnterWindow(Offset(50f, 50f)) + } + awaitIdle() + assertThat(events.size).isEqualTo(1) + assertThat(events.last()).isEqualTo(DragStarted(Offset(50f, 50f))) + + window.dragEvents { + onDragInsideWindow(Offset(70f, 70f)) + } + awaitIdle() + + assertThat(events.size).isEqualTo(2) + assertThat(events.last()).isEqualTo(Drag(Offset(70f, 70f))) + + exitApplication() + } + + + @Test + fun `drag enters component that far from top left corner`() = runApplicationTest { + lateinit var window: ComposeWindow + + val events = mutableListOf() + + launchApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 200.dp, height = 100.dp) + ) { + window = this.window + Column { + Spacer(modifier = Modifier.height(height = 25.dp)) + Box( + modifier = Modifier.fillMaxSize() + .saveExternalDragEvents(events) + ) + } + } + } + + awaitIdle() + val componentYOffset = with(window.density) { + 25.dp.toPx() + } + + assertThat(events.size).isEqualTo(0) + + window.dragEvents { + onDragEnterWindow(Offset(10f, 10f)) + } + awaitIdle() + assertThat(events.size).isEqualTo(0) + + window.dragEvents { + onDragInsideWindow(Offset(70f, componentYOffset + 1f)) + } + awaitIdle() + + assertThat(events.size).isEqualTo(2) + assertThat(events[0]).isEqualTo(DragStarted(Offset(70f, 1f))) + assertThat(events[1]).isEqualTo(Drag(Offset(70f, 1f))) + + exitApplication() + } + + @Test + fun `multiple components`() = runApplicationTest { + lateinit var window: ComposeWindow + + val eventsComponent1 = mutableListOf() + val eventsComponent2 = mutableListOf() + + launchApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 400.dp, height = 400.dp) + ) { + window = this.window + Column { + Box( + modifier = Modifier.size(100.dp, 100.dp) + .saveExternalDragEvents(eventsComponent1) + ) + Box( + modifier = Modifier.size(100.dp, 100.dp) + .saveExternalDragEvents(eventsComponent2) + ) + } + } + } + + awaitIdle() + val component2YOffset = with(window.density) { + 100.dp.toPx() + } + + assertThat(eventsComponent1.size).isEqualTo(0) + assertThat(eventsComponent2.size).isEqualTo(0) + + window.dragEvents { + onDragEnterWindow(Offset(10f, 10f)) + } + awaitIdle() + assertThat(eventsComponent1.size).isEqualTo(1) + assertThat(eventsComponent1.last()).isEqualTo(DragStarted(Offset(10f, 10f))) + + assertThat(eventsComponent2.size).isEqualTo(0) + + window.dragEvents { + onDragInsideWindow(Offset(70f, component2YOffset + 1f)) + } + awaitIdle() + + assertThat(eventsComponent1.size).isEqualTo(2) + assertThat(eventsComponent1.last()).isEqualTo(DragCancelled) + + assertThat(eventsComponent2.size).isEqualTo(2) + assertThat(eventsComponent2[0]).isEqualTo(DragStarted(Offset(70f, 1f))) + assertThat(eventsComponent2[1]).isEqualTo(Drag(Offset(70f, 1f))) + + val dropData = DropData.Text("Text", mimeType = "text/plain") + window.dragEvents { + onDrop(dropData) + } + awaitIdle() + + assertThat(eventsComponent1.size).isEqualTo(2) + + assertThat(eventsComponent2.size).isEqualTo(3) + assertThat(eventsComponent2.last()).isEqualTo(TestDragEvent.Drop(dropData)) + + exitApplication() + } + + @Test + fun `stop dnd handling when there are no components`() = runApplicationTest { + lateinit var window: ComposeWindow + + lateinit var componentIsVisible: MutableState + + launchApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 400.dp, height = 400.dp) + ) { + window = this.window + Column { + componentIsVisible = remember { mutableStateOf(true) } + if (componentIsVisible.value) { + Box( + modifier = Modifier.fillMaxSize().onExternalDrag() + ) + } + } + } + } + + awaitIdle() + assertThat(window.dropTarget.isActive).isEqualTo(true) + + componentIsVisible.value = false + awaitIdle() + assertThat(window.dropTarget.isActive).isEqualTo(false) + } + + private fun Window.dragEvents(eventsProvider: AwtWindowDragTargetListener.() -> Unit) { + val listener = (dropTarget as AwtWindowDropTarget).dragTargetListener + listener.eventsProvider() + } + + @Composable + private fun Modifier.saveExternalDragEvents(events: MutableList): Modifier { + return this.onExternalDrag( + onDragStart = { + events.add(DragStarted(it)) + }, + onDrop = { + events.add(TestDragEvent.Drop(it)) + }, + onDrag = { + events.add(Drag(it)) + }, + onDragCancel = { + events.add(DragCancelled) + } + ) + } + + private sealed interface TestDragEvent { + data class DragStarted(val offset: Offset) : TestDragEvent + object DragCancelled : TestDragEvent + data class Drag(val offset: Offset) : TestDragEvent + data class Drop(val data: DropData) : TestDragEvent + } +} \ No newline at end of file