/**
* 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.Color;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Build;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.method.KeyListener;
import android.text.method.QwertyKeyListener;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.views.text.CustomStyleSpan;
import com.facebook.react.views.text.ReactTagSpan;
import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.TextInlineImageSpan;
import com.facebook.react.views.view.ReactViewBackgroundDrawable;
/**
* 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 int mMostRecentEventCount;
private @Nullable ArrayList<TextWatcher> mListeners;
private @Nullable TextWatcherDelegator mTextWatcherDelegator;
private int mStagedInputType;
private boolean mContainsImages;
private boolean mBlurOnSubmit;
private boolean mDisableFullscreen;
private @Nullable String mReturnKeyType;
private @Nullable SelectionWatcher mSelectionWatcher;
private @Nullable ContentSizeWatcher mContentSizeWatcher;
private final InternalKeyListener mKeyListener;
private boolean mDetectScrollMovement = false;
private ReactViewBackgroundDrawable mReactBackgroundDrawable;
private static final KeyListener sKeyListener = QwertyKeyListener.getInstanceForFullKeyboard();
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;
mMostRecentEventCount = 0;
mIsSettingTextFromJS = false;
mIsJSSettingFocus = false;
mBlurOnSubmit = true;
mDisableFullscreen = false;
mListeners = null;
mTextWatcherDelegator = null;
mStagedInputType = getInputType();
mKeyListener = new InternalKeyListener();
}
// 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() {
// If we are watching and updating container height based on content size
// then we don't want to scroll right away. This isn't perfect -- you might
// want to limit the height the text input can grow to. Possible solution
// is to add another prop that determines whether we should scroll to end
// of text.
if (mContentSizeWatcher != null) {
return isMultiline();
} else {
return false;
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
if (mContentSizeWatcher != null) {
mContentSizeWatcher.onLayout();
}
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mDetectScrollMovement = true;
// Disallow parent views to intercept touch events, until we can detect if we should be
// capturing these touches or not.
this.getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if (mDetectScrollMovement) {
if (!canScrollVertically(-1) &&
!canScrollVertically(1) &&
!canScrollHorizontally(-1) &&
!canScrollHorizontally(1)) {
// We cannot scroll, let parent views take care of these touches.
this.getParent().requestDisallowInterceptTouchEvent(false);
}
mDetectScrollMovement = false;
}
break;
}
return super.onTouchEvent(ev);
}
// 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 && !isMultiline()) {
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());
}
}
}
public void setContentSizeWatcher(ContentSizeWatcher contentSizeWatcher) {
mContentSizeWatcher = contentSizeWatcher;
}
@Override
public void setSelection(int start, int end) {
// Skip setting the selection if the text wasn't set because of an out of date value.
if (mMostRecentEventCount < mNativeEventCount) {
return;
}
super.setSelection(start, end);
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (mSelectionWatcher != null && hasFocus()) {
mSelectionWatcher.onSelectionChanged(selStart, selEnd);
}
}
@Override
protected void onFocusChanged(
boolean focused, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(focused, direction, previouslyFocusedRect);
if (focused && mSelectionWatcher != null) {
mSelectionWatcher.onSelectionChanged(getSelectionStart(), getSelectionEnd());
}
}
public void setSelectionWatcher(SelectionWatcher selectionWatcher) {
mSelectionWatcher = selectionWatcher;
}
public void setBlurOnSubmit(boolean blurOnSubmit) {
mBlurOnSubmit = blurOnSubmit;
}
public boolean getBlurOnSubmit() {
return mBlurOnSubmit;
}
public void setDisableFullscreenUI(boolean disableFullscreenUI) {
mDisableFullscreen = disableFullscreenUI;
updateImeOptions();
}
public boolean getDisableFullscreenUI() {
return mDisableFullscreen;
}
public void setReturnKeyType(String returnKeyType) {
mReturnKeyType = returnKeyType;
updateImeOptions();
}
public String getReturnKeyType() {
return mReturnKeyType;
}
/*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) {
Typeface tf = super.getTypeface();
super.setInputType(type);
mStagedInputType = type;
// Input type password defaults to monospace font, so we need to re-apply the font
super.setTypeface(tf);
// We override the KeyListener so that all keys on the soft input keyboard as well as hardware
// keyboards work. Some KeyListeners like DigitsKeyListener will display the keyboard but not
// accept all input from it
mKeyListener.setInputType(type);
setKeyListener(mKeyListener);
}
// VisibleForTesting from {@link TextInputEventsTestCase}.
public 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.
mMostRecentEventCount = reactTextUpdate.getJsEventCounter();
if (mMostRecentEventCount < 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);
mContainsImages = reactTextUpdate.containsImages();
mIsSettingTextFromJS = true;
getText().replace(0, length(), spannableStringBuilder);
mIsSettingTextFromJS = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (getBreakStrategy() != reactTextUpdate.getTextBreakStrategy()) {
setBreakStrategy(reactTextUpdate.getTextBreakStrategy());
}
}
}
/**
* 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++) {
// Remove all styling spans we might have previously set
if (ForegroundColorSpan.class.isInstance(spans[spanIdx]) ||
BackgroundColorSpan.class.isInstance(spans[spanIdx]) ||
AbsoluteSizeSpan.class.isInstance(spans[spanIdx]) ||
CustomStyleSpan.class.isInstance(spans[spanIdx]) ||
ReactTagSpan.class.isInstance(spans[spanIdx])) {
getText().removeSpan(spans[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;
}
private boolean isMultiline() {
return (getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0;
}
/* 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);
}
private void updateImeOptions() {
// Default to IME_ACTION_DONE
int returnKeyFlag = EditorInfo.IME_ACTION_DONE;
if (mReturnKeyType != null) {
switch (mReturnKeyType) {
case "go":
returnKeyFlag = EditorInfo.IME_ACTION_GO;
break;
case "next":
returnKeyFlag = EditorInfo.IME_ACTION_NEXT;
break;
case "none":
returnKeyFlag = EditorInfo.IME_ACTION_NONE;
break;
case "previous":
returnKeyFlag = EditorInfo.IME_ACTION_PREVIOUS;
break;
case "search":
returnKeyFlag = EditorInfo.IME_ACTION_SEARCH;
break;
case "send":
returnKeyFlag = EditorInfo.IME_ACTION_SEND;
break;
case "done":
returnKeyFlag = EditorInfo.IME_ACTION_DONE;
break;
}
}
if (mDisableFullscreen) {
setImeOptions(returnKeyFlag | EditorInfo.IME_FLAG_NO_FULLSCREEN);
} else {
setImeOptions(returnKeyFlag);
}
}
@Override
protected boolean verifyDrawable(Drawable drawable) {
if (mContainsImages && getText() instanceof Spanned) {
Spanned text = (Spanned) getText();
TextInlineImageSpan[] spans = text.getSpans(0, text.length(), TextInlineImageSpan.class);
for (TextInlineImageSpan span : spans) {
if (span.getDrawable() == drawable) {
return true;
}
}
}
return super.verifyDrawable(drawable);
}
@Override
public void invalidateDrawable(Drawable drawable) {
if (mContainsImages && getText() instanceof Spanned) {
Spanned text = (Spanned) getText();
TextInlineImageSpan[] spans = text.getSpans(0, text.length(), TextInlineImageSpan.class);
for (TextInlineImageSpan span : spans) {
if (span.getDrawable() == drawable) {
invalidate();
}
}
}
super.invalidateDrawable(drawable);
}
@Override
public void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mContainsImages && getText() instanceof Spanned) {
Spanned text = (Spanned) getText();
TextInlineImageSpan[] spans = text.getSpans(0, text.length(), TextInlineImageSpan.class);
for (TextInlineImageSpan span : spans) {
span.onDetachedFromWindow();
}
}
}
@Override
public void onStartTemporaryDetach() {
super.onStartTemporaryDetach();
if (mContainsImages && getText() instanceof Spanned) {
Spanned text = (Spanned) getText();
TextInlineImageSpan[] spans = text.getSpans(0, text.length(), TextInlineImageSpan.class);
for (TextInlineImageSpan span : spans) {
span.onStartTemporaryDetach();
}
}
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
if (mContainsImages && getText() instanceof Spanned) {
Spanned text = (Spanned) getText();
TextInlineImageSpan[] spans = text.getSpans(0, text.length(), TextInlineImageSpan.class);
for (TextInlineImageSpan span : spans) {
span.onAttachedToWindow();
}
}
}
@Override
public void onFinishTemporaryDetach() {
super.onFinishTemporaryDetach();
if (mContainsImages && getText() instanceof Spanned) {
Spanned text = (Spanned) getText();
TextInlineImageSpan[] spans = text.getSpans(0, text.length(), TextInlineImageSpan.class);
for (TextInlineImageSpan span : spans) {
span.onFinishTemporaryDetach();
}
}
}
@Override
public void setBackgroundColor(int color) {
if (color == Color.TRANSPARENT && mReactBackgroundDrawable == null) {
// don't do anything, no need to allocate ReactBackgroundDrawable for transparent background
} else {
getOrCreateReactViewBackground().setColor(color);
}
}
public void setBorderWidth(int position, float width) {
getOrCreateReactViewBackground().setBorderWidth(position, width);
}
public void setBorderColor(int position, float color, float alpha) {
getOrCreateReactViewBackground().setBorderColor(position, color, alpha);
}
public void setBorderRadius(float borderRadius) {
getOrCreateReactViewBackground().setRadius(borderRadius);
}
public void setBorderRadius(float borderRadius, int position) {
getOrCreateReactViewBackground().setRadius(borderRadius, position);
}
public void setBorderStyle(@Nullable String style) {
getOrCreateReactViewBackground().setBorderStyle(style);
}
private ReactViewBackgroundDrawable getOrCreateReactViewBackground() {
if (mReactBackgroundDrawable == null) {
mReactBackgroundDrawable = new ReactViewBackgroundDrawable();
Drawable backgroundDrawable = getBackground();
super.setBackground(null); // required so that drawable callback is cleared before we add the
// drawable back as a part of LayerDrawable
if (backgroundDrawable == null) {
super.setBackground(mReactBackgroundDrawable);
} else {
LayerDrawable layerDrawable =
new LayerDrawable(new Drawable[]{mReactBackgroundDrawable, backgroundDrawable});
super.setBackground(layerDrawable);
}
}
return mReactBackgroundDrawable;
}
/**
* 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 (TextWatcher listener : mListeners) {
listener.afterTextChanged(s);
}
}
}
}
/*
* This class is set as the KeyListener for the underlying TextView
* It does two things
* 1) Provides the same answer to getInputType() as the real KeyListener would have which allows
* the proper keyboard to pop up on screen
* 2) Permits all keyboard input through
*/
private static class InternalKeyListener implements KeyListener {
private int mInputType = 0;
public InternalKeyListener() {
}
public void setInputType(int inputType) {
mInputType = inputType;
}
/*
* getInputType will return whatever value is passed in. This will allow the proper keyboard
* to be shown on screen but without the actual filtering done by other KeyListeners
*/
@Override
public int getInputType() {
return mInputType;
}
/*
* All overrides of key handling defer to the underlying KeyListener which is shared by all
* ReactEditText instances. It will basically allow any/all keyboard input whether from
* physical keyboard or from soft input.
*/
@Override
public boolean onKeyDown(View view, Editable text, int keyCode, KeyEvent event) {
return sKeyListener.onKeyDown(view, text, keyCode, event);
}
@Override
public boolean onKeyUp(View view, Editable text, int keyCode, KeyEvent event) {
return sKeyListener.onKeyUp(view, text, keyCode, event);
}
@Override
public boolean onKeyOther(View view, Editable text, KeyEvent event) {
return sKeyListener.onKeyOther(view, text, event);
}
@Override
public void clearMetaKeyState(View view, Editable content, int states) {
sKeyListener.clearMetaKeyState(view, content, states);
}
}
}