/* * Copyright 2017 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 * * https://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 com.google.android.material.chip; import com.google.android.material.R; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Outline; import android.graphics.PorterDuff.Mode; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; import android.graphics.drawable.RippleDrawable; import android.os.Build; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Bundle; import androidx.annotation.AnimatorRes; import androidx.annotation.BoolRes; import androidx.annotation.CallSuper; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.Dimension; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; import androidx.annotation.StyleRes; import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; import androidx.appcompat.widget.AppCompatCheckBox; import android.text.TextPaint; import android.text.TextUtils; import android.text.TextUtils.TruncateAt; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.PointerIcon; import android.view.SoundEffectConstants; import android.view.View; import android.view.ViewOutlineProvider; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import androidx.customview.widget.ExploreByTouchHelper; import com.google.android.material.animation.MotionSpec; import com.google.android.material.chip.ChipDrawable.Delegate; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewUtils; import com.google.android.material.resources.MaterialResources; import com.google.android.material.resources.TextAppearance; import com.google.android.material.resources.TextAppearanceFontCallback; import com.google.android.material.ripple.RippleUtils; import com.google.android.material.shape.MaterialShapeUtils; import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.shape.Shapeable; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.List; /** * Chips are compact elements that represent an attribute, text, entity, or action. They allow users * to enter information, select a choice, filter content, or trigger an action. * *
The Chip widget is a thin view wrapper around the {@link ChipDrawable}, which contains all of * the layout and draw logic. The extra logic exists to support touch, mouse, keyboard, and * accessibility navigation. The main chip and close icon are considered to be separate logical * sub-views, and contain their own navigation behavior and state. * *
All attributes from {@link R.styleable#Chip} are supported. Do not use the {@code * android:background} attribute. It will be ignored because Chip manages its own background * Drawable. Also do not use the {@code android:drawableStart} and {@code android:drawableEnd} * attributes. They will be ignored because Chip manages its own start ({@code app:chipIcon}) and * end ({@code app:closeIcon}) drawables. The basic attributes you can set are: * *
You can register a listener on the main chip with {@link #setOnClickListener(OnClickListener)} * or {@link #setOnCheckedChangeListener(OnCheckedChangeListener)}. You can register a listener on * the close icon with {@link #setOnCloseIconClickListener(OnClickListener)}. * *
For proper rendering of the ancestor TextView in RTL mode, call {@link
* #setLayoutDirection(int)} with View.LAYOUT_DIRECTION_LOCALE. By default, TextView's
* layout rendering sets the text padding in LTR on initial rendering and it only renders correctly
* after the layout has been invalidated so you need to ensure that initial rendering has the
* correct layout.
*
* @see ChipDrawable
*/
public class Chip extends AppCompatCheckBox implements Delegate, Shapeable {
private static final String TAG = "Chip";
private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_Chip_Action;
private static final int CHIP_BODY_VIRTUAL_ID = 0;
private static final int CLOSE_ICON_VIRTUAL_ID = 1;
private static final Rect EMPTY_BOUNDS = new Rect();
private static final int[] SELECTED_STATE = new int[] {android.R.attr.state_selected};
private static final int[] CHECKABLE_STATE_SET = {android.R.attr.state_checkable};
private static final String NAMESPACE_ANDROID = "http://schemas.android.com/apk/res/android";
/** Value taken from Android Accessibility Guide */
private static final int MIN_TOUCH_TARGET_DP = 48;
@Nullable private ChipDrawable chipDrawable;
@Nullable private InsetDrawable insetBackgroundDrawable;
//noinspection NewApi
@Nullable private RippleDrawable ripple;
@Nullable private OnClickListener onCloseIconClickListener;
@Nullable private OnCheckedChangeListener onCheckedChangeListenerInternal;
private boolean deferredCheckedValue;
private boolean closeIconPressed;
private boolean closeIconHovered;
private boolean closeIconFocused;
private boolean ensureMinTouchTargetSize;
private int lastLayoutDirection;
@Dimension(unit = Dimension.PX)
private int minTouchTargetSize;
private static final String BUTTON_ACCESSIBILITY_CLASS_NAME = "android.widget.Button";
private static final String COMPOUND_BUTTON_ACCESSIBILITY_CLASS_NAME =
"android.widget.CompoundButton";
private static final String GENERIC_VIEW_ACCESSIBILITY_CLASS_NAME = "android.view.View";
@NonNull private final ChipTouchHelper touchHelper;
private final Rect rect = new Rect();
private final RectF rectF = new RectF();
private final TextAppearanceFontCallback fontCallback =
new TextAppearanceFontCallback() {
@Override
public void onFontRetrieved(@NonNull Typeface typeface, boolean fontResolvedSynchronously) {
// Set text to re-trigger internal ellipsize width calculation.
setText(chipDrawable.shouldDrawText() ? chipDrawable.getText() : getText());
requestLayout();
invalidate();
}
@Override
public void onFontRetrievalFailed(int reason) {}
};
public Chip(Context context) {
this(context, null);
}
public Chip(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.chipStyle);
}
public Chip(Context context, AttributeSet attrs, int defStyleAttr) {
super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr);
// Ensure we are using the correctly themed context rather than the context that was passed in.
context = getContext();
validateAttributes(attrs);
ChipDrawable drawable =
ChipDrawable.createFromAttributes(
context, attrs, defStyleAttr, DEF_STYLE_RES);
initMinTouchTarget(context, attrs, defStyleAttr);
setChipDrawable(drawable);
drawable.setElevation(ViewCompat.getElevation(this));
TypedArray a =
ThemeEnforcement.obtainStyledAttributes(
context,
attrs,
R.styleable.Chip,
defStyleAttr,
DEF_STYLE_RES);
if (VERSION.SDK_INT < VERSION_CODES.M) {
// This is necessary to work around a bug that doesn't support themed color referenced in
// ColorStateList for API level < 23.
setTextColor(
MaterialResources.getColorStateList(context, a, R.styleable.Chip_android_textColor));
}
boolean hasShapeAppearanceAttribute = a.hasValue(R.styleable.Chip_shapeAppearance);
a.recycle();
touchHelper = new ChipTouchHelper(this);
updateAccessibilityDelegate();
if (!hasShapeAppearanceAttribute) {
initOutlineProvider();
}
// Set deferred values
setChecked(deferredCheckedValue);
setText(drawable.getText());
setEllipsize(drawable.getEllipsize());
setIncludeFontPadding(false);
updateTextPaintDrawState();
// Chip text should not extend to more than 1 line.
if (!chipDrawable.shouldDrawText()) {
setLines(1);
setHorizontallyScrolling(true);
}
// Chip text should be vertically center aligned and start aligned.
// Final horizontal text origin is set during the onDraw call via canvas translation.
setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
// Helps TextView calculate the available text width.
updatePaddingInternal();
if (shouldEnsureMinTouchTargetSize()) {
setMinHeight(minTouchTargetSize);
}
lastLayoutDirection = ViewCompat.getLayoutDirection(this);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
MaterialShapeUtils.setParentAbsoluteElevation(this, chipDrawable);
}
@RequiresApi(VERSION_CODES.LOLLIPOP)
@Override
public void setElevation(float elevation) {
super.setElevation(elevation);
if (chipDrawable != null) {
chipDrawable.setElevation(elevation);
}
}
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
if (isCheckable() || isClickable()) {
info.setClassName(
isCheckable()
? COMPOUND_BUTTON_ACCESSIBILITY_CLASS_NAME
: BUTTON_ACCESSIBILITY_CLASS_NAME);
} else {
info.setClassName(GENERIC_VIEW_ACCESSIBILITY_CLASS_NAME);
}
info.setCheckable(isCheckable());
info.setClickable(isClickable());
if (getParent() instanceof ChipGroup) {
ChipGroup chipGroup = ((ChipGroup) getParent());
AccessibilityNodeInfoCompat infoCompat = AccessibilityNodeInfoCompat.wrap(info);
// -1 for unknown column indices in a reflowing layout
int columnIndex = chipGroup.isSingleLine() ? chipGroup.getIndexOfChip(this) : -1;
infoCompat.setCollectionItemInfo(
CollectionItemInfoCompat.obtain(
/* rowIndex= */ chipGroup.getRowIndex(this),
/* rowSpan= */ 1,
/* columnIndex= */ columnIndex,
/* columnSpan= */ 1,
/* heading= */ false,
/* selected= */ isChecked()));
}
}
// TODO(b/80452017): Due to a11y bug, avoid setting custom ExploreByTouchHelper as delegate
// unless there's a close/trailing icon. Re-evaulate this once bug is fixed.
private void updateAccessibilityDelegate() {
if (hasCloseIcon() && isCloseIconVisible() && onCloseIconClickListener != null) {
ViewCompat.setAccessibilityDelegate(this, touchHelper);
} else {
// Avoid setting custom ExploreByTouchHelper if the trailing icon is only decorative.
ViewCompat.setAccessibilityDelegate(this, null);
}
}
private void initMinTouchTarget(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
// Checks if the Chip should meet Android's minimum touch target size.
TypedArray a =
ThemeEnforcement.obtainStyledAttributes(
context,
attrs,
R.styleable.Chip,
defStyleAttr,
DEF_STYLE_RES);
ensureMinTouchTargetSize = a.getBoolean(R.styleable.Chip_ensureMinTouchTargetSize, false);
float defaultMinTouchTargetSize =
(float) Math.ceil(ViewUtils.dpToPx(getContext(), MIN_TOUCH_TARGET_DP));
minTouchTargetSize =
(int)
Math.ceil(
a.getDimension(R.styleable.Chip_chipMinTouchTargetSize, defaultMinTouchTargetSize));
a.recycle();
}
/**
* Updates the paddings to inform {@link android.widget.TextView} how much width the text can
* occupy.
*/
private void updatePaddingInternal() {
if (TextUtils.isEmpty(getText()) || chipDrawable == null) {
return;
}
int paddingEnd =
(int)
(chipDrawable.getChipEndPadding()
+ chipDrawable.getTextEndPadding()
+ chipDrawable.calculateCloseIconWidth());
int paddingStart =
(int)
(chipDrawable.getChipStartPadding()
+ chipDrawable.getTextStartPadding()
+ chipDrawable.calculateChipIconWidth());
if (insetBackgroundDrawable != null) {
Rect padding = new Rect();
insetBackgroundDrawable.getPadding(padding);
paddingStart += padding.left;
paddingEnd += padding.right;
}
ViewCompat.setPaddingRelative(
this, paddingStart, getPaddingTop(), paddingEnd, getPaddingBottom());
}
@Override
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
public void onRtlPropertiesChanged(int layoutDirection) {
super.onRtlPropertiesChanged(layoutDirection);
// Layout direction can be updated via a parent (or ancestor) View, causing this property change
// method to be called. Update text padding whenever a layout direction change is detected.
if (lastLayoutDirection != layoutDirection) {
lastLayoutDirection = layoutDirection;
updatePaddingInternal();
}
}
private void validateAttributes(@Nullable AttributeSet attributeSet) {
if (attributeSet == null) {
return;
}
if (attributeSet.getAttributeValue(NAMESPACE_ANDROID, "background") != null) {
Log.w(TAG, "Do not set the background; Chip manages its own background drawable.");
}
if (attributeSet.getAttributeValue(NAMESPACE_ANDROID, "drawableLeft") != null) {
throw new UnsupportedOperationException("Please set left drawable using R.attr#chipIcon.");
}
if (attributeSet.getAttributeValue(NAMESPACE_ANDROID, "drawableStart") != null) {
throw new UnsupportedOperationException("Please set start drawable using R.attr#chipIcon.");
}
if (attributeSet.getAttributeValue(NAMESPACE_ANDROID, "drawableEnd") != null) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
if (attributeSet.getAttributeValue(NAMESPACE_ANDROID, "drawableRight") != null) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
if (!attributeSet.getAttributeBooleanValue(NAMESPACE_ANDROID, "singleLine", true)
|| (attributeSet.getAttributeIntValue(NAMESPACE_ANDROID, "lines", 1) != 1)
|| (attributeSet.getAttributeIntValue(NAMESPACE_ANDROID, "minLines", 1) != 1)
|| (attributeSet.getAttributeIntValue(NAMESPACE_ANDROID, "maxLines", 1) != 1)) {
throw new UnsupportedOperationException("Chip does not support multi-line text");
}
if (attributeSet.getAttributeIntValue(
NAMESPACE_ANDROID, "gravity", (Gravity.CENTER_VERTICAL | Gravity.START))
!= (Gravity.CENTER_VERTICAL | Gravity.START)) {
Log.w(TAG, "Chip text must be vertically center and start aligned");
}
}
private void initOutlineProvider() {
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
setOutlineProvider(
new ViewOutlineProvider() {
@Override
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public void getOutline(View view, @NonNull Outline outline) {
if (chipDrawable != null) {
chipDrawable.getOutline(outline);
} else {
outline.setAlpha(0.0f);
}
}
});
}
}
/** Returns the ChipDrawable backing this chip. */
public Drawable getChipDrawable() {
return chipDrawable;
}
/** Sets the ChipDrawable backing this chip. */
public void setChipDrawable(@NonNull ChipDrawable drawable) {
if (chipDrawable != drawable) {
unapplyChipDrawable(chipDrawable);
chipDrawable = drawable;
// Defers to TextView to draw the text and ChipDrawable to render the
// rest (e.g. chip / check / close icons).
chipDrawable.setShouldDrawText(false);
applyChipDrawable(chipDrawable);
ensureAccessibleTouchTarget(minTouchTargetSize);
}
}
private void updateBackgroundDrawable() {
if (RippleUtils.USE_FRAMEWORK_RIPPLE) {
updateFrameworkRippleBackground();
} else {
chipDrawable.setUseCompatRipple(true);
ViewCompat.setBackground(this, getBackgroundDrawable());
updatePaddingInternal();
ensureChipDrawableHasCallback();
}
}
private void ensureChipDrawableHasCallback() {
if (getBackgroundDrawable() == insetBackgroundDrawable && chipDrawable.getCallback() == null) {
// View#setBackground nulls out the callback of the previous background drawable, so we need
// to reset it.
chipDrawable.setCallback(insetBackgroundDrawable);
}
}
@Nullable
public Drawable getBackgroundDrawable() {
if (insetBackgroundDrawable == null) {
return chipDrawable;
}
return insetBackgroundDrawable;
}
private void updateFrameworkRippleBackground() {
//noinspection NewApi
ripple =
new RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(chipDrawable.getRippleColor()),
getBackgroundDrawable(),
null);
chipDrawable.setUseCompatRipple(false);
//noinspection NewApi
ViewCompat.setBackground(this, ripple);
updatePaddingInternal();
}
private void unapplyChipDrawable(@Nullable ChipDrawable chipDrawable) {
if (chipDrawable != null) {
chipDrawable.setDelegate(null);
}
}
private void applyChipDrawable(@NonNull ChipDrawable chipDrawable) {
chipDrawable.setDelegate(this);
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
final int[] state = super.onCreateDrawableState(extraSpace + 2);
if (isChecked()) {
mergeDrawableStates(state, SELECTED_STATE);
}
if (isCheckable()) {
mergeDrawableStates(state, CHECKABLE_STATE_SET);
}
return state;
}
@Override
public void setGravity(int gravity) {
if (gravity != (Gravity.CENTER_VERTICAL | Gravity.START)) {
Log.w(TAG, "Chip text must be vertically center and start aligned");
} else {
super.setGravity(gravity);
}
}
public void setBackgroundTintList(@Nullable ColorStateList tint) {
Log.w(TAG, "Do not set the background tint list; Chip manages its own background drawable.");
}
@Override
public void setBackgroundTintMode(@Nullable Mode tintMode) {
Log.w(TAG, "Do not set the background tint mode; Chip manages its own background drawable.");
}
@Override
public void setBackgroundColor(int color) {
Log.w(TAG, "Do not set the background color; Chip manages its own background drawable.");
}
@Override
public void setBackgroundResource(int resid) {
Log.w(TAG, "Do not set the background resource; Chip manages its own background drawable.");
}
@Override
public void setBackground(Drawable background) {
if (background != getBackgroundDrawable() && background != ripple) {
Log.w(TAG, "Do not set the background; Chip manages its own background drawable.");
} else {
super.setBackground(background);
}
}
@Override
public void setBackgroundDrawable(Drawable background) {
if (background != getBackgroundDrawable() && background != ripple) {
Log.w(TAG, "Do not set the background drawable; Chip manages its own background drawable.");
} else {
super.setBackgroundDrawable(background);
}
}
@Override
public void setCompoundDrawables(
@Nullable Drawable left,
@Nullable Drawable top,
@Nullable Drawable right,
@Nullable Drawable bottom) {
if (left != null) {
throw new UnsupportedOperationException("Please set start drawable using R.attr#chipIcon.");
}
if (right != null) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
super.setCompoundDrawables(left, top, right, bottom);
}
@Override
public void setCompoundDrawablesWithIntrinsicBounds(int left, int top, int right, int bottom) {
if (left != 0) {
throw new UnsupportedOperationException("Please set start drawable using R.attr#chipIcon.");
}
if (right != 0) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
super.setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
}
@Override
public void setCompoundDrawablesWithIntrinsicBounds(
@Nullable Drawable left,
@Nullable Drawable top,
@Nullable Drawable right,
@Nullable Drawable bottom) {
if (left != null) {
throw new UnsupportedOperationException("Please set left drawable using R.attr#chipIcon.");
}
if (right != null) {
throw new UnsupportedOperationException("Please set right drawable using R.attr#closeIcon.");
}
super.setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
}
@Override
public void setCompoundDrawablesRelative(
@Nullable Drawable start,
@Nullable Drawable top,
@Nullable Drawable end,
@Nullable Drawable bottom) {
if (start != null) {
throw new UnsupportedOperationException("Please set start drawable using R.attr#chipIcon.");
}
if (end != null) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
super.setCompoundDrawablesRelative(start, top, end, bottom);
}
@Override
public void setCompoundDrawablesRelativeWithIntrinsicBounds(
int start, int top, int end, int bottom) {
if (start != 0) {
throw new UnsupportedOperationException("Please set start drawable using R.attr#chipIcon.");
}
if (end != 0) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
super.setCompoundDrawablesRelativeWithIntrinsicBounds(start, top, end, bottom);
}
@Override
public void setCompoundDrawablesRelativeWithIntrinsicBounds(
@Nullable Drawable start,
@Nullable Drawable top,
@Nullable Drawable end,
@Nullable Drawable bottom) {
if (start != null) {
throw new UnsupportedOperationException("Please set start drawable using R.attr#chipIcon.");
}
if (end != null) {
throw new UnsupportedOperationException("Please set end drawable using R.attr#closeIcon.");
}
super.setCompoundDrawablesRelativeWithIntrinsicBounds(start, top, end, bottom);
}
@Nullable
@Override
public TruncateAt getEllipsize() {
return chipDrawable != null ? chipDrawable.getEllipsize() : null;
}
@Override
public void setEllipsize(TruncateAt where) {
if (chipDrawable == null) {
return;
}
if (where == TruncateAt.MARQUEE) {
throw new UnsupportedOperationException("Text within a chip are not allowed to scroll.");
}
super.setEllipsize(where);
if (chipDrawable != null) {
chipDrawable.setEllipsize(where);
}
}
@Override
public void setSingleLine(boolean singleLine) {
if (!singleLine) {
throw new UnsupportedOperationException("Chip does not support multi-line text");
}
super.setSingleLine(singleLine);
}
@Override
public void setLines(int lines) {
if (lines > 1) {
throw new UnsupportedOperationException("Chip does not support multi-line text");
}
super.setLines(lines);
}
@Override
public void setMinLines(int minLines) {
if (minLines > 1) {
throw new UnsupportedOperationException("Chip does not support multi-line text");
}
super.setMinLines(minLines);
}
@Override
public void setMaxLines(int maxLines) {
if (maxLines > 1) {
throw new UnsupportedOperationException("Chip does not support multi-line text");
}
super.setMaxLines(maxLines);
}
@Override
public void setMaxWidth(@Px int maxWidth) {
super.setMaxWidth(maxWidth);
if (chipDrawable != null) {
chipDrawable.setMaxWidth(maxWidth);
}
}
@Override
public void onChipDrawableSizeChange() {
ensureAccessibleTouchTarget(minTouchTargetSize);
requestLayout();
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
invalidateOutline();
}
}
@Override
public void setChecked(boolean checked) {
if (chipDrawable == null) {
// Defer the setChecked() call until after initialization.
deferredCheckedValue = checked;
} else if (chipDrawable.isCheckable()) {
boolean wasChecked = isChecked();
super.setChecked(checked);
if (wasChecked != checked) {
if (onCheckedChangeListenerInternal != null) {
onCheckedChangeListenerInternal.onCheckedChanged(this, checked);
}
}
}
}
/**
* Register a callback to be invoked when the checked state of this chip changes. This callback is
* used for internal purpose only.
*/
void setOnCheckedChangeListenerInternal(OnCheckedChangeListener listener) {
onCheckedChangeListenerInternal = listener;
}
/** Register a callback to be invoked when the close icon is clicked. */
public void setOnCloseIconClickListener(OnClickListener listener) {
this.onCloseIconClickListener = listener;
updateAccessibilityDelegate();
}
/**
* Call this chip's close icon click listener, if it is defined. Performs all normal actions
* associated with clicking: reporting accessibility event, playing a sound, etc.
*
* @return True there was an assigned close icon click listener that was called, false otherwise
* is returned.
* @see #setOnCloseIconClickListener(OnClickListener)
*/
@CallSuper
public boolean performCloseIconClick() {
playSoundEffect(SoundEffectConstants.CLICK);
boolean result;
if (onCloseIconClickListener != null) {
onCloseIconClickListener.onClick(this);
result = true;
} else {
result = false;
}
touchHelper.sendEventForVirtualView(
CLOSE_ICON_VIRTUAL_ID, AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}
@SuppressLint("ClickableViewAccessibility") // There's an accessibility delegate that will handle
// interactions with the trailing chip icon.
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
boolean handled = false;
int action = event.getActionMasked();
boolean eventInCloseIcon = getCloseIconTouchBounds().contains(event.getX(), event.getY());
switch (action) {
case MotionEvent.ACTION_DOWN:
if (eventInCloseIcon) {
setCloseIconPressed(true);
handled = true;
}
break;
case MotionEvent.ACTION_MOVE:
if (closeIconPressed) {
if (!eventInCloseIcon) {
setCloseIconPressed(false);
}
handled = true;
}
break;
case MotionEvent.ACTION_UP:
if (closeIconPressed) {
performCloseIconClick();
handled = true;
}
// Fall-through.
case MotionEvent.ACTION_CANCEL:
setCloseIconPressed(false);
break;
default:
break;
}
return handled || super.onTouchEvent(event);
}
@Override
public boolean onHoverEvent(@NonNull MotionEvent event) {
int action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_HOVER_MOVE:
setCloseIconHovered(getCloseIconTouchBounds().contains(event.getX(), event.getY()));
break;
case MotionEvent.ACTION_HOVER_EXIT:
setCloseIconHovered(false);
break;
default:
break;
}
return super.onHoverEvent(event);
}
// There is a bug which causes the AccessibilityEvent.TYPE_VIEW_HOVER_ENTER and
// AccessibilityEvent.TYPE_VIEW_HOVER_EXIT events to only fire the first time a chip gets focused.
// Until the accessibility focus bug is fixed in ExploreByTouchHelper, we simulate the correct
// behavior here. Once that bug is fixed we can remove this.
@SuppressLint("PrivateApi")
private boolean handleAccessibilityExit(@NonNull MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) {
try {
Field f = ExploreByTouchHelper.class.getDeclaredField("mHoveredVirtualViewId");
f.setAccessible(true);
int mHoveredVirtualViewId = (int) f.get(touchHelper);
if (mHoveredVirtualViewId != ExploreByTouchHelper.INVALID_ID) {
Method m =
ExploreByTouchHelper.class.getDeclaredMethod("updateHoveredVirtualView", int.class);
m.setAccessible(true);
m.invoke(touchHelper, ExploreByTouchHelper.INVALID_ID);
return true;
}
} catch (NoSuchMethodException e) {
// Multi-catch for reflection requires API level 19
Log.e(TAG, "Unable to send Accessibility Exit event", e);
} catch (IllegalAccessException e) {
// Multi-catch for reflection requires API level 19
Log.e(TAG, "Unable to send Accessibility Exit event", e);
} catch (InvocationTargetException e) {
// Multi-catch for reflection requires API level 19
Log.e(TAG, "Unable to send Accessibility Exit event", e);
} catch (NoSuchFieldException e) {
// Multi-catch for reflection requires API level 19
Log.e(TAG, "Unable to send Accessibility Exit event", e);
}
}
return false;
}
@Override
protected boolean dispatchHoverEvent(@NonNull MotionEvent event) {
return handleAccessibilityExit(event)
|| touchHelper.dispatchHoverEvent(event)
|| super.dispatchHoverEvent(event);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
boolean handled = touchHelper.dispatchKeyEvent(event);
// If the key event moves focus one beyond the end of the virtual view hierarchy in the
// traversal direction (i.e. beyond the last virtual view while moving forward or before the
// first virtual view while traversing backward), ExploreByTouchHelper will erroneously report
// that it consumed the key event even though it does not move focus to the next or previous
// real view. In order to account for this, call through to super to move focus to the correct
// real view.
if (handled
&& touchHelper.getKeyboardFocusedVirtualViewId() != ExploreByTouchHelper.INVALID_ID) {
return true;
}
return super.dispatchKeyEvent(event);
}
@Override
protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(focused, direction, previouslyFocusedRect);
touchHelper.onFocusChanged(focused, direction, previouslyFocusedRect);
}
@Override
public void getFocusedRect(@NonNull Rect r) {
if (touchHelper.getKeyboardFocusedVirtualViewId() == CLOSE_ICON_VIRTUAL_ID
|| touchHelper.getAccessibilityFocusedVirtualViewId() == CLOSE_ICON_VIRTUAL_ID) {
r.set(getCloseIconTouchBoundsInt());
} else {
super.getFocusedRect(r);
}
}
private void setCloseIconPressed(boolean pressed) {
if (closeIconPressed != pressed) {
closeIconPressed = pressed;
refreshDrawableState();
}
}
private void setCloseIconHovered(boolean hovered) {
if (closeIconHovered != hovered) {
closeIconHovered = hovered;
refreshDrawableState();
}
}
@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
boolean changed = false;
if (chipDrawable != null && chipDrawable.isCloseIconStateful()) {
changed = chipDrawable.setCloseIconState(createCloseIconDrawableState());
}
if (changed) {
invalidate();
}
}
@NonNull
private int[] createCloseIconDrawableState() {
int count = 0;
if (isEnabled()) {
count++;
}
if (closeIconFocused) {
count++;
}
if (closeIconHovered) {
count++;
}
if (closeIconPressed) {
count++;
}
if (isChecked()) {
count++;
}
int[] stateSet = new int[count];
int i = 0;
if (isEnabled()) {
stateSet[i] = android.R.attr.state_enabled;
i++;
}
if (closeIconFocused) {
stateSet[i] = android.R.attr.state_focused;
i++;
}
if (closeIconHovered) {
stateSet[i] = android.R.attr.state_hovered;
i++;
}
if (closeIconPressed) {
stateSet[i] = android.R.attr.state_pressed;
i++;
}
if (isChecked()) {
stateSet[i] = android.R.attr.state_selected;
i++;
}
return stateSet;
}
private boolean hasCloseIcon() {
return chipDrawable != null && chipDrawable.getCloseIcon() != null;
}
@NonNull
private RectF getCloseIconTouchBounds() {
rectF.setEmpty();
if (hasCloseIcon()) {
// noinspection ConstantConditions
chipDrawable.getCloseIconTouchBounds(rectF);
}
return rectF;
}
@NonNull
private Rect getCloseIconTouchBoundsInt() {
RectF bounds = getCloseIconTouchBounds();
rect.set((int) bounds.left, (int) bounds.top, (int) bounds.right, (int) bounds.bottom);
return rect;
}
@Nullable
@Override
@TargetApi(VERSION_CODES.N)
public PointerIcon onResolvePointerIcon(@NonNull MotionEvent event, int pointerIndex) {
if (getCloseIconTouchBounds().contains(event.getX(), event.getY()) && isEnabled()) {
return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND);
}
return null;
}
/** Provides a virtual view hierarchy for the close icon. */
private class ChipTouchHelper extends ExploreByTouchHelper {
ChipTouchHelper(Chip view) {
super(view);
}
@Override
protected int getVirtualViewAt(float x, float y) {
return (hasCloseIcon() && getCloseIconTouchBounds().contains(x, y))
? CLOSE_ICON_VIRTUAL_ID
: CHIP_BODY_VIRTUAL_ID;
}
@Override
protected void getVisibleVirtualViews(@NonNull List