/* * Copyright © 2016 Tinkoff Bank * * 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 * * http://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 ru.tinkoff.decoro.watchers; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import android.text.Editable; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextWatcher; import android.view.View; import android.view.inputmethod.BaseInputConnection; import android.widget.EditText; import android.widget.TextView; import ru.tinkoff.decoro.FormattedTextChangeListener; import ru.tinkoff.decoro.Mask; import ru.tinkoff.decoro.MaskFactory; /** * <p> * This class encapsulates logic of formatting (pretty printing) content of a TextView. All * the formatting logic is encapsulated inside the {@link Mask} class. This class is only * used to follow TextView changes and format it according to the {@link Mask}. It's okay * to * use it either with {@link TextView} or {@link EditText}. Important note for using with * bare {@link TextView}. Since its content usually changes with {@link TextView#setText}, * inserting text should contain all the hardcoded symbols of the {@link Mask}. * <p> * All the children classes should implement their own way of creating {@link Mask}. * * @author Mikhail Artemev */ public abstract class FormatWatcher implements TextWatcher, MaskFactory { public static boolean DEBUG = false; private static final String TAG = "FormatWatcher"; private DiffMeasures diffMeasures = new DiffMeasures(); private CharSequence textBeforeChange; private Mask mask; private TextView textView; private boolean initWithMask; private boolean selfEdit = false; private boolean noChanges = false; private boolean formattingCancelled = false; private FormattedTextChangeListener callback; protected FormatWatcher() { } /** * Starts to follow text changes in the specified {@link TextView} to format any input. <br/> * IMPORTANT: this call will force watcher to re-create the mask * * @param textView text view to watch and format */ public void installOn(@NonNull final TextView textView) { installOn(textView, false); } /** * Starts to follow text changes in the specified {@link TextView} to format any input. * Initial mask's value (e.g. hardcoded head) will be displayed in the view.<br/> * IMPORTANT: this call will force watcher to re-create the mask * * @param textView text view to watch and format */ public void installOnAndFill(@NonNull final TextView textView) { installOn(textView, true); } public void removeFromTextView() { if (textView != null) { this.textView.removeTextChangedListener(this); this.textView = null; } } public boolean isInstalled() { return this.textView != null; } public boolean hasMask() { return mask != null; } /** * @param textView an observable text view which content text will be formatted using * {@link * Mask} * @param initWithMask this flags defines whether hardcoded head of the mask (e.g "+7 ") will * fill the initial text of the {@code textView}. */ protected void installOn(final TextView textView, final boolean initWithMask) { if (textView == null) { throw new IllegalArgumentException("text view cannot be null"); } this.textView = textView; this.initWithMask = initWithMask; // try to remove us from listeners (useful in case user's trying to install the formatter twice on a same TextView) textView.removeTextChangedListener(this); textView.addTextChangedListener(this); this.mask = null; refreshMask(); } public void refreshMask() { refreshMask(null); } public void refreshMask(@Nullable final CharSequence initialValue) { final boolean initial = this.mask == null; this.mask = createMask(); checkMask(); final boolean initiationNeeded = initialValue != null; diffMeasures = new DiffMeasures(); if (initiationNeeded) { diffMeasures.setCursorPosition(mask.insertFront(initialValue)); } if ((!initial || initWithMask || initiationNeeded) && isInstalled()) { selfEdit = true; final String formattedInitialValue = mask.toString(); if (textView instanceof EditText) { final Editable editable = (Editable) textView.getText(); editable.replace(0, editable.length(), formattedInitialValue, 0, formattedInitialValue.length()); } else { textView.setText(formattedInitialValue); } setSelection(mask.getInitialInputPosition()); selfEdit = false; } } @NonNull @Override public String toString() { return mask == null ? "" : mask.toString(); } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { if (selfEdit || mask == null) { return; } // copy original string textBeforeChange = new String(s.toString()); diffMeasures.calculateBeforeTextChanged(start, count, after); } @Override public void onTextChanged(CharSequence s, int start, int before, int insertedCount) { if (selfEdit || mask == null) { return; } CharSequence diffChars = null; if (diffMeasures.isInsertingChars()) { diffChars = s.subSequence(diffMeasures.getStartPosition(), diffMeasures.getInsertEndPosition()); // Here's the android's "feature": if EditText supports input hints then modification of // a word from the TextWatcher's perspective will look like a removing a word and // inserting shorter (or longer) word instead. For example: removing trailing "s" from // the word "Carlos" will be presented as removing the word "Carlos" and inserting the // word "Carlo" instead. If modified sequence ends with hardcoded symbols then such a // modification will affect the cursor position. To avoid placing a cursor in an // unexpected position we should detect such changes and present them as the removing of // an actual character(s). So in the above example we should present a modification as a // simple removing of "s". if (diffMeasures.isTrimmingSequence()) { CharSequence diffBefore = textBeforeChange.subSequence(diffMeasures.getStartPosition(), diffMeasures.getInsertEndPosition()); if (diffBefore.equals(diffChars)) { // that's it. We're removing trailing character(s) of a word! // so modify diff info and present it as a removing of those characters diffMeasures.recalculateOnModifyingWord(diffChars.length()); } } } // ask client code - should we proceed the modification of a mask if (callback != null && callback.beforeFormatting(textBeforeChange.toString(), s.toString())) { formattingCancelled = true; return; } noChanges = textBeforeChange.equals(s.toString()); if (noChanges) { return; } if (diffMeasures.isRemovingChars()) { if (!diffMeasures.isInsertingChars()) { diffMeasures.setCursorPosition(mask.removeBackwards(diffMeasures.getRemoveEndPosition(), diffMeasures.getRemoveLength())); } else { diffMeasures.setCursorPosition(mask.removeBackwardsWithoutHardcoded(diffMeasures.getRemoveEndPosition(), diffMeasures.getRemoveLength())); } } if (diffMeasures.isInsertingChars()) { diffMeasures.setCursorPosition(mask.insertAt(diffMeasures.getStartPosition(), diffChars)); } } @Override public void afterTextChanged(Editable newText) { if (formattingCancelled || selfEdit || mask == null || noChanges) { formattingCancelled = false; noChanges = false; return; } String formatted = mask.toString(); final int cursorPosition = diffMeasures.getCursorPosition(); // force change text of EditText we're attached to // only in case it's necessary (formatted text differs from inputted) if (!formatted.equals(newText.toString())) { int start = BaseInputConnection.getComposingSpanStart(newText); int end = cursorPosition > newText.length() ? newText.length() : cursorPosition; CharSequence pasted; if (start == -1 || end == -1) { pasted = formatted; } else { SpannableStringBuilder sb = new SpannableStringBuilder(); sb.append(formatted.substring(0, start)); SpannableString composing = new SpannableString(formatted.substring(start, end)); // void setComposingSpans(Spannable text, int start, int end) in BaseInputConnection is hide api BaseInputConnection.setComposingSpans(composing); sb.append(composing); sb.append(formatted.substring(end, formatted.length())); pasted = sb; } selfEdit = true; newText.replace(0, newText.length(), pasted, 0, formatted.length()); selfEdit = false; } if (0 <= cursorPosition && cursorPosition <= newText.length()) { setSelection(cursorPosition); } textBeforeChange = null; if (callback != null) { callback.onTextFormatted(this, toString()); } } /** * @return Unmodifiable wrapper around inner mask. It allows to obtain inner mask statem * but not to change it. */ public Mask getMask() { return new UnmodifiableMask(mask); } public boolean isAttachedTo(@NonNull View view) { return this.textView == view; } public void setCallback(@NonNull FormattedTextChangeListener callback) { this.callback = callback; } public int getCursorPosition() { return diffMeasures.getCursorPosition(); } protected Mask getTrueMask() { return mask; } protected void setTrueMask(Mask mask) { this.mask = mask; } protected TextView getTextView() { return textView; } protected void setTextView(TextView textView) { this.textView = textView; } private void checkMask() { if (mask == null) { throw new IllegalStateException("Mask cannot be null at this point. Check maybe you forgot " + "to call refreshMask()"); } } private void setSelection(int position) { if (textView instanceof EditText && position <= textView.length()) { ((EditText) textView).setSelection(position); } } }