Fix menu closing on soft keyboard input
A change in API 31+ means that the x and y of ACTION_OUTSIDE
MotionEvents are incorrectly reported as (-width, -height)
instead of (0, 0) for touches in other windows. We switch to
using rawX and rawY instead, which are still correct.
Also cleans up some unused fields in PopupLayout.
Bug: b/238331998
Test: Added in ExposedDropdownMenuTest
Change-Id: Ie5d465fd98797df5870d18f2c450e5be22bfb55b
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ExposedDropdownMenuTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ExposedDropdownMenuTest.kt
index 24bde2e..95357cf 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ExposedDropdownMenuTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ExposedDropdownMenuTest.kt
@@ -54,8 +54,11 @@
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 org.junit.Assume.assumeNotNull
+import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -75,7 +78,7 @@
private val OptionName = "Option 1"
@Test
- fun expandedBehaviour_expandsOnClickAndCollapsesOnOutside() {
+ fun edm_expandsOnClick_andCollapsesOnClickOutside() {
var textFieldBounds = Rect.Zero
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
@@ -106,7 +109,7 @@
}
@Test
- fun expandedBehaviour_collapseOnTextFieldClick() {
+ fun edm_collapsesOnTextFieldClick() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(true) }
ExposedDropdownMenuForTest(
@@ -126,7 +129,39 @@
}
@Test
- fun expandedBehaviour_expandsAndFocusesTextFieldOnTrailingIconClick() {
+ 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()
+ }
+
+ rule.onNodeWithTag(TFTag).assertTextContains("zzz")
+ rule.onNodeWithTag(MenuItemTag).assertIsDisplayed()
+ }
+
+ @Test
+ fun edm_expandsAndFocusesTextField_whenTrailingIconClicked() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuForTest(
@@ -146,7 +181,7 @@
}
@Test
- fun expandedBehaviour_doesNotExpandIfTouchEndsOutsideBounds() {
+ fun edm_doesNotExpand_ifTouchEndsOutsideBounds() {
var textFieldBounds = Rect.Zero
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(false) }
@@ -184,7 +219,7 @@
}
@Test
- fun expandedBehaviour_doesNotExpandIfTouchIsPartOfScroll() {
+ fun edm_doesNotExpand_ifTouchIsPartOfScroll() {
val testIndex = 2
var textFieldSize = IntSize.Zero
rule.setMaterialContent(lightColorScheme()) {
@@ -268,7 +303,7 @@
}
@Test
- fun uiProperties_menuMatchesTextWidth() {
+ fun edm_widthMatchesTextFieldWidth() {
var textFieldBounds by mutableStateOf(Rect.Zero)
var menuBounds by mutableStateOf(Rect.Zero)
rule.setMaterialContent(lightColorScheme()) {
@@ -294,7 +329,7 @@
}
@Test
- fun EDMBehaviour_rightOptionIsChosen() {
+ fun edm_collapsesWithSelection_whenMenuItemClicked() {
rule.setMaterialContent(lightColorScheme()) {
var expanded by remember { mutableStateOf(true) }
ExposedDropdownMenuForTest(
@@ -314,8 +349,9 @@
rule.onNodeWithTag(TFTag).assertTextContains(OptionName)
}
+ @Ignore("b/266109857")
@Test
- fun doesNotCrashWhenAnchorDetachedFirst() {
+ fun edm_doesNotCrash_whenAnchorDetachedFirst() {
var parent: FrameLayout? = null
rule.setContent {
AndroidView(
@@ -323,12 +359,20 @@
FrameLayout(context).apply {
addView(ComposeView(context).apply {
setContent {
- Box {
- ExposedDropdownMenuBox(expanded = true, onExpandedChange = {}) {
- Box(
- Modifier
- .menuAnchor()
- .size(20.dp))
+ ExposedDropdownMenuBox(expanded = true, onExpandedChange = {}) {
+ TextField(
+ value = "Text",
+ onValueChange = {},
+ modifier = Modifier.menuAnchor().size(20.dp),
+ )
+ ExposedDropdownMenu(
+ expanded = true,
+ onDismissRequest = {},
+ ) {
+ DropdownMenuItem(
+ text = { Text(OptionName) },
+ onClick = {},
+ )
}
}
}
@@ -349,7 +393,7 @@
@OptIn(ExperimentalMaterial3Api::class)
@Test
- fun withScrolledContent() {
+ fun edm_withScrolledContent() {
rule.setMaterialContent(lightColorScheme()) {
Box(Modifier.fillMaxSize()) {
ExposedDropdownMenuBox(
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/ExposedDropdownMenuPopup.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/ExposedDropdownMenuPopup.kt
index d18f90d..6900df5 100644
--- a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/ExposedDropdownMenuPopup.kt
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/ExposedDropdownMenuPopup.kt
@@ -32,7 +32,6 @@
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -85,7 +84,6 @@
) {
val view = LocalView.current
val density = LocalDensity.current
- val testTag = LocalPopupTestTag.current
val layoutDirection = LocalLayoutDirection.current
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
@@ -93,10 +91,9 @@
val popupLayout = remember {
PopupLayout(
onDismissRequest = onDismissRequest,
- testTag = testTag,
composeView = view,
+ positionProvider = popupPositionProvider,
density = density,
- initialPositionProvider = popupPositionProvider,
popupId = popupId
).apply {
setContent(parentComposition) {
@@ -121,7 +118,6 @@
popupLayout.show()
popupLayout.updateParameters(
onDismissRequest = onDismissRequest,
- testTag = testTag,
layoutDirection = layoutDirection
)
onDispose {
@@ -134,17 +130,10 @@
SideEffect {
popupLayout.updateParameters(
onDismissRequest = onDismissRequest,
- testTag = testTag,
layoutDirection = layoutDirection
)
}
- DisposableEffect(popupPositionProvider) {
- popupLayout.positionProvider = popupPositionProvider
- popupLayout.updatePosition()
- onDispose {}
- }
-
// TODO(soboleva): Look at module arrangement so that Box can be
// used instead of this custom Layout
// Get the parent's position, size and layout direction
@@ -167,11 +156,6 @@
}
}
-// TODO(b/139861182): This is a hack to work around Popups not using Semantics for test tags
-// We should either remove it, or come up with an abstracted general solution that isn't specific
-// to Popup
-internal val LocalPopupTestTag = compositionLocalOf { "DEFAULT_TEST_TAG" }
-
// TODO(soboleva): Look at module dependencies so that we can get code reuse between
// Popup's SimpleStack and Box.
@Suppress("NOTHING_TO_INLINE")
@@ -214,21 +198,18 @@
@SuppressLint("ViewConstructor")
private class PopupLayout(
private var onDismissRequest: (() -> Unit)?,
- var testTag: String,
private val composeView: View,
+ private val positionProvider: PopupPositionProvider,
density: Density,
- initialPositionProvider: PopupPositionProvider,
popupId: UUID
) : AbstractComposeView(composeView.context),
ViewRootForInspector,
ViewTreeObserver.OnGlobalLayoutListener {
+
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val params = createLayoutParams()
- /** The logic of positioning the popup relative to its parent. */
- var positionProvider = initialPositionProvider
-
// Position params
var parentLayoutDirection: LayoutDirection = LayoutDirection.Ltr
var parentBounds: IntRect? by mutableStateOf(null)
@@ -247,15 +228,6 @@
override val subCompositionView: AbstractComposeView get() = this
- // Specific to exposed dropdown menus.
- private val dismissOnOutsideClick = { offset: Offset?, bounds: IntRect ->
- if (offset == null) false
- else {
- offset.x < bounds.left || offset.x > bounds.right ||
- offset.y < bounds.top || offset.y > bounds.bottom
- }
- }
-
init {
id = android.R.id.content
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
@@ -304,9 +276,7 @@
content()
}
- /**
- * Taken from PopupWindow
- */
+ // Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed.
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK) {
if (keyDispatcherState == null) {
@@ -329,11 +299,9 @@
fun updateParameters(
onDismissRequest: (() -> Unit)?,
- testTag: String,
layoutDirection: LayoutDirection
) {
this.onDismissRequest = onDismissRequest
- this.testTag = testTag
superSetLayoutDirection(layoutDirection)
}
@@ -383,28 +351,18 @@
// matter whether we return true or false as some upper layer decides on whether the
// event is propagated to other windows or not. So for focusable the event is consumed but
// for not focusable it is propagated to other windows.
- if (
- (
- (event.action == MotionEvent.ACTION_DOWN) &&
- (
- (event.x < 0) ||
- (event.x >= width) ||
- (event.y < 0) ||
- (event.y >= height)
- )
- ) ||
- event.action == MotionEvent.ACTION_OUTSIDE
+ if (event.action == MotionEvent.ACTION_OUTSIDE ||
+ (event.action == MotionEvent.ACTION_DOWN &&
+ (event.x < 0 || event.x >= width || event.y < 0 || event.y >= height))
) {
val parentBounds = parentBounds
val shouldDismiss = parentBounds == null || dismissOnOutsideClick(
- if (event.x != 0f || event.y != 0f) {
- Offset(
- params.x + event.x,
- params.y + event.y
- )
- } else null,
+ // Keep menu open if ACTION_OUTSIDE event is reported as raw coordinates of (0, 0).
+ // This means it belongs to another owner, e.g., the soft keyboard or other window.
+ if (event.rawX != 0f && event.rawY != 0f) Offset(event.rawX, event.rawY) else null,
parentBounds
)
+
if (shouldDismiss) {
onDismissRequest?.invoke()
return true
@@ -427,6 +385,15 @@
super.setLayoutDirection(direction)
}
+ // Specific to exposed dropdown menus.
+ private fun dismissOnOutsideClick(offset: Offset?, bounds: IntRect): Boolean =
+ if (offset == null) {
+ false
+ } else {
+ offset.x < bounds.left || offset.x > bounds.right ||
+ offset.y < bounds.top || offset.y > bounds.bottom
+ }
+
/**
* Initialize the LayoutParams specific to [android.widget.PopupWindow].
*/