blob: 745c482db4c5ed90cf363c7038f981f1eb739f80 [file] [log] [blame]
/*
* Copyright 2019 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.foundation
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.HoverInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
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.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.InspectableValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
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.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTouchHeightIsEqualTo
import androidx.compose.ui.test.assertTouchWidthIsEqualTo
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.click
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@MediumTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalFoundationApi::class)
class ClickableTest {
@get:Rule
val rule = createComposeRule()
@Before
fun before() {
isDebugInspectorInfoEnabled = true
}
@After
fun after() {
isDebugInspectorInfoEnabled = false
}
@Test
fun clickableTest_defaultSemantics() {
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier.testTag("myClickable").clickable {}
)
}
}
rule.onNodeWithTag("myClickable")
.assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
.assertIsEnabled()
.assertHasClickAction()
}
@Test
fun clickableTest_disabledSemantics() {
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier.testTag("myClickable").clickable(enabled = false) {}
)
}
}
rule.onNodeWithTag("myClickable")
.assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
.assertIsNotEnabled()
.assertHasClickAction()
}
@Test
fun clickableTest_longClickSemantics() {
var counter = 0
val onClick: () -> Unit = { ++counter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(onLongClick = onClick) {}
)
}
}
rule.onNodeWithTag("myClickable")
.assertIsEnabled()
.assert(SemanticsMatcher.keyIsDefined(SemanticsActions.OnLongClick))
rule.runOnIdle {
assertThat(counter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performSemanticsAction(SemanticsActions.OnLongClick)
rule.runOnIdle {
assertThat(counter).isEqualTo(1)
}
}
@Test
fun clickableTest_click() {
var counter = 0
val onClick: () -> Unit = {
++counter
}
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier.testTag("myClickable").clickable(onClick = onClick)
)
}
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.runOnIdle {
assertThat(counter).isEqualTo(1)
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.runOnIdle {
assertThat(counter).isEqualTo(2)
}
}
@Test
fun clickableTest_clickOnChildBasicText() {
var counter = 0
val onClick: () -> Unit = { ++counter }
rule.setContent {
Box(modifier = Modifier.clickable(onClick = onClick)) {
BasicText("Foo")
BasicText("Bar")
}
}
rule.onNodeWithText("Foo", substring = true).assertExists()
rule.onNodeWithText("Bar", substring = true).assertExists()
rule.onNodeWithText("Foo", substring = true).performClick()
rule.runOnIdle {
assertThat(counter).isEqualTo(1)
}
rule.onNodeWithText("Bar", substring = true).performClick()
rule.runOnIdle {
assertThat(counter).isEqualTo(2)
}
}
@Test
@LargeTest
fun clickableTest_longClick() {
var counter = 0
val onClick: () -> Unit = { ++counter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(onLongClick = onClick) {}
)
}
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
rule.runOnIdle {
assertThat(counter).isEqualTo(1)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
rule.runOnIdle {
assertThat(counter).isEqualTo(2)
}
}
@Test
fun clickableTest_click_withLongClick() {
var clickCounter = 0
var longClickCounter = 0
val onClick: () -> Unit = { ++clickCounter }
val onLongClick: () -> Unit = { ++longClickCounter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
onLongClick = onLongClick,
onClick = onClick
)
)
}
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
click()
}
rule.runOnIdle {
assertThat(clickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
rule.runOnIdle {
assertThat(clickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(1)
}
}
@Test
fun clickableTest_click_withDoubleClick() {
var clickCounter = 0
var doubleClickCounter = 0
val onClick: () -> Unit = { ++clickCounter }
val onDoubleClick: () -> Unit = { ++doubleClickCounter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
onDoubleClick = onDoubleClick,
onClick = onClick
)
)
}
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.mainClock.advanceTimeUntil { clickCounter == 1 }
rule.runOnIdle {
assertThat(clickCounter).isEqualTo(1)
assertThat(doubleClickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(clickCounter).isEqualTo(1)
}
}
@Test
@LargeTest
fun clickableTest_click_withDoubleClick_andLongClick() {
var clickCounter = 0
var doubleClickCounter = 0
var longClickCounter = 0
val onClick: () -> Unit = { ++clickCounter }
val onDoubleClick: () -> Unit = { ++doubleClickCounter }
val onLongClick: () -> Unit = { ++longClickCounter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
onDoubleClick = onDoubleClick,
onLongClick = onLongClick,
onClick = onClick
)
)
}
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.mainClock.advanceTimeUntil { clickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(1)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
rule.mainClock.advanceTimeUntil { doubleClickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(1)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
rule.mainClock.advanceTimeUntil { longClickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(1)
assertThat(clickCounter).isEqualTo(1)
}
}
@Test
fun clickableTest_doubleClick() {
var counter = 0
val onClick: () -> Unit = { ++counter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(onDoubleClick = onClick) {}
)
}
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
rule.mainClock.advanceTimeUntil { counter == 1 }
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
rule.mainClock.advanceTimeUntil { counter == 2 }
}
@Test
fun clickableTest_interactionSource() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput { down(center) }
val halfTapIndicationDelay = TapIndicationDelay / 2
rule.mainClock.advanceTimeBy(halfTapIndicationDelay)
// Haven't reached the tap delay yet, so we shouldn't have started a press
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
// Advance past the tap delay
rule.mainClock.advanceTimeBy(halfTapIndicationDelay)
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
}
rule.onNodeWithTag("myClickable")
.performTouchInput { up() }
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Release::class.java)
assertThat((interactions[1] as PressInteraction.Release).press)
.isEqualTo(interactions[0])
}
}
@Test
fun clickableTest_interactionSource_immediateRelease() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
down(center)
up()
}
// We haven't reached the tap delay, but we have finished a press so we should have
// emitted both press and release
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Release::class.java)
assertThat((interactions[1] as PressInteraction.Release).press)
.isEqualTo(interactions[0])
}
}
@Test
fun clickableTest_interactionSource_immediateCancel() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
down(center)
cancel()
}
// We haven't reached the tap delay, and a cancel was emitted, so no press should ever be
// shown
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
}
@Test
fun clickableTest_interactionSource_immediateDrag() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.draggable(
state = rememberDraggableState {},
orientation = Orientation.Horizontal
)
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
down(centerLeft)
moveTo(centerRight)
}
rule.mainClock.advanceTimeBy(TapIndicationDelay)
// We started a drag before the timeout, so no press should be emitted
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
}
@Test
fun clickableTest_interactionSource_dragAfterTimeout() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.draggable(
state = rememberDraggableState {},
orientation = Orientation.Horizontal
)
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
down(centerLeft)
}
rule.mainClock.advanceTimeBy(TapIndicationDelay)
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
moveTo(centerRight)
}
// The drag should cancel the press
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Cancel::class.java)
assertThat((interactions[1] as PressInteraction.Cancel).press)
.isEqualTo(interactions[0])
}
}
@Test
fun clickableTest_interactionSource_cancelledGesture() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput { down(center) }
rule.mainClock.advanceTimeBy(TapIndicationDelay)
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
}
rule.onNodeWithTag("myClickable")
.performTouchInput { cancel() }
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Cancel::class.java)
assertThat((interactions[1] as PressInteraction.Cancel).press)
.isEqualTo(interactions[0])
}
}
@Test
fun clickableTest_interactionSource_resetWhenDisposed() {
val interactionSource = MutableInteractionSource()
var emitClickableText by mutableStateOf(true)
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
if (emitClickableText) {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performTouchInput { down(center) }
rule.mainClock.advanceTimeBy(TapIndicationDelay)
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
}
// Dispose clickable
rule.runOnIdle {
emitClickableText = false
}
rule.mainClock.advanceTimeByFrame()
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Cancel::class.java)
assertThat((interactions[1] as PressInteraction.Cancel).press)
.isEqualTo(interactions[0])
}
}
@OptIn(ExperimentalTestApi::class)
@Test
fun clickableTest_interactionSource_hover() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performMouseInput { enter(center) }
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
}
rule.onNodeWithTag("myClickable")
.performMouseInput { exit(Offset(-1f, -1f)) }
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
assertThat(interactions[1])
.isInstanceOf(HoverInteraction.Exit::class.java)
assertThat((interactions[1] as HoverInteraction.Exit).enter)
.isEqualTo(interactions[0])
}
}
@OptIn(ExperimentalTestApi::class)
@Test
fun clickableTest_interactionSource_hover_and_press() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.onNodeWithTag("myClickable")
.performMouseInput {
enter(center)
click()
exit(Offset(-1f, -1f))
}
rule.runOnIdle {
assertThat(interactions).hasSize(4)
assertThat(interactions[0]).isInstanceOf(HoverInteraction.Enter::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[2]).isInstanceOf(PressInteraction.Release::class.java)
assertThat(interactions[3]).isInstanceOf(HoverInteraction.Exit::class.java)
assertThat((interactions[2] as PressInteraction.Release).press)
.isEqualTo(interactions[1])
assertThat((interactions[3] as HoverInteraction.Exit).enter)
.isEqualTo(interactions[0])
}
}
@Test
fun clickableTest_interactionSource_focus_inTouchMode() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
val focusRequester = FocusRequester()
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.focusRequester(focusRequester)
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.runOnIdle {
focusRequester.requestFocus()
}
// Touch mode by default, so we shouldn't be focused
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
}
@Test
fun clickableTest_interactionSource_focus_inKeyboardMode() {
val interactionSource = MutableInteractionSource()
lateinit var scope: CoroutineScope
val focusRequester = FocusRequester()
lateinit var focusManager: FocusManager
val keyboardInputModeManager = object : InputModeManager {
override val inputMode = InputMode.Keyboard
@OptIn(ExperimentalComposeUiApi::class)
override fun requestInputMode(inputMode: InputMode) = true
}
rule.setContent {
scope = rememberCoroutineScope()
focusManager = LocalFocusManager.current
CompositionLocalProvider(LocalInputModeManager provides keyboardInputModeManager) {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.focusRequester(focusRequester)
.combinedClickable(
interactionSource = interactionSource,
indication = null
) {}
)
}
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
}
rule.runOnIdle {
focusRequester.requestFocus()
}
// Keyboard mode, so we should now be focused and see an interaction
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
}
rule.runOnIdle {
focusManager.clearFocus()
}
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
assertThat(interactions[1])
.isInstanceOf(FocusInteraction.Unfocus::class.java)
assertThat((interactions[1] as FocusInteraction.Unfocus).focus)
.isEqualTo(interactions[0])
}
}
// TODO: b/202871171 - add test for changing between keyboard mode and touch mode, making sure
// it resets existing focus
/**
* Regression test for b/186223077
*
* Tests that if a long click causes the long click lambda to change instances, we will still
* correctly wait for the up event and emit [PressInteraction.Release].
*/
@Test
@LargeTest
fun clickableTest_longClick_interactionSource_continuesTrackingPressAfterLambdasChange() {
val interactionSource = MutableInteractionSource()
var onLongClick by mutableStateOf({})
val finalLongClick = {}
val initialLongClick = { onLongClick = finalLongClick }
// Simulate the long click causing a recomposition, and changing the lambda instance
onLongClick = initialLongClick
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
onLongClick = onLongClick,
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
assertThat(onLongClick).isEqualTo(initialLongClick)
}
rule.onNodeWithTag("myClickable")
.performTouchInput { down(center) }
// Simulate a long click
rule.mainClock.advanceTimeBy(1000)
// Run another frame to trigger recomposition caused by the long click
rule.mainClock.advanceTimeByFrame()
// We should have a press interaction, with no release, even though the lambda instance
// has changed
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(onLongClick).isEqualTo(finalLongClick)
}
rule.onNodeWithTag("myClickable")
.performTouchInput { up() }
// The up should now cause a release
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Release::class.java)
assertThat((interactions[1] as PressInteraction.Release).press)
.isEqualTo(interactions[0])
}
}
/**
* Regression test for b/186223077
*
* Tests that if a long click causes the long click lambda to become null, we will emit
* [PressInteraction.Cancel].
*/
@Test
@LargeTest
fun clickableTest_longClick_interactionSource_cancelsIfLongClickBecomesNull() {
val interactionSource = MutableInteractionSource()
var onLongClick: (() -> Unit)? by mutableStateOf(null)
val initialLongClick = { onLongClick = null }
// Simulate the long click causing a recomposition, and changing the lambda to be null
onLongClick = initialLongClick
lateinit var scope: CoroutineScope
rule.mainClock.autoAdvance = false
rule.setContent {
scope = rememberCoroutineScope()
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
onLongClick = onLongClick,
interactionSource = interactionSource,
indication = null
) {}
)
}
}
val interactions = mutableListOf<Interaction>()
scope.launch {
interactionSource.interactions.collect { interactions.add(it) }
}
rule.runOnIdle {
assertThat(interactions).isEmpty()
assertThat(onLongClick).isEqualTo(initialLongClick)
}
rule.onNodeWithTag("myClickable")
.performTouchInput { down(center) }
// Initial press
rule.mainClock.advanceTimeBy(100)
rule.runOnIdle {
assertThat(interactions).hasSize(1)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(onLongClick).isEqualTo(initialLongClick)
}
// Long click
rule.mainClock.advanceTimeBy(1000)
// Run another frame to trigger recomposition caused by the long click
rule.mainClock.advanceTimeByFrame()
// The new onLongClick lambda should be null, and so we should cancel the existing press.
rule.runOnIdle {
assertThat(interactions).hasSize(2)
assertThat(interactions.first()).isInstanceOf(PressInteraction.Press::class.java)
assertThat(interactions[1]).isInstanceOf(PressInteraction.Cancel::class.java)
assertThat((interactions[1] as PressInteraction.Cancel).press)
.isEqualTo(interactions[0])
assertThat(onLongClick).isNull()
}
}
@Test
@LargeTest
fun clickableTest_click_withDoubleClick_andLongClick_disabled() {
val enabled = mutableStateOf(false)
var clickCounter = 0
var doubleClickCounter = 0
var longClickCounter = 0
val onClick: () -> Unit = { ++clickCounter }
val onDoubleClick: () -> Unit = { ++doubleClickCounter }
val onLongClick: () -> Unit = { ++longClickCounter }
rule.setContent {
Box {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
enabled = enabled.value,
onDoubleClick = onDoubleClick,
onLongClick = onLongClick,
onClick = onClick
)
)
}
}
rule.onNodeWithTag("myClickable")
.performClick()
// Process gestures
rule.mainClock.advanceTimeBy(1000)
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
// Process gestures
rule.mainClock.advanceTimeBy(1000)
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
// Process gestures
rule.mainClock.advanceTimeBy(1000)
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(0)
enabled.value = true
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.mainClock.advanceTimeUntil { clickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(1)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
rule.mainClock.advanceTimeUntil { doubleClickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(1)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
rule.mainClock.advanceTimeUntil { longClickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(1)
assertThat(clickCounter).isEqualTo(1)
}
}
@Test
@LargeTest
fun combinedClickableTest_clicks_consumedWhenDisabled() {
val enabled = mutableStateOf(false)
var clickCounter = 0
var doubleClickCounter = 0
var longClickCounter = 0
val onClick: () -> Unit = { ++clickCounter }
val onDoubleClick: () -> Unit = { ++doubleClickCounter }
val onLongClick: () -> Unit = { ++longClickCounter }
var outerClickCounter = 0
var outerDoubleClickCounter = 0
var outerLongClickCounter = 0
val outerOnClick: () -> Unit = { ++outerClickCounter }
val outerOnDoubleClick: () -> Unit = { ++outerDoubleClickCounter }
val outerOnLongClick: () -> Unit = { ++outerLongClickCounter }
rule.setContent {
Box(
Modifier.combinedClickable(
onDoubleClick = outerOnDoubleClick,
onLongClick = outerOnLongClick,
onClick = outerOnClick
)
) {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.combinedClickable(
enabled = enabled.value,
onDoubleClick = onDoubleClick,
onLongClick = onLongClick,
onClick = onClick
)
)
}
}
rule.onNodeWithTag("myClickable")
.performClick()
// Process gestures
rule.mainClock.advanceTimeBy(1000)
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(0)
assertThat(outerDoubleClickCounter).isEqualTo(0)
assertThat(outerLongClickCounter).isEqualTo(0)
assertThat(outerClickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
// Process gestures
rule.mainClock.advanceTimeBy(1000)
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(0)
assertThat(outerDoubleClickCounter).isEqualTo(0)
assertThat(outerLongClickCounter).isEqualTo(0)
assertThat(outerClickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
// Process gestures
rule.mainClock.advanceTimeBy(1000)
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(0)
assertThat(outerDoubleClickCounter).isEqualTo(0)
assertThat(outerLongClickCounter).isEqualTo(0)
assertThat(outerClickCounter).isEqualTo(0)
enabled.value = true
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.mainClock.advanceTimeUntil { clickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(0)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(1)
assertThat(outerDoubleClickCounter).isEqualTo(0)
assertThat(outerLongClickCounter).isEqualTo(0)
assertThat(outerClickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
doubleClick()
}
rule.mainClock.advanceTimeUntil { doubleClickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(0)
assertThat(clickCounter).isEqualTo(1)
assertThat(outerDoubleClickCounter).isEqualTo(0)
assertThat(outerLongClickCounter).isEqualTo(0)
assertThat(outerClickCounter).isEqualTo(0)
}
rule.onNodeWithTag("myClickable")
.performTouchInput {
longClick()
}
rule.mainClock.advanceTimeUntil { longClickCounter == 1 }
rule.runOnIdle {
assertThat(doubleClickCounter).isEqualTo(1)
assertThat(longClickCounter).isEqualTo(1)
assertThat(clickCounter).isEqualTo(1)
assertThat(outerDoubleClickCounter).isEqualTo(0)
assertThat(outerLongClickCounter).isEqualTo(0)
assertThat(outerClickCounter).isEqualTo(0)
}
}
@Test
@LargeTest
fun clickableTest_click_consumedWhenDisabled() {
val enabled = mutableStateOf(false)
var clickCounter = 0
var outerCounter = 0
val onClick: () -> Unit = { ++clickCounter }
val onOuterClick: () -> Unit = { ++outerCounter }
rule.setContent {
Box(Modifier.clickable(onClick = onOuterClick)) {
BasicText(
"ClickableText",
modifier = Modifier
.testTag("myClickable")
.clickable(enabled = enabled.value, onClick = onClick)
)
}
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.runOnIdle {
assertThat(clickCounter).isEqualTo(0)
assertThat(outerCounter).isEqualTo(0)
enabled.value = true
}
rule.onNodeWithTag("myClickable")
.performClick()
rule.runOnIdle {
assertThat(clickCounter).isEqualTo(1)
assertThat(outerCounter).isEqualTo(0)
}
}
@Test
fun clickable_testInspectorValue_noIndicationOverload() {
val onClick: () -> Unit = { }
rule.setContent {
val modifier = Modifier.combinedClickable(onClick = onClick) as InspectableValue
assertThat(modifier.nameFallback).isEqualTo("combinedClickable")
assertThat(modifier.valueOverride).isNull()
assertThat(modifier.inspectableElements.map { it.name }.asIterable()).containsExactly(
"enabled",
"onClickLabel",
"role",
"onClick",
"onDoubleClick",
"onLongClick",
"onLongClickLabel"
)
}
}
@Test
fun clickable_testInspectorValue_fullParamsOverload() {
val onClick: () -> Unit = { }
rule.setContent {
val modifier = Modifier.combinedClickable(
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = null
) as InspectableValue
assertThat(modifier.nameFallback).isEqualTo("combinedClickable")
assertThat(modifier.valueOverride).isNull()
assertThat(modifier.inspectableElements.map { it.name }.asIterable()).containsExactly(
"enabled",
"onClickLabel",
"onClick",
"role",
"onDoubleClick",
"onLongClick",
"onLongClickLabel",
"indication",
"interactionSource"
)
}
}
// integration test for b/184872415
@Test
fun tapGestureTest_tryAwaitRelease_ReturnsTrue() {
val wasSuccess = mutableStateOf(false)
rule.setContent {
Box(
Modifier
.size(100.dp)
.testTag("myClickable")
.pointerInput(Unit) {
detectTapGestures(
onPress = {
wasSuccess.value = tryAwaitRelease()
}
)
}
)
}
rule.onNodeWithTag("myClickable")
.performClick()
assertThat(wasSuccess.value).isTrue()
}
@Test
fun clickInMinimumTouchArea() {
var clicked by mutableStateOf(false)
val tag = "my clickable"
rule.setContent {
Box(
Modifier
.requiredHeight(20.dp)
.requiredWidth(20.dp)
.clipToBounds()
.clickable { clicked = true }
.testTag(tag)
)
}
rule.onNodeWithTag(tag)
.assertWidthIsEqualTo(20.dp)
.assertHeightIsEqualTo(20.dp)
.assertTouchHeightIsEqualTo(48.dp)
.assertTouchWidthIsEqualTo(48.dp)
.performTouchInput {
click(Offset(-1f, -1f))
}
rule.runOnIdle {
assertThat(clicked).isTrue()
}
}
@Test
fun clickInVerticalTargetInMinimumTouchArea() {
var clicked by mutableStateOf(false)
val tag = "my clickable"
rule.setContent {
Box(
Modifier
.requiredHeight(50.dp)
.requiredWidth(20.dp)
.clipToBounds()
.clickable { clicked = true }
.testTag(tag)
)
}
rule.onNodeWithTag(tag)
.assertWidthIsEqualTo(20.dp)
.assertHeightIsEqualTo(50.dp)
.assertTouchHeightIsEqualTo(50.dp)
.assertTouchWidthIsEqualTo(48.dp)
.performTouchInput {
click(Offset(-1f, 0f))
}
rule.runOnIdle {
assertThat(clicked).isTrue()
}
}
@Test
fun clickInHorizontalTargetInMinimumTouchArea() {
var clicked by mutableStateOf(false)
val tag = "my clickable"
rule.setContent {
Box(
Modifier
.requiredHeight(20.dp)
.requiredWidth(50.dp)
.clipToBounds()
.clickable { clicked = true }
.testTag(tag)
)
}
rule.onNodeWithTag(tag)
.assertWidthIsEqualTo(50.dp)
.assertHeightIsEqualTo(20.dp)
.assertTouchHeightIsEqualTo(48.dp)
.assertTouchWidthIsEqualTo(50.dp)
.performTouchInput {
click(Offset(0f, -1f))
}
rule.runOnIdle {
assertThat(clicked).isTrue()
}
}
}