Skip to content

Commit c7fa5dc

Browse files
Material Design Teamdsn5ft
authored andcommitted
[TextField][A11y] Add tooltip support to TextInputLayout icons
- The icon's `contentDescription` is used as the tooltip text. - Tooltips are only shown for icons that are interactive (focusable). Decorative-only icons will not display a tooltip. - `CheckableImageButton` is updated to provide a callback for when its focusable state changes, which is used to trigger tooltip updates. - API-level differences are handled to ensure that custom `OnLongClickListeners` are not overridden by the tooltip's long-press listener on older platforms (pre-API 26). PiperOrigin-RevId: 794951524
1 parent babc9fc commit c7fa5dc

File tree

7 files changed

+276
-2
lines changed

7 files changed

+276
-2
lines changed

lib/java/com/google/android/material/internal/CheckableImageButton.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import android.view.accessibility.AccessibilityEvent;
2828
import android.widget.Checkable;
2929
import androidx.annotation.NonNull;
30+
import androidx.annotation.Nullable;
3031
import androidx.annotation.RestrictTo;
3132
import androidx.core.view.AccessibilityDelegateCompat;
3233
import androidx.core.view.ViewCompat;
@@ -44,6 +45,9 @@ public class CheckableImageButton extends AppCompatImageButton implements Checka
4445
private boolean checkable = true;
4546
private boolean pressable = true;
4647

48+
@Nullable
49+
private OnFocusableChangedListener onFocusableChangedListener;
50+
4751
public CheckableImageButton(Context context) {
4852
this(context, null);
4953
}
@@ -100,6 +104,16 @@ public void setPressed(boolean pressed) {
100104
}
101105
}
102106

107+
@Override
108+
public void setFocusable(boolean focusable) {
109+
boolean originalFocusable = isFocusable();
110+
super.setFocusable(focusable);
111+
112+
if (originalFocusable != focusable && onFocusableChangedListener != null) {
113+
onFocusableChangedListener.onFocusableChanged(this, focusable);
114+
}
115+
}
116+
103117
@Override
104118
public int[] onCreateDrawableState(int extraSpace) {
105119
if (checked) {
@@ -131,6 +145,12 @@ protected void onRestoreInstanceState(Parcelable state) {
131145
setChecked(savedState.checked);
132146
}
133147

148+
@Override
149+
protected void onDetachedFromWindow() {
150+
onFocusableChangedListener = null;
151+
super.onDetachedFromWindow();
152+
}
153+
134154
/** Sets image button to be checkable or not. */
135155
public void setCheckable(boolean checkable) {
136156
if (this.checkable != checkable) {
@@ -154,6 +174,26 @@ public boolean isPressable() {
154174
return pressable;
155175
}
156176

177+
/** Register a callback to be invoked when the focusable state of this view changes. */
178+
public void setOnFocusableChangedListener(
179+
@Nullable OnFocusableChangedListener onFocusableChangedListener) {
180+
this.onFocusableChangedListener = onFocusableChangedListener;
181+
}
182+
183+
/**
184+
* Interface definition for a callback to be invoked when the focusable state of this view
185+
* changes.
186+
*/
187+
public interface OnFocusableChangedListener {
188+
/**
189+
* Called when the focusable state of a view has changed.
190+
*
191+
* @param view The view whose focusable state has changed.
192+
* @param focusable The new focusable state of view.
193+
*/
194+
void onFocusableChanged(@NonNull View view, boolean focusable);
195+
}
196+
157197
static class SavedState extends AbsSavedState {
158198

159199
boolean checked;

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import static com.google.android.material.textfield.IconHelper.setIconOnClickListener;
2727
import static com.google.android.material.textfield.IconHelper.setIconOnLongClickListener;
2828
import static com.google.android.material.textfield.IconHelper.setIconScaleType;
29+
import static com.google.android.material.textfield.IconHelper.updateIconTooltip;
2930
import static com.google.android.material.textfield.TextInputLayout.END_ICON_CLEAR_TEXT;
3031
import static com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM;
3132
import static com.google.android.material.textfield.TextInputLayout.END_ICON_DROPDOWN_MENU;
@@ -182,6 +183,17 @@ public void onEditTextAttached(@NonNull TextInputLayout textInputLayout) {
182183
addView(endIconFrame);
183184
addView(errorIconView);
184185

186+
errorIconView.setOnFocusableChangedListener(
187+
(v, focusable) ->
188+
updateIconTooltip(
189+
errorIconView,
190+
errorIconOnLongClickListener,
191+
errorIconView.getContentDescription()));
192+
endIconView.setOnFocusableChangedListener(
193+
(v, focusable) ->
194+
updateIconTooltip(
195+
endIconView, endIconOnLongClickListener, getEndIconContentDescription()));
196+
185197
textInputLayout.addOnEditTextAttachedListener(onEditTextAttachedListener);
186198
addOnAttachStateChangeListener(
187199
new OnAttachStateChangeListener() {
@@ -363,7 +375,6 @@ void setEndIconMode(@EndIconMode int endIconMode) {
363375
setEndIconVisible(endIconMode != END_ICON_NONE);
364376
EndIconDelegate delegate = getEndIconDelegate();
365377
setEndIconDrawable(getIconResId(delegate));
366-
setEndIconContentDescription(delegate.getIconContentDescriptionResId());
367378
setEndIconCheckable(delegate.isIconCheckable());
368379
if (delegate.isBoxBackgroundModeSupported(textInputLayout.getBoxBackgroundMode())) {
369380
setUpDelegate(delegate);
@@ -375,6 +386,7 @@ void setEndIconMode(@EndIconMode int endIconMode) {
375386
+ endIconMode);
376387
}
377388
setEndIconOnClickListener(delegate.getOnIconClickListener());
389+
setEndIconContentDescription(delegate.getIconContentDescriptionResId());
378390
if (editText != null) {
379391
delegate.onEditTextAttached(editText);
380392
setOnFocusChangeListenersIfNeeded(delegate);
@@ -534,6 +546,7 @@ void setEndIconContentDescription(@StringRes int resId) {
534546
void setEndIconContentDescription(@Nullable CharSequence endIconContentDescription) {
535547
if (getEndIconContentDescription() != endIconContentDescription) {
536548
endIconView.setContentDescription(endIconContentDescription);
549+
updateIconTooltip(endIconView, endIconOnLongClickListener, endIconContentDescription);
537550
}
538551
}
539552

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
import android.content.res.ColorStateList;
2222
import android.graphics.PorterDuff;
2323
import android.graphics.drawable.Drawable;
24+
import android.os.Build;
2425
import android.os.Build.VERSION;
2526
import android.os.Build.VERSION_CODES;
27+
import androidx.appcompat.widget.TooltipCompat;
2628
import android.view.View;
2729
import android.view.View.OnClickListener;
2830
import android.view.View.OnLongClickListener;
@@ -61,7 +63,12 @@ private static void setIconClickable(
6163
iconView.setFocusable(iconFocusable);
6264
iconView.setClickable(iconClickable);
6365
iconView.setPressable(iconClickable);
64-
iconView.setLongClickable(iconLongClickable);
66+
// Pre-O, the tooltip is set via a long-click listener. If we have a custom OnClickListener but
67+
// no custom OnLongClickListener, do not set the view to not be long-clickable, so that the
68+
// tooltip can be shown.
69+
if (VERSION.SDK_INT >= VERSION_CODES.O || !iconFocusable || iconLongClickable) {
70+
iconView.setLongClickable(iconLongClickable);
71+
}
6572
iconView.setImportantForAccessibility(
6673
iconFocusable
6774
? View.IMPORTANT_FOR_ACCESSIBILITY_YES
@@ -173,4 +180,30 @@ static ImageView.ScaleType convertScaleType(int scaleType) {
173180
return ImageView.ScaleType.CENTER;
174181
}
175182
}
183+
184+
/**
185+
* Updates the tooltip for an icon, handling API-level-specific behavior.
186+
*
187+
* <p>The tooltip is only set if the icon is focusable.
188+
*
189+
* <p>On API 26 and above, this method calls {@link
190+
* android.view.View#setTooltipText(CharSequence)}. This is safe to use even with a custom {@link
191+
* OnLongClickListener}.
192+
*
193+
* <p>On API levels below 26, this method uses {@link TooltipCompat#setTooltipText(View,
194+
* CharSequence)}, but only if a custom {@link OnLongClickListener} has not been set. This is to
195+
* avoid overwriting a developer-provided long-press listener. Thus, a custom {@link
196+
* OnLongClickListener} will override the tooltip.
197+
*/
198+
static void updateIconTooltip(
199+
@NonNull CheckableImageButton iconView,
200+
@Nullable OnLongClickListener onLongClickListener,
201+
@Nullable CharSequence tooltip) {
202+
final CharSequence tooltipText = iconView.isFocusable() ? tooltip : null;
203+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
204+
iconView.setTooltipText(tooltipText);
205+
} else if (onLongClickListener == null) {
206+
TooltipCompat.setTooltipText(iconView, tooltipText);
207+
}
208+
}
176209
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import static com.google.android.material.textfield.IconHelper.setIconOnClickListener;
2727
import static com.google.android.material.textfield.IconHelper.setIconOnLongClickListener;
2828
import static com.google.android.material.textfield.IconHelper.setIconScaleType;
29+
import static com.google.android.material.textfield.IconHelper.updateIconTooltip;
2930

3031
import android.annotation.SuppressLint;
3132
import android.content.res.ColorStateList;
@@ -100,6 +101,11 @@ class StartCompoundLayout extends LinearLayout {
100101

101102
addView(startIconView);
102103
addView(prefixTextView);
104+
105+
startIconView.setOnFocusableChangedListener(
106+
(v, focusable) ->
107+
updateIconTooltip(
108+
startIconView, startIconOnLongClickListener, getStartIconContentDescription()));
103109
}
104110

105111
private void initStartIconView(TintTypedArray a) {
@@ -254,6 +260,7 @@ boolean isStartIconCheckable() {
254260
void setStartIconContentDescription(@Nullable CharSequence startIconContentDescription) {
255261
if (getStartIconContentDescription() != startIconContentDescription) {
256262
startIconView.setContentDescription(startIconContentDescription);
263+
updateIconTooltip(startIconView, startIconOnLongClickListener, startIconContentDescription);
257264
}
258265
}
259266

tests/javatests/com/google/android/material/testutils/TestUtilsMatchers.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import android.graphics.drawable.Drawable;
2626
import android.os.Build;
2727
import androidx.appcompat.view.menu.MenuItemImpl;
28+
import android.text.TextUtils;
2829
import android.view.Gravity;
2930
import android.view.Menu;
3031
import android.view.View;
@@ -35,6 +36,7 @@
3536
import androidx.annotation.ColorInt;
3637
import androidx.annotation.IdRes;
3738
import androidx.annotation.NonNull;
39+
import androidx.annotation.Nullable;
3840
import androidx.core.view.GravityCompat;
3941
import androidx.core.view.ViewCompat;
4042
import androidx.core.widget.TextViewCompat;
@@ -514,6 +516,21 @@ public boolean matchesSafely(View view) {
514516
};
515517
}
516518

519+
/** Returns a matcher that matches {@link View}s that are long-clickable. */
520+
public static Matcher<View> isLongClickable() {
521+
return new TypeSafeMatcher<View>() {
522+
@Override
523+
public void describeTo(Description description) {
524+
description.appendText("is long-clickable");
525+
}
526+
527+
@Override
528+
public boolean matchesSafely(View view) {
529+
return view.isLongClickable();
530+
}
531+
};
532+
}
533+
517534
/**
518535
* Returns a matcher that matches views which have a z-value greater than 0. Also matches if the
519536
* platform we're running on does not support z-values.
@@ -624,4 +641,30 @@ public boolean matchesSafely(View view) {
624641
}
625642
};
626643
}
644+
645+
/**
646+
* Returns a matcher that matches {@link View}s with the specified tooltip text. Only works for
647+
* API 26+
648+
*/
649+
public static Matcher<View> withTooltipText(@Nullable final CharSequence text) {
650+
return new BoundedMatcher<View, View>(View.class) {
651+
@Override
652+
public void describeTo(Description description) {
653+
description.appendText("with tooltip text: ");
654+
if (text == null) {
655+
description.appendText("null");
656+
} else {
657+
description.appendText(text.toString());
658+
}
659+
}
660+
661+
@Override
662+
protected boolean matchesSafely(View item) {
663+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
664+
return false;
665+
}
666+
return TextUtils.equals(item.getTooltipText(), text);
667+
}
668+
};
669+
}
627670
}

tests/javatests/com/google/android/material/testutils/TextInputLayoutActions.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,4 +1025,24 @@ public void perform(UiController uiController, View view) {
10251025
}
10261026
};
10271027
}
1028+
1029+
/** Sets end icon content description on {@link TextInputLayout} */
1030+
public static ViewAction setEndIconContentDescription(final CharSequence contentDescription) {
1031+
return new ViewAction() {
1032+
@Override
1033+
public Matcher<View> getConstraints() {
1034+
return isAssignableFrom(TextInputLayout.class);
1035+
}
1036+
1037+
@Override
1038+
public String getDescription() {
1039+
return "set end icon content description";
1040+
}
1041+
1042+
@Override
1043+
public void perform(UiController uiController, View view) {
1044+
((TextInputLayout) view).setEndIconContentDescription(contentDescription);
1045+
}
1046+
};
1047+
}
10281048
}

0 commit comments

Comments
 (0)