Skip to content

Commit

Permalink
Disable default reaction on secondary mouse clicks when we click on B…
Browse files Browse the repository at this point in the history
…utton, move Slider, etc

This CL disables all non-left mouse buttons interactions for clickable, draggable, etc.

Now we have these event API's:
- low-level awaitPointerEvent, which triggers on any event (no matter if it is left or right mouse button)
- medium-level awaitFirstDown, which triggers only on left mouse event, and on any touch/stylus/eraser events. If we trigger it on right click too, the user can't distinguish it if it was left or right click, because we don't provide this information in PointerInputChange. In the future we can add something like `filter: (PointerEvent) -> Unit = PrimaryButtonFilter` to awaitFirstDown, and to other high-level API.
- high-level clickable, draggable, toggleable, which use awaitFirstDown under the hood

Alternative:
1. move PointerButtons from PointerEvent to PointerEventChange
2. filter events on clickable/toggleable/draggable level

Fixes JetBrains/compose-multiplatform#832

Change-Id: I32b1cbe283ab0119a49f63288f0cc56baa36d1e5
Test: ./gradlew :compose:foundation:foundation:connectedCheck
Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true

# Conflicts:
#	compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComposeWindowTest.kt
  • Loading branch information
igordmn authored and eymar committed Nov 16, 2022
1 parent df658a8 commit e6c1d93
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
Expand All @@ -53,6 +55,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.MouseInjectionScope
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasClickAction
Expand Down Expand Up @@ -204,6 +208,80 @@ class ClickableTest {
}
}

@OptIn(ExperimentalTestApi::class)
@Test
fun clickableTest_mousePrimaryClick() {
var counter = 0
val onClick: () -> Unit = {
++counter
}

rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier.testTag("myClickable").clickable(onClick = onClick)
)
}
}

rule.onNodeWithTag("myClickable")
.performMouseInput { click() }

rule.runOnIdle {
assertThat(counter).isEqualTo(1)
}

rule.onNodeWithTag("myClickable")
.performMouseInput { click() }

rule.runOnIdle {
assertThat(counter).isEqualTo(2)
}
}

@OptIn(ExperimentalTestApi::class)
@Test
fun clickableTest_mouseSecondaryClick() {
var counter = 0
val onClick: () -> Unit = {
++counter
}

rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier.testTag("myClickable").clickable(onClick = onClick)
)
}
}

rule.onNodeWithTag("myClickable")
.performMouseInput { secondaryClick() }

rule.runOnIdle {
assertThat(counter).isEqualTo(0)
}

rule.onNodeWithTag("myClickable")
.performMouseInput { secondaryClick() }

rule.runOnIdle {
assertThat(counter).isEqualTo(0)
}
}

@OptIn(ExperimentalTestApi::class)
private fun MouseInjectionScope.secondaryClick(position: Offset = center) {
if (position.isSpecified) {
updatePointerTo(position)
}
press(MouseButton.Secondary)
advanceEventTime(60L)
release(MouseButton.Secondary)
}

@Test
@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
fun clickableTest_clickWithEnterKey() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import androidx.compose.ui.platform.InspectableValue
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.MouseInjectionScope
import androidx.compose.ui.test.animateTo
import androidx.compose.ui.test.dragAndDrop
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
Expand Down Expand Up @@ -154,6 +158,77 @@ class DraggableTest {
}
}

@OptIn(ExperimentalTestApi::class)
@Test
fun draggable_dragWithPrimaryMouseButton() {
var total = 0f
setDraggableContent {
Modifier.draggable(Orientation.Horizontal) { total += it }
}
rule.onNodeWithTag(draggableBoxTag).performMouseInput {
dragAndDrop(
start = this.center,
end = Offset(this.center.x + 100f, this.center.y),
durationMillis = 100
)
}
val lastTotal = rule.runOnIdle {
assertThat(total).isGreaterThan(0)
total
}
rule.onNodeWithTag(draggableBoxTag).performMouseInput {
dragAndDrop(
start = this.center,
end = Offset(this.center.x, this.center.y + 100f),
durationMillis = 100
)
}
rule.runOnIdle {
assertThat(total).isEqualTo(lastTotal)
}
rule.onNodeWithTag(draggableBoxTag).performMouseInput {
dragAndDrop(
start = this.center,
end = Offset(this.center.x - 100f, this.center.y),
durationMillis = 100
)
}
rule.runOnIdle {
assertThat(total).isLessThan(0.01f)
}
}

@OptIn(ExperimentalTestApi::class)
@Test
fun draggable_dragWithSecondaryMouseButton() {
var total = 0f
setDraggableContent {
Modifier.draggable(Orientation.Horizontal) { total += it }
}
rule.onNodeWithTag(draggableBoxTag).performMouseInput {
dragAndDropSecondary(
start = this.center,
end = Offset(this.center.x + 100f, this.center.y),
durationMillis = 100
)
}
rule.runOnIdle {
assertThat(total).isEqualTo(0)
}
}

@OptIn(ExperimentalTestApi::class)
private fun MouseInjectionScope.dragAndDropSecondary(
start: Offset,
end: Offset,
durationMillis: Long = 300L
) {
updatePointerTo(start)
press(MouseButton.Secondary)
animateTo(end, durationMillis)
release(MouseButton.Secondary)
}

@Test
fun draggable_verticalDrag_newState() {
var total = 0f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.platform.InspectableValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.MouseInjectionScope
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasClickAction
Expand Down Expand Up @@ -238,6 +241,66 @@ class ToggleableTest {
}
}

@OptIn(ExperimentalTestApi::class)
@Test
fun toggleableTest_mouseToggle() {
var checked = true
val onCheckedChange: (Boolean) -> Unit = { checked = it }

rule.setContent {
Box {
Box(
Modifier.toggleable(value = checked, onValueChange = onCheckedChange),
content = {
BasicText("ToggleableText")
}
)
}
}

rule.onNode(isToggleable())
.performMouseInput { click() }

rule.runOnIdle {
assertThat(checked).isEqualTo(false)
}
}

@OptIn(ExperimentalTestApi::class)
@Test
fun toggleableTest_mouseSecondaryToggle() {
var checked = true
val onCheckedChange: (Boolean) -> Unit = { checked = it }

rule.setContent {
Box {
Box(
Modifier.toggleable(value = checked, onValueChange = onCheckedChange),
content = {
BasicText("ToggleableText")
}
)
}
}

rule.onNode(isToggleable())
.performMouseInput { secondaryClick() }

rule.runOnIdle {
assertThat(checked).isEqualTo(true)
}
}

@OptIn(ExperimentalTestApi::class)
private fun MouseInjectionScope.secondaryClick(position: Offset = center) {
if (position.isSpecified) {
updatePointerTo(position)
}
press(MouseButton.Secondary)
advanceEventTime(60L)
release(MouseButton.Secondary)
}

@Test
fun toggleableTest_toggle_consumedWhenDisabled() {
val enabled = mutableStateOf(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ package androidx.compose.foundation.gestures

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUp
Expand Down Expand Up @@ -241,6 +243,7 @@ internal suspend fun PointerInputScope.detectTapAndPress(
/**
* Reads events until the first down is received. If [requireUnconsumed] is `true` and the first
* down is consumed in the [PointerEventPass.Main] pass, that gesture is ignored.
* If it was down caused by [PointerType.Mouse], this function reacts only on primary button.
*/
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true
Expand All @@ -254,14 +257,18 @@ internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
var event: PointerEvent
do {
event = awaitPointerEvent(pass)
} while (
!event.changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
)
} while (!event.isPrimaryChangedDown(requireUnconsumed))
return event.changes[0]
}

private fun PointerEvent.isPrimaryChangedDown(requireUnconsumed: Boolean): Boolean {
val primaryButtonCausesDown = changes.fastAll { it.type == PointerType.Mouse }
val changedToDown = changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
return changedToDown && (buttons.isPrimaryPressed || !primaryButtonCausesDown)
}

/**
* Reads events until all pointers are up or the gesture was canceled. The gesture
* is considered canceled when a pointer leaves the event region, a position change
Expand Down

0 comments on commit e6c1d93

Please sign in to comment.