Skip to content

Commit d8cc690

Browse files
Material Design Teamdsn5ft
authored andcommitted
[ExposedDropdownMenu][A11y] Add keyboard support for dropdown menus
PiperOrigin-RevId: 788007779
1 parent b70c46b commit d8cc690

File tree

5 files changed

+285
-3
lines changed

5 files changed

+285
-3
lines changed

lib/java/com/google/android/material/textfield/MaterialAutoCompleteTextView.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
import androidx.appcompat.widget.ListPopupWindow;
3434
import android.text.InputType;
3535
import android.util.AttributeSet;
36+
import android.view.KeyEvent;
3637
import android.view.View;
37-
import android.view.View.MeasureSpec;
3838
import android.view.ViewGroup;
3939
import android.view.ViewGroup.LayoutParams;
4040
import android.view.ViewParent;
@@ -51,6 +51,7 @@
5151
import androidx.annotation.LayoutRes;
5252
import androidx.annotation.NonNull;
5353
import androidx.annotation.Nullable;
54+
import androidx.annotation.VisibleForTesting;
5455
import com.google.android.material.color.MaterialColors;
5556
import com.google.android.material.internal.ManufacturerUtils;
5657
import com.google.android.material.internal.ThemeEnforcement;
@@ -201,6 +202,43 @@ public void dismissDropDown() {
201202
}
202203
}
203204

205+
@Override
206+
public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
207+
if (shouldShowPopup(keyCode)) {
208+
TextInputLayout textInputLayout = findTextInputLayoutAncestor();
209+
if (textInputLayout != null) {
210+
// A click on the end icon will show the dropdown and animate the icon
211+
// Note that View.performClick() is a programmatic action that works even if the view is
212+
// not clickable.
213+
textInputLayout.getEndIconView().performClick();
214+
}
215+
return true;
216+
}
217+
return super.onKeyDown(keyCode, event);
218+
}
219+
220+
/**
221+
* Determines whether the dropdown should be shown based on the key press.
222+
*
223+
* <p>If the view is editable and single-line, the dropdown is shown only for the Enter or D-pad
224+
* Center keys.
225+
*
226+
* <p>If the view is not editable, the dropdown is shown if the user presses the Enter, D-pad
227+
* Center, or Space keys.
228+
*/
229+
@VisibleForTesting
230+
boolean shouldShowPopup(int keyCode) {
231+
boolean isEnterKey =
232+
keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_DPAD_CENTER;
233+
boolean isSpaceKey = keyCode == KeyEvent.KEYCODE_SPACE;
234+
boolean isEditable = getKeyListener() != null;
235+
if (isEditable) {
236+
return isEnterKey && getMaxLines() == 1;
237+
} else {
238+
return isEnterKey || isSpaceKey;
239+
}
240+
}
241+
204242
@Override
205243
public void onWindowFocusChanged(boolean hasWindowFocus) {
206244
if (isPopupRequired()) {

lib/java/com/google/android/material/textfield/TextInputLayout.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap;
2525

2626
import android.animation.ValueAnimator;
27-
import android.annotation.TargetApi;
2827
import android.content.Context;
2928
import android.content.res.ColorStateList;
3029
import android.content.res.Configuration;
@@ -1465,7 +1464,7 @@ public LengthCounter getLengthCounter() {
14651464
}
14661465

14671466
@Override
1468-
@TargetApi(VERSION_CODES.O)
1467+
@RequiresApi(VERSION_CODES.O)
14691468
public void dispatchProvideAutofillStructure(@NonNull ViewStructure structure, int flags) {
14701469
if (editText == null) {
14711470
super.dispatchProvideAutofillStructure(structure, flags);
@@ -4524,6 +4523,19 @@ void updateTextInputBoxState() {
45244523
}
45254524

45264525
applyBoxAttributes();
4526+
4527+
if (getEndIconMode() == END_ICON_DROPDOWN_MENU) {
4528+
if (editText instanceof AutoCompleteTextView && !isEditable(editText)) {
4529+
// For non-editable dropdowns, the end icon is not clickable and focusable, because the
4530+
// whole field is a single touch target. The dropdown can be toggled programmatically by
4531+
// calling performClick() on the end icon.
4532+
getEndIconView().setFocusable(false);
4533+
getEndIconView().setClickable(false);
4534+
} else {
4535+
getEndIconView().setFocusable(true);
4536+
getEndIconView().setClickable(true);
4537+
}
4538+
}
45274539
}
45284540

45294541
private boolean isOnError() {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (C) 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.material.testutils;
18+
19+
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
20+
21+
import android.view.View;
22+
import android.widget.EditText;
23+
import android.widget.TextView;
24+
import androidx.test.espresso.UiController;
25+
import androidx.test.espresso.ViewAction;
26+
import org.hamcrest.Matcher;
27+
28+
public class EditTextActions {
29+
30+
private EditTextActions() {}
31+
32+
public static ViewAction setSingleLine(final boolean isSingleLine) {
33+
return new ViewAction() {
34+
@Override
35+
public Matcher<View> getConstraints() {
36+
return isAssignableFrom(TextView.class);
37+
}
38+
39+
@Override
40+
public String getDescription() {
41+
return "Sets the single line";
42+
}
43+
44+
@Override
45+
public void perform(UiController uiController, View view) {
46+
EditText editText = (EditText) view;
47+
editText.setSingleLine(isSingleLine);
48+
}
49+
};
50+
}
51+
}

tests/javatests/com/google/android/material/textfield/ExposedDropdownMenuTest.java

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
import static androidx.test.espresso.accessibility.AccessibilityChecks.accessibilityAssertion;
2020
import static androidx.test.espresso.action.ViewActions.clearText;
2121
import static androidx.test.espresso.action.ViewActions.click;
22+
import static androidx.test.espresso.action.ViewActions.pressKey;
2223
import static androidx.test.espresso.action.ViewActions.typeText;
2324
import static androidx.test.espresso.action.ViewActions.typeTextIntoFocusedView;
2425
import static androidx.test.espresso.assertion.ViewAssertions.matches;
2526
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
2627
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
2728
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
2829
import static androidx.test.espresso.matcher.ViewMatchers.withId;
30+
import static com.google.android.material.testutils.EditTextActions.setSingleLine;
31+
import static com.google.android.material.testutils.TestUtilsActions.requestFocus;
2932
import static com.google.android.material.testutils.TestUtilsActions.waitFor;
3033
import static com.google.android.material.testutils.TextInputLayoutActions.clickIcon;
3134
import static com.google.android.material.testutils.TextInputLayoutActions.setInputType;
@@ -45,6 +48,7 @@
4548
import android.graphics.drawable.Drawable;
4649
import android.graphics.drawable.LayerDrawable;
4750
import android.text.InputType;
51+
import android.view.KeyEvent;
4852
import android.widget.AutoCompleteTextView;
4953
import androidx.test.filters.MediumTest;
5054
import androidx.test.rule.ActivityTestRule;
@@ -65,6 +69,7 @@ public class ExposedDropdownMenuTest {
6569
public final ActivityTestRule<ExposedDropdownMenuActivity> activityTestRule =
6670
new ActivityTestRule<>(ExposedDropdownMenuActivity.class);
6771

72+
6873
@Test
6974
public void testMenuIsNonEditableWithInputTypeNone() {
7075
final Activity activity = activityTestRule.getActivity();
@@ -238,4 +243,150 @@ public void testEndIconIsAccessible() {
238243
isDescendantOfA(withId(R.id.filled_dropdown))))
239244
.check(accessibilityAssertion());
240245
}
246+
247+
@Test
248+
public void testOnKeyDown_enterOnNonEditableField_showsDropDown() {
249+
final Activity activity = activityTestRule.getActivity();
250+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled);
251+
252+
onView(withId(R.id.edittext_filled)).perform(requestFocus());
253+
onView(withId(R.id.edittext_filled)).perform(pressKey(KeyEvent.KEYCODE_ENTER));
254+
onView(withId(R.id.filled_dropdown)).perform(skipAnimations());
255+
256+
assertThat(editText.isPopupShowing(), is(true));
257+
}
258+
259+
@Test
260+
public void testOnKeyDown_spaceOnNonEditableField_showsDropDown() {
261+
final Activity activity = activityTestRule.getActivity();
262+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled);
263+
264+
onView(withId(R.id.edittext_filled)).perform(requestFocus());
265+
onView(withId(R.id.edittext_filled)).perform(pressKey(KeyEvent.KEYCODE_SPACE));
266+
onView(withId(R.id.filled_dropdown)).perform(skipAnimations());
267+
268+
assertThat(editText.isPopupShowing(), is(true));
269+
}
270+
271+
@Test
272+
public void testOnKeyDown_enterOnNonEditableField_hidesDropDown() {
273+
final Activity activity = activityTestRule.getActivity();
274+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled);
275+
276+
onView(withId(R.id.filled_dropdown)).perform(click());
277+
onView(withId(R.id.filled_dropdown)).perform(skipAnimations());
278+
279+
onView(withId(R.id.edittext_filled)).perform(requestFocus());
280+
onView(withId(R.id.edittext_filled)).perform(pressKey(KeyEvent.KEYCODE_ENTER));
281+
onView(withId(R.id.filled_dropdown)).perform(skipAnimations());
282+
283+
assertThat(editText.isPopupShowing(), is(false));
284+
}
285+
286+
@Test
287+
public void testOnKeyDown_spaceOnNonEditableField_hidesDropDown() {
288+
final Activity activity = activityTestRule.getActivity();
289+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled);
290+
291+
onView(withId(R.id.filled_dropdown)).perform(click());
292+
onView(withId(R.id.filled_dropdown)).perform(skipAnimations());
293+
294+
onView(withId(R.id.edittext_filled)).perform(requestFocus());
295+
onView(withId(R.id.edittext_filled)).perform(pressKey(KeyEvent.KEYCODE_SPACE));
296+
onView(withId(R.id.filled_dropdown)).perform(skipAnimations());
297+
298+
assertThat(editText.isPopupShowing(), is(false));
299+
}
300+
301+
@Test
302+
public void testOnKeyDown_enterOnEditableMultiLineField_doesNotShowDropDown() {
303+
final Activity activity = activityTestRule.getActivity();
304+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled_editable);
305+
306+
onView(withId(R.id.edittext_filled_editable)).perform(requestFocus());
307+
onView(withId(R.id.edittext_filled_editable)).perform(pressKey(KeyEvent.KEYCODE_ENTER));
308+
onView(withId(R.id.filled_editable_dropdown)).perform(skipAnimations());
309+
310+
assertThat(editText.isPopupShowing(), is(false));
311+
}
312+
313+
@Test
314+
public void testOnKeyDown_enterOnEditableSingleLineField_showsDropDown() {
315+
final Activity activity = activityTestRule.getActivity();
316+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled_editable);
317+
318+
onView(withId(R.id.edittext_filled_editable)).perform(setSingleLine(true));
319+
320+
onView(withId(R.id.edittext_filled_editable)).perform(requestFocus());
321+
onView(withId(R.id.edittext_filled_editable)).perform(pressKey(KeyEvent.KEYCODE_ENTER));
322+
onView(withId(R.id.filled_editable_dropdown)).perform(skipAnimations());
323+
324+
assertThat(editText.isPopupShowing(), is(true));
325+
}
326+
327+
@Test
328+
public void testOnKeyDown_enterOnEditableSingleLineField_hidesDropDown() {
329+
final Activity activity = activityTestRule.getActivity();
330+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled_editable);
331+
332+
onView(withId(R.id.edittext_filled_editable)).perform(setSingleLine(true));
333+
334+
onView(withId(R.id.filled_editable_dropdown)).perform(click());
335+
onView(withId(R.id.filled_editable_dropdown)).perform(skipAnimations());
336+
337+
onView(withId(R.id.edittext_filled_editable)).perform(requestFocus());
338+
onView(withId(R.id.edittext_filled_editable)).perform(pressKey(KeyEvent.KEYCODE_ENTER));
339+
onView(withId(R.id.filled_editable_dropdown)).perform(skipAnimations());
340+
341+
assertThat(editText.isPopupShowing(), is(false));
342+
}
343+
344+
@Test
345+
public void testOnKeyDown_spaceOnEditableField_doesNotShowDropDown() {
346+
final Activity activity = activityTestRule.getActivity();
347+
final AutoCompleteTextView editText = activity.findViewById(R.id.edittext_filled_editable);
348+
349+
onView(withId(R.id.edittext_filled_editable)).perform(requestFocus());
350+
onView(withId(R.id.edittext_filled_editable)).perform(pressKey(KeyEvent.KEYCODE_SPACE));
351+
onView(withId(R.id.filled_editable_dropdown)).perform(skipAnimations());
352+
353+
assertThat(editText.isPopupShowing(), is(false));
354+
}
355+
356+
@Test
357+
public void shouldShowPopup_nonEditable_isTrueForEnterOrSpace() {
358+
final Activity activity = activityTestRule.getActivity();
359+
final MaterialAutoCompleteTextView nonEditable = activity.findViewById(R.id.edittext_filled);
360+
361+
assertThat(nonEditable.shouldShowPopup(KeyEvent.KEYCODE_ENTER), is(true));
362+
assertThat(nonEditable.shouldShowPopup(KeyEvent.KEYCODE_DPAD_CENTER), is(true));
363+
assertThat(nonEditable.shouldShowPopup(KeyEvent.KEYCODE_SPACE), is(true));
364+
assertThat(nonEditable.shouldShowPopup(KeyEvent.KEYCODE_A), is(false));
365+
}
366+
367+
@Test
368+
public void shouldShowPopup_editableMultiLine_isFalse() {
369+
final Activity activity = activityTestRule.getActivity();
370+
final MaterialAutoCompleteTextView editable =
371+
activity.findViewById(R.id.edittext_filled_editable);
372+
373+
onView(withId(R.id.edittext_filled_editable)).perform(setSingleLine(false));
374+
375+
assertThat(editable.shouldShowPopup(KeyEvent.KEYCODE_ENTER), is(false));
376+
assertThat(editable.shouldShowPopup(KeyEvent.KEYCODE_DPAD_CENTER), is(false));
377+
assertThat(editable.shouldShowPopup(KeyEvent.KEYCODE_SPACE), is(false));
378+
}
379+
380+
@Test
381+
public void shouldShowPopup_editableSingleLine_isTrueForEnter() {
382+
final Activity activity = activityTestRule.getActivity();
383+
final MaterialAutoCompleteTextView editable =
384+
activity.findViewById(R.id.edittext_filled_editable);
385+
386+
onView(withId(R.id.edittext_filled_editable)).perform(setSingleLine(true));
387+
388+
assertThat(editable.shouldShowPopup(KeyEvent.KEYCODE_ENTER), is(true));
389+
assertThat(editable.shouldShowPopup(KeyEvent.KEYCODE_DPAD_CENTER), is(true));
390+
assertThat(editable.shouldShowPopup(KeyEvent.KEYCODE_SPACE), is(false));
391+
}
241392
}

tests/javatests/com/google/android/material/textfield/TextInputLayoutTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import android.text.TextPaint;
7272
import android.util.SparseArray;
7373
import android.view.inputmethod.EditorInfo;
74+
import android.widget.AutoCompleteTextView;
7475
import android.widget.EditText;
7576
import android.widget.TextView;
7677
import androidx.annotation.ColorInt;
@@ -1032,6 +1033,35 @@ public void hintSetOnOuterLayout_propagateOuterHintToAutofillProvider() {
10321033
assertEquals("Outer hint", editText.getHint().toString());
10331034
}
10341035

1036+
@UiThreadTest
1037+
@Test
1038+
public void testDropdownMenu_nonEditable_endIconIsNotFocusableOrClickable() {
1039+
final Activity activity = activityTestRule.getActivity();
1040+
final TextInputLayout textInputLayout = activity.findViewById(R.id.textinput_noedittext);
1041+
final AutoCompleteTextView editText = new AutoCompleteTextView(activity);
1042+
1043+
textInputLayout.setEndIconMode(TextInputLayout.END_ICON_DROPDOWN_MENU);
1044+
editText.setKeyListener(null); // This makes the EditText not-editable
1045+
textInputLayout.addView(editText);
1046+
1047+
assertThat(textInputLayout.getEndIconView().isFocusable()).isFalse();
1048+
assertThat(textInputLayout.getEndIconView().isClickable()).isFalse();
1049+
}
1050+
1051+
@UiThreadTest
1052+
@Test
1053+
public void testDropdownMenu_editable_endIconIsFocusableAndClickable() {
1054+
final Activity activity = activityTestRule.getActivity();
1055+
final TextInputLayout textInputLayout = activity.findViewById(R.id.textinput_noedittext);
1056+
final AutoCompleteTextView editText = new AutoCompleteTextView(activity);
1057+
1058+
textInputLayout.setEndIconMode(TextInputLayout.END_ICON_DROPDOWN_MENU);
1059+
textInputLayout.addView(editText);
1060+
1061+
assertThat(textInputLayout.getEndIconView().isFocusable()).isTrue();
1062+
assertThat(textInputLayout.getEndIconView().isClickable()).isTrue();
1063+
}
1064+
10351065
private static ViewAssertion isHintExpanded(final boolean expanded) {
10361066
return (view, noViewFoundException) -> {
10371067
assertTrue(view instanceof TextInputLayout);

0 commit comments

Comments
 (0)