package com.hootsuite.nachos; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.Paint; import android.text.Editable; import android.text.Layout; import android.text.TextUtils; import android.text.TextWatcher; import android.util.AttributeSet; import android.util.Log; import android.util.Pair; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import android.view.inputmethod.EditorInfo; import android.widget.Adapter; import android.widget.AdapterView; import android.widget.AutoCompleteTextView; import android.widget.ListAdapter; import android.widget.MultiAutoCompleteTextView; import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.hootsuite.nachos.chip.Chip; import com.hootsuite.nachos.chip.ChipInfo; import com.hootsuite.nachos.chip.ChipSpan; import com.hootsuite.nachos.chip.ChipSpanChipCreator; import com.hootsuite.nachos.terminator.ChipTerminatorHandler; import com.hootsuite.nachos.terminator.DefaultChipTerminatorHandler; import com.hootsuite.nachos.tokenizer.ChipTokenizer; import com.hootsuite.nachos.tokenizer.SpanChipTokenizer; import com.hootsuite.nachos.validator.ChipifyingNachoValidator; import com.hootsuite.nachos.validator.IllegalCharacterIdentifier; import com.hootsuite.nachos.validator.NachoValidator; import org.joinmastodon.android.R; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; /** * An editable TextView extending {@link MultiAutoCompleteTextView} that supports "chipifying" pieces of text and displaying suggestions for segments of the text. *
* nachoTextView.setNachoValidator(new ChipifyingNachoValidator()); ** Note: The NachoValidator will be ignored if a ChipTokenizer is not set. To perform validation without a ChipTokenizer you can use * {@link AutoCompleteTextView}'s built-in {@link AutoCompleteTextView.Validator Validator} through {@link #setValidator(Validator)} *
* String[] suggestions = new String[]{"suggestion 1", "suggestion 2"};
* ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, suggestions);
* nachoTextView.setAdapter(adapter);
* nachoTextView.addChipTerminator('\n', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL);
* nachoTextView.addChipTerminator(' ', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_TO_TERMINATOR);
* nachoTextView.setIllegalCharacters('@');
* nachoTextView.setNachoValidator(new ChipifyingNachoValidator());
* nachoTextView.enableEditChipOnTouch(true, true);
* nachoTextView.setOnChipClickListener(new NachoTextView.OnChipClickListener() {
* {@literal @Override}
* public void onChipClick(Chip chip, MotionEvent motionEvent) {
* // Handle click event
* }
* });
* nachoTextView.setOnChipRemoveListener(new NachoTextView.OnChipRemoveListener() {
* {@literal @Override}
* public void onChipRemove(Chip chip) {
* // Handle remove event
* }
* });
*
*
* @see SpanChipTokenizer
* @see DefaultChipTerminatorHandler
* @see ChipifyingNachoValidator
*/
public class NachoTextView extends MultiAutoCompleteTextView implements TextWatcher, AdapterView.OnItemClickListener {
// UI Attributes
private int mChipHorizontalSpacing = -1;
private ColorStateList mChipBackground = null;
private int mChipCornerRadius = -1;
private int mChipTextColor = Color.TRANSPARENT;
private int mChipTextSize = -1;
private int mChipHeight = -1;
private int mChipVerticalSpacing = -1;
private int mDefaultPaddingTop = 0;
private int mDefaultPaddingBottom = 0;
/**
* Flag to keep track of the padding state so we only update the padding when necessary
*/
private boolean mUsingDefaultPadding = true;
// Touch events
@Nullable
private OnChipClickListener mOnChipClickListener;
private GestureDetector singleTapDetector;
private boolean mEditChipOnTouchEnabled;
private boolean mMoveChipToEndOnEdit;
private boolean mChipifyUnterminatedTokensOnEdit;
// Text entry
@Nullable
private ChipTokenizer mChipTokenizer;
@Nullable
private ChipTerminatorHandler mChipTerminatorHandler;
@Nullable
private NachoValidator mNachoValidator;
@Nullable
private IllegalCharacterIdentifier illegalCharacterIdentifier;
@Nullable
private OnChipRemoveListener mOnChipRemoveListener;
private List* Note: If an {@link OnChipClickListener} is set it's behavior will override the behavior described here if it's * {@link OnChipClickListener#onChipClick(Chip, MotionEvent)} method returns true. If that method returns false, the touched chip will be put * in editing mode as expected. *
* * @param moveChipToEnd if true, the chip will also be moved to the end of the text when it is put in editing mode * @param chipifyUnterminatedTokens if true, all unterminated tokens will be chipified before the touched chip is put in editing mode * @see #disableEditChipOnTouch() */ public void enableEditChipOnTouch(boolean moveChipToEnd, boolean chipifyUnterminatedTokens) { mEditChipOnTouchEnabled = true; mMoveChipToEndOnEdit = moveChipToEnd; mChipifyUnterminatedTokensOnEdit = chipifyUnterminatedTokens; } /** * Disables editing chips on touch events. To re-enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}. * * @see #enableEditChipOnTouch(boolean, boolean) */ public void disableEditChipOnTouch() { mEditChipOnTouchEnabled = false; } /** * Puts the provided Chip in editing mode (i.e. reverts it to an unchipified token whose text can be edited). * * @param chip the chip to edit * @param moveChipToEnd if true, the chip will also be moved to the end of the text */ public void setEditingChip(Chip chip, boolean moveChipToEnd) { if (mChipTokenizer == null) { return; } beginUnwatchedTextChange(); Editable text = getText(); if (moveChipToEnd) { // Move the chip text to the end of the text text.append(chip.getText()); // Delete the existing chip mChipTokenizer.deleteChipAndPadding(chip, text); // Move the cursor to the end of the text setSelection(text.length()); } else { int chipStart = mChipTokenizer.findChipStart(chip, text); mChipTokenizer.revertChipToToken(chip, text); setSelection(mChipTokenizer.findTokenEnd(text, chipStart)); } endUnwatchedTextChange(); } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { boolean wasHandled = false; clearChipStates(); Chip touchedChip = findTouchedChip(event); if (touchedChip != null && isFocused() && singleTapDetector.onTouchEvent(event)) { touchedChip.setState(View.PRESSED_SELECTED_STATE_SET); if (onChipClicked(touchedChip)) { wasHandled = true; } if (mOnChipClickListener != null) { mOnChipClickListener.onChipClick(touchedChip, event); } } // Getting NullPointerException inside Editor.updateFloatingToolbarVisibility (Editor.java:1520) // primarily seen in Samsung Nougat devices. boolean superOnTouch = false; try { superOnTouch = super.onTouchEvent(event); } catch (NullPointerException e) { Log.w("Nacho", String.format("Error during touch event of type [%d]", event.getAction()), e); // can't handle or reproduce, but will monitor the error } return wasHandled || superOnTouch; } @Nullable private Chip findTouchedChip(MotionEvent event) { if (mChipTokenizer == null) { return null; } Editable text = getText(); int offset = getOffsetForPosition(event.getX(), event.getY()); List
* If there is no ChipTokenizer set, we call through to the super method.
*
* @param text the text to be chipified
*/
@Override
protected void replaceText(CharSequence text) {
// If we have a ChipTokenizer, this will be handled by our OnItemClickListener so we can do nothing here.
// If we don't have a ChipTokenizer, we'll use the default behavior
if (mChipTokenizer == null) {
super.replaceText(text);
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
if (mIgnoreTextChangedEvents) {
return;
}
mTextChangedStart = start;
mTextChangedEnd = start + after;
// Check for backspace
if (mChipTokenizer != null) {
if (count > 0 && after < count) {
int end = start + count;
Editable message = getText();
Chip[] chips = mChipTokenizer.findAllChips(start, end, message);
for (Chip chip : chips) {
int spanStart = mChipTokenizer.findChipStart(chip, message);
int spanEnd = mChipTokenizer.findChipEnd(chip, message);
if ((spanStart < end) && (spanEnd > start)) {
// Add to remove list
mChipsToRemove.add(chip);
}
}
}
}
}
@Override
public void onTextChanged(@NonNull CharSequence textChanged, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable message) {
if (mIgnoreTextChangedEvents) {
return;
}
// Avoid triggering text changed events from changes we make in this method
beginUnwatchedTextChange();
// Handle backspace key
if (mChipTokenizer != null) {
Iterator