/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.react.views.textinput;
import javax.annotation.Nullable;
import java.util.ArrayList;
import android.content.Context;
import android.graphics.Rect;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import com.facebook.infer.annotation.Assertions;
/**
* A wrapper around the EditText that lets us better control what happens when an EditText gets
* focused or blurred, and when to display the soft keyboard and when not to.
*
* ReactEditTexts have setFocusableInTouchMode set to false automatically because touches on the
* EditText are managed on the JS side. This also removes the nasty side effect that EditTexts
* have, which is that focus is always maintained on one of the EditTexts.
*
* The wrapper stops the EditText from triggering *TextChanged events, in the case where JS
* has called this explicitly. This is the default behavior on other platforms as well.
* VisibleForTesting from {@link TextInputEventsTestCase}.
*/
public class ReactEditText extends EditText {
private final InputMethodManager mInputMethodManager;
// This flag is set to true when we set the text of the EditText explicitly. In that case, no
// *TextChanged events should be triggered. This is less expensive than removing the text
// listeners and adding them back again after the text change is completed.
private boolean mIsSettingTextFromJS;
// This component is controlled, so we want it to get focused only when JS ask it to do so.
// Whenever android requests focus (which it does for random reasons), it will be ignored.
private boolean mIsJSSettingFocus;
private int mDefaultGravityHorizontal;
private int mDefaultGravityVertical;
private int mNativeEventCount;
private @Nullable ArrayList<TextWatcher> mListeners;
private @Nullable TextWatcherDelegator mTextWatcherDelegator;
private int mStagedInputType;
public ReactEditText(Context context) {
super(context);
setFocusableInTouchMode(false);
mInputMethodManager = (InputMethodManager)
Assertions.assertNotNull(getContext().getSystemService(Context.INPUT_METHOD_SERVICE));
mDefaultGravityHorizontal =
getGravity() & (Gravity.HORIZONTAL_GRAVITY_MASK | Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK);
mDefaultGravityVertical = getGravity() & Gravity.VERTICAL_GRAVITY_MASK;
mNativeEventCount = 0;
mIsSettingTextFromJS = false;
mIsJSSettingFocus = false;
mListeners = null;
mTextWatcherDelegator = null;
mStagedInputType = getInputType();
}
// After the text changes inside an EditText, TextView checks if a layout() has been requested.
// If it has, it will not scroll the text to the end of the new text inserted, but wait for the
// next layout() to be called. However, we do not perform a layout() after a requestLayout(), so
// we need to override isLayoutRequested to force EditText to scroll to the end of the new text
// immediately.
// TODO: t6408636 verify if we should schedule a layout after a View does a requestLayout()
@Override
public boolean isLayoutRequested() {
return false;
}
// Consume 'Enter' key events: TextView tries to give focus to the next TextInput, but it can't
// since we only allow JS to change focus, which in turn causes TextView to crash.
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_ENTER &&
((getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) == 0 )) {
hideSoftKeyboard();
return true;
}
return super.onKeyUp(keyCode, event);
}
@Override
public void clearFocus() {
setFocusableInTouchMode(false);
super.clearFocus();
hideSoftKeyboard();
}
@Override
public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
// Always return true if we are already focused. This is used by android in certain places,
// such as text selection.
if (isFocused()) {
return true;
}
if (!mIsJSSettingFocus) {
return false;
}
setFocusableInTouchMode(true);
boolean focused = super.requestFocus(direction, previouslyFocusedRect);
showSoftKeyboard();
return focused;
}
@Override
public void addTextChangedListener(TextWatcher watcher) {
if (mListeners == null) {
mListeners = new ArrayList<>();
super.addTextChangedListener(getTextWatcherDelegator());
}
mListeners.add(watcher);
}
@Override
public void removeTextChangedListener(TextWatcher watcher) {
if (mListeners != null) {
mListeners.remove(watcher);
if (mListeners.isEmpty()) {
mListeners = null;
super.removeTextChangedListener(getTextWatcherDelegator());
}
}
}
/*protected*/ int getStagedInputType() {
return mStagedInputType;
}
/*package*/ void setStagedInputType(int stagedInputType) {
mStagedInputType = stagedInputType;
}
/*package*/ void commitStagedInputType() {
if (getInputType() != mStagedInputType) {
setInputType(mStagedInputType);
}
}
@Override
public void setInputType(int type) {
super.setInputType(type);
mStagedInputType = type;
}
/* package */ void requestFocusFromJS() {
mIsJSSettingFocus = true;
requestFocus();
mIsJSSettingFocus = false;
}
/* package */ void clearFocusFromJS() {
clearFocus();
}
// VisibleForTesting from {@link TextInputEventsTestCase}.
public int incrementAndGetEventCounter() {
return ++mNativeEventCount;
}
// VisibleForTesting from {@link TextInputEventsTestCase}.
public void maybeSetText(ReactTextUpdate reactTextUpdate) {
// Only set the text if it is up to date.
if (reactTextUpdate.getJsEventCounter() < mNativeEventCount) {
return;
}
// The current text gets replaced with the text received from JS. However, the spans on the
// current text need to be adapted to the new text. Since TextView#setText() will remove or
// reset some of these spans even if they are set directly, SpannableStringBuilder#replace() is
// used instead (this is also used by the the keyboard implementation underneath the covers).
SpannableStringBuilder spannableStringBuilder =
new SpannableStringBuilder(reactTextUpdate.getText());
manageSpans(spannableStringBuilder);
mIsSettingTextFromJS = true;
getText().replace(0, length(), spannableStringBuilder);
mIsSettingTextFromJS = false;
}
/**
* Remove and/or add {@link Spanned.SPAN_EXCLUSIVE_EXCLUSIVE} spans, since they should only exist
* as long as the text they cover is the same. All other spans will remain the same, since they
* will adapt to the new text, hence why {@link SpannableStringBuilder#replace} never removes
* them.
*/
private void manageSpans(SpannableStringBuilder spannableStringBuilder) {
Object[] spans = getText().getSpans(0, length(), Object.class);
for (int spanIdx = 0; spanIdx < spans.length; spanIdx++) {
if ((getText().getSpanFlags(spans[spanIdx]) & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) !=
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) {
continue;
}
Object span = spans[spanIdx];
final int spanStart = getText().getSpanStart(spans[spanIdx]);
final int spanEnd = getText().getSpanEnd(spans[spanIdx]);
final int spanFlags = getText().getSpanFlags(spans[spanIdx]);
// Make sure the span is removed from existing text, otherwise the spans we set will be
// ignored or it will cover text that has changed.
getText().removeSpan(spans[spanIdx]);
if (sameTextForSpan(getText(), spannableStringBuilder, spanStart, spanEnd)) {
spannableStringBuilder.setSpan(span, spanStart, spanEnd, spanFlags);
}
}
}
private static boolean sameTextForSpan(
final Editable oldText,
final SpannableStringBuilder newText,
final int start,
final int end) {
if (start > newText.length() || end > newText.length()) {
return false;
}
for (int charIdx = start; charIdx < end; charIdx++) {
if (oldText.charAt(charIdx) != newText.charAt(charIdx)) {
return false;
}
}
return true;
}
private boolean showSoftKeyboard() {
return mInputMethodManager.showSoftInput(this, 0);
}
private void hideSoftKeyboard() {
mInputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
}
private TextWatcherDelegator getTextWatcherDelegator() {
if (mTextWatcherDelegator == null) {
mTextWatcherDelegator = new TextWatcherDelegator();
}
return mTextWatcherDelegator;
}
/* package */ void setGravityHorizontal(int gravityHorizontal) {
if (gravityHorizontal == 0) {
gravityHorizontal = mDefaultGravityHorizontal;
}
setGravity(
(getGravity() & ~Gravity.HORIZONTAL_GRAVITY_MASK &
~Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) | gravityHorizontal);
}
/* package */ void setGravityVertical(int gravityVertical) {
if (gravityVertical == 0) {
gravityVertical = mDefaultGravityVertical;
}
setGravity((getGravity() & ~Gravity.VERTICAL_GRAVITY_MASK) | gravityVertical);
}
/**
* This class will redirect *TextChanged calls to the listeners only in the case where the text
* is changed by the user, and not explicitly set by JS.
*/
private class TextWatcherDelegator implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.beforeTextChanged(s, start, count, after);
}
}
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.onTextChanged(s, start, before, count);
}
}
}
@Override
public void afterTextChanged(Editable s) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (android.text.TextWatcher listener : mListeners) {
listener.afterTextChanged(s);
}
}
}
}
}