blob: 0ebdf52ca14efc861bd31c9b7d04738aa42f5946 [file] [log] [blame]
/*
* Copyright 2022 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.material3
import android.widget.FrameLayout
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.animateScrollBy
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeNotNull
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(ExperimentalMaterial3Api::class)
@MediumTest
@RunWith(AndroidJUnit4::class)
class ExposedDropdownMenuTest {
@get:Rule
val rule = createComposeRule()
private val TFTag = "TextFieldTag"
private val TrailingIconTag = "TrailingIconTag"
private val EDMTag = "ExposedDropdownMenuTag"
private val MenuItemTag = "MenuItemTag"
private val OptionName = "Option 1"
@Test
fun edm_expandsOnClick_andCollapsesOnClickOutside() {
var textFieldBounds = Rect.Zero
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it },
onTextFieldBoundsChanged = {
textFieldBounds = it
}
)
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(EDMTag).assertDoesNotExist()
// Click on the TextField
rule.onNodeWithTag(TFTag).performClick()
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
// Click outside EDM
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).click(
(textFieldBounds.right + 1).toInt(),
(textFieldBounds.bottom + 1).toInt(),
)
rule.onNodeWithTag(MenuItemTag).assertDoesNotExist()
}
@Test
fun edm_collapsesOnTextFieldClick() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(true) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it }
)
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(EDMTag).assertIsDisplayed()
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
// Click on the TextField
rule.onNodeWithTag(TFTag).performClick()
rule.onNodeWithTag(MenuItemTag).assertDoesNotExist()
}
@Test
fun edm_doesNotCollapse_whenTypingOnSoftKeyboard() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it }
)
}
rule.onNodeWithTag(TFTag).performClick()
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(TFTag).assertIsFocused()
rule.onNodeWithTag(EDMTag).assertIsDisplayed()
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val zKey = device.findObject(By.desc("z")) ?: device.findObject(By.text("z"))
// Only run the test if we can find a key to type, which might fail for any number of
// reasons (keyboard doesn't appear, unexpected locale, etc.)
assumeNotNull(zKey)
repeat(3) {
zKey.click()
rule.waitForIdle()
}
val matcher = hasText("zzz")
rule.waitUntil {
matcher.matches(rule.onNodeWithTag(TFTag).fetchSemanticsNode())
}
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
}
@Test
fun edm_expandsAndFocusesTextField_whenTrailingIconClicked() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it },
)
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(TrailingIconTag, useUnmergedTree = true).assertIsDisplayed()
// Click on the Trailing Icon
rule.onNodeWithTag(TrailingIconTag, useUnmergedTree = true).performClick()
rule.onNodeWithTag(TFTag).assertIsFocused()
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
}
@Test
fun edm_doesNotExpand_ifTouchEndsOutsideBounds() {
var textFieldBounds = Rect.Zero
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it },
onTextFieldBoundsChanged = {
textFieldBounds = it
}
)
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(EDMTag).assertDoesNotExist()
// A swipe that ends outside the bounds of the anchor should not expand the menu.
rule.onNodeWithTag(TFTag).performTouchInput {
swipe(
start = this.center,
end = Offset(this.centerX, this.centerY + (textFieldBounds.height / 2) + 1),
durationMillis = 100
)
}
rule.onNodeWithTag(MenuItemTag).assertDoesNotExist()
// A swipe that ends within the bounds of the anchor should expand the menu.
rule.onNodeWithTag(TFTag).performTouchInput {
swipe(
start = this.center,
end = Offset(this.centerX, this.centerY + (textFieldBounds.height / 2) - 1),
durationMillis = 100
)
}
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
}
@Test
fun edm_doesNotExpand_ifTouchIsPartOfScroll() {
val testIndex = 2
var textFieldSize = IntSize.Zero
rule.setMaterialContent(lightColorScheme()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(50) { index ->
var expanded by remember { mutableStateOf(false) }
var selectedOptionText by remember { mutableStateOf("") }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.padding(8.dp),
) {
TextField(
modifier = Modifier
.menuAnchor()
.then(
if (index == testIndex) Modifier
.testTag(TFTag)
.onSizeChanged {
textFieldSize = it
} else {
Modifier
}
),
value = selectedOptionText,
onValueChange = { selectedOptionText = it },
label = { Text("Label") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
ExposedDropdownMenu(
modifier = if (index == testIndex) {
Modifier.testTag(EDMTag)
} else { Modifier },
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(OptionName) },
onClick = {
selectedOptionText = OptionName
expanded = false
},
modifier = if (index == testIndex) {
Modifier.testTag(MenuItemTag)
} else { Modifier },
)
}
}
}
}
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(EDMTag).assertDoesNotExist()
// A swipe that causes a scroll should not expand the menu, even if it remains within the
// bounds of the anchor.
rule.onNodeWithTag(TFTag).performTouchInput {
swipe(
start = this.center,
end = Offset(this.centerX, this.centerY - (textFieldSize.height / 2) + 1),
durationMillis = 100
)
}
rule.onNodeWithTag(MenuItemTag).assertDoesNotExist()
// But a swipe that does not cause a scroll should expand the menu.
rule.onNodeWithTag(TFTag).performTouchInput {
swipe(
start = this.center,
end = Offset(this.centerX + (textFieldSize.width / 2) - 1, this.centerY),
durationMillis = 100
)
}
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
}
@Test
fun edm_doesNotRecomposeOnScroll() {
var compositionCount = 0
lateinit var scrollState: ScrollState
lateinit var scope: CoroutineScope
rule.setMaterialContent(lightColorScheme()) {
scrollState = rememberScrollState()
scope = rememberCoroutineScope()
Column(Modifier.verticalScroll(scrollState)) {
Spacer(Modifier.height(300.dp))
val expanded = false
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {},
) {
TextField(
modifier = Modifier.menuAnchor(),
readOnly = true,
value = "",
onValueChange = {},
label = { Text("Label") },
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {},
content = {},
)
SideEffect {
compositionCount++
}
}
Spacer(Modifier.height(300.dp))
}
}
assertThat(compositionCount).isEqualTo(1)
rule.runOnIdle {
scope.launch {
scrollState.animateScrollBy(500f)
}
}
rule.waitForIdle()
assertThat(compositionCount).isEqualTo(1)
}
@Test
fun edm_widthMatchesTextFieldWidth() {
var textFieldBounds by mutableStateOf(Rect.Zero)
var menuBounds by mutableStateOf(Rect.Zero)
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(true) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it },
onTextFieldBoundsChanged = {
textFieldBounds = it
},
onMenuBoundsChanged = {
menuBounds = it
}
)
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
rule.runOnIdle {
assertThat(menuBounds.width).isEqualTo(textFieldBounds.width)
}
}
@Test
fun edm_collapsesWithSelection_whenMenuItemClicked() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(true) }
ExposedDropdownMenuForTest(
expanded = expanded,
onExpandChange = { expanded = it }
)
}
rule.onNodeWithTag(TFTag).assertIsDisplayed()
rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
// Choose the option
rule.onNodeWithTag(MenuItemTag).performClick()
// Menu should collapse
rule.onNodeWithTag(MenuItemTag).assertDoesNotExist()
rule.onNodeWithTag(TFTag).assertTextContains(OptionName)
}
@Test
fun edm_resizesWithinWindowBounds_uponImeAppearance() {
var actualMenuSize: IntSize? = null
var density: Density? = null
val itemSize = 50.dp
val itemCount = 10
rule.setMaterialContent(lightColorScheme()) {
density = LocalDensity.current
Column(Modifier.fillMaxSize()) {
// Push the EDM down so opening the keyboard causes a pan/scroll
Spacer(Modifier.weight(1f))
ExposedDropdownMenuBox(
expanded = true,
onExpandedChange = { }
) {
TextField(
modifier = Modifier.menuAnchor(),
value = "",
onValueChange = { },
label = { Text("Label") },
)
ExposedDropdownMenu(
expanded = true,
onDismissRequest = { },
modifier = Modifier.onGloballyPositioned {
actualMenuSize = it.size
}
) {
repeat(itemCount) {
Box(Modifier.size(itemSize))
}
}
}
}
}
// This would fit on screen if the keyboard wasn't displayed.
val menuPreferredHeight = with(density!!) {
(itemSize * itemCount + DropdownMenuVerticalPadding * 2).roundToPx()
}
// But the keyboard *is* displayed, forcing the actual size to be smaller.
assertThat(actualMenuSize!!.height).isLessThan(menuPreferredHeight)
}
@Ignore("b/266109857")
@Test
fun edm_doesNotCrash_whenAnchorDetachedFirst() {
var parent: FrameLayout? = null
rule.setMaterialContent(lightColorScheme()) {
AndroidView(
factory = { context ->
FrameLayout(context).apply {
addView(ComposeView(context).apply {
setContent {
ExposedDropdownMenuBox(expanded = true, onExpandedChange = {}) {
TextField(
value = "Text",
onValueChange = {},
modifier = Modifier.menuAnchor(),
)
ExposedDropdownMenu(
expanded = true,
onDismissRequest = {},
) {
DropdownMenuItem(
text = { Text(OptionName) },
onClick = {},
)
}
}
}
})
}.also { parent = it }
}
)
}
rule.runOnIdle {
parent!!.removeAllViews()
}
rule.waitForIdle()
// Should not have crashed.
}
@OptIn(ExperimentalMaterial3Api::class)
@Test
fun edm_withScrolledContent() {
lateinit var scrollState: ScrollState
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.fillMaxSize()) {
ExposedDropdownMenuBox(
modifier = Modifier.align(Alignment.Center),
expanded = true,
onExpandedChange = { }
) {
scrollState = rememberScrollState()
TextField(
modifier = Modifier.menuAnchor(),
value = "",
onValueChange = { },
label = { Text("Label") },
)
ExposedDropdownMenu(
expanded = true,
onDismissRequest = { },
scrollState = scrollState
) {
repeat(100) {
Text(
text = "Text ${it + 1}",
modifier = Modifier.testTag("MenuContent ${it + 1}"),
)
}
}
}
}
}
rule.runOnIdle {
runBlocking {
scrollState.scrollTo(scrollState.maxValue)
}
}
rule.waitForIdle()
rule.onNodeWithTag("MenuContent 1").assertIsNotDisplayed()
rule.onNodeWithTag("MenuContent 100").assertIsDisplayed()
}
@Composable
fun ExposedDropdownMenuForTest(
expanded: Boolean,
onExpandChange: (Boolean) -> Unit,
onTextFieldBoundsChanged: ((Rect) -> Unit)? = null,
onMenuBoundsChanged: ((Rect) -> Unit)? = null
) {
var selectedOptionText by remember { mutableStateOf("") }
Box(Modifier.fillMaxSize()) {
ExposedDropdownMenuBox(
modifier = Modifier.align(Alignment.Center),
expanded = expanded,
onExpandedChange = onExpandChange,
) {
TextField(
modifier = Modifier
.menuAnchor()
.testTag(TFTag)
.onGloballyPositioned {
onTextFieldBoundsChanged?.invoke(it.boundsInRoot())
},
value = selectedOptionText,
onValueChange = { selectedOptionText = it },
label = { Text("Label") },
trailingIcon = {
Box(
modifier = Modifier.testTag(TrailingIconTag)
) {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
}
},
colors = ExposedDropdownMenuDefaults.textFieldColors()
)
ExposedDropdownMenu(
modifier = Modifier
.testTag(EDMTag)
.onGloballyPositioned {
onMenuBoundsChanged?.invoke(it.boundsInRoot())
},
expanded = expanded,
onDismissRequest = { onExpandChange(false) }
) {
DropdownMenuItem(
text = { Text(OptionName) },
onClick = {
selectedOptionText = OptionName
onExpandChange(false)
},
modifier = Modifier.testTag(MenuItemTag)
)
}
}
}
}
}