diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index 67f3e8d19a6552..ddd509ef0fd0dd 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -422,7 +422,6 @@ const TextInput = createReactClass({ * where `keyValue` is `'Enter'` or `'Backspace'` for respective keys and * the typed-in character otherwise including `' '` for space. * Fires before `onChange` callbacks. - * @platform ios */ onKeyPress: PropTypes.func, /** diff --git a/RNTester/js/TextInputExample.android.js b/RNTester/js/TextInputExample.android.js index 04d438684711da..e70d7d1ba50854 100644 --- a/RNTester/js/TextInputExample.android.js +++ b/RNTester/js/TextInputExample.android.js @@ -27,6 +27,7 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> { curText: '', prevText: '', prev2Text: '', + prev3Text: '', }; updateText = (text) => { @@ -35,6 +36,7 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> { curText: text, prevText: state.curText, prev2Text: state.prevText, + prev3Text: state.prev2Text, }; }); }; @@ -46,6 +48,7 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> { autoCapitalize="none" placeholder="Enter text to see events" autoCorrect={false} + multiline onFocus={() => this.updateText('onFocus')} onBlur={() => this.updateText('onBlur')} onChange={(event) => this.updateText( @@ -60,12 +63,16 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> { onSubmitEditing={(event) => this.updateText( 'onSubmitEditing text: ' + event.nativeEvent.text )} + onKeyPress={(event) => this.updateText( + 'onKeyPress key: ' + event.nativeEvent.key + )} style={styles.singleLine} /> {this.state.curText}{'\n'} (prev: {this.state.prevText}){'\n'} - (prev2: {this.state.prev2Text}) + (prev2: {this.state.prev2Text}){'\n'} + (prev3: {this.state.prev3Text}) ); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 83b559bdd0d8c8..f2e8f968e8af50 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -173,12 +173,15 @@ protected void onScrollChanged(int horiz, int vert, int oldHoriz, int oldVert) { @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { - InputConnection connection = super.onCreateInputConnection(outAttrs); + ReactContext reactContext = (ReactContext) getContext(); + ReactEditTextInputConnectionWrapper inputConnectionWrapper = + new ReactEditTextInputConnectionWrapper(super.onCreateInputConnection(outAttrs), reactContext, this); + if (isMultiline() && getBlurOnSubmit()) { // Remove IME_FLAG_NO_ENTER_ACTION to keep the original IME_OPTION outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; } - return connection; + return inputConnectionWrapper; } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.java new file mode 100644 index 00000000000000..5a06db595e2027 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditTextInputConnectionWrapper.java @@ -0,0 +1,163 @@ +/** + * 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 android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.events.EventDispatcher; + +/** + * A class to implement the TextInput 'onKeyPress' API on android for soft keyboards. + * It is instantiated in {@link ReactEditText#onCreateInputConnection(EditorInfo)}. + * + * Android IMEs interface with EditText views through the {@link InputConnection} interface, + * so any observable change in state of the EditText via the soft-keyboard, should be a side effect of + * one or more of the methods in {@link InputConnectionWrapper}. + * + * {@link InputConnection#setComposingText(CharSequence, int)} is used to set the composing region + * (the underlined text) in the {@link android.widget.EditText} view, i.e. when React Native's + * TextInput has the property 'autoCorrect' set to true. When text is being composed in the composing + * state within the EditText, each key press will result in a call to + * {@link InputConnection#setComposingText(CharSequence, int)} with a CharSequence argument equal to + * that of the entire composing region, rather than a single character diff. + * We can reason about the keyPress based on the resultant cursor position changes of the EditText after + * applying this change. For example if the cursor moved backwards by one character when composing, + * it's likely it was a delete; if it moves forward by a character, likely to be a key press of that character. + * + * IMEs can also call {@link InputConnection#beginBatchEdit()} to signify a batch of operations. One + * such example is committing a word currently in composing state with the press of the space key. + * It is IME dependent but the stock Android keyboard behavior seems to be to commit the currently composing + * text with {@link InputConnection#setComposingText(CharSequence, int)} and commits a space character + * with a separate call to {@link InputConnection#setComposingText(CharSequence, int)}. + * Here we chose to emit the last input of a batch edit as that tends to be the user input, but + * it's completely arbitrary. + * + * Another function of this class is to detect backspaces when the cursor at the beginning of the + * {@link android.widget.EditText}, i.e no text is deleted. + * + * N.B. this class is only applicable for soft keyboards behavior. For hardware keyboards + * {@link android.view.View#onKeyDown(int, KeyEvent)} can be overridden to obtain the keycode of the + * key pressed. + */ +class ReactEditTextInputConnectionWrapper extends InputConnectionWrapper { + public static final String NEWLINE_RAW_VALUE = "\n"; + public static final String BACKSPACE_KEY_VALUE = "Backspace"; + public static final String ENTER_KEY_VALUE = "Enter"; + + private ReactEditText mEditText; + private EventDispatcher mEventDispatcher; + private boolean mIsBatchEdit; + private @Nullable String mKey = null; + + public ReactEditTextInputConnectionWrapper( + InputConnection target, + final ReactContext reactContext, + final ReactEditText editText + ) { + super(target, false); + mEventDispatcher = reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher(); + mEditText = editText; + } + + @Override + public boolean beginBatchEdit() { + mIsBatchEdit = true; + return super.beginBatchEdit(); + } + + @Override + public boolean endBatchEdit() { + mIsBatchEdit = false; + if (mKey != null) { + dispatchKeyEvent(mKey); + mKey = null; + } + return super.endBatchEdit(); + } + + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + int previousSelectionStart = mEditText.getSelectionStart(); + int previousSelectionEnd = mEditText.getSelectionEnd(); + String key; + boolean consumed = super.setComposingText(text, newCursorPosition); + boolean noPreviousSelection = previousSelectionStart == previousSelectionEnd; + boolean cursorDidNotMove = mEditText.getSelectionStart() == previousSelectionStart; + boolean cursorMovedBackwards = mEditText.getSelectionStart() < previousSelectionStart; + if ((noPreviousSelection && cursorMovedBackwards) + || !noPreviousSelection && cursorDidNotMove) { + key = BACKSPACE_KEY_VALUE; + } else { + key = String.valueOf(mEditText.getText().charAt(mEditText.getSelectionStart() - 1)); + } + dispatchKeyEventOrEnqueue(key); + return consumed; + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + String key = text.toString(); + // Assume not a keyPress if length > 1 + if (key.length() <= 1) { + if (key.equals("")) { + key = BACKSPACE_KEY_VALUE; + } + dispatchKeyEventOrEnqueue(key); + } + + return super.commitText(text, newCursorPosition); + } + + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + dispatchKeyEvent(BACKSPACE_KEY_VALUE); + return super.deleteSurroundingText(beforeLength, afterLength); + } + + // Called by SwiftKey when cursor at beginning of input when there is a delete + // or when enter is pressed anywhere in the text. Whereas stock Android Keyboard calls + // {@link InputConnection#deleteSurroundingText} & {@link InputConnection#commitText} + // in each case, respectively. + @Override + public boolean sendKeyEvent(KeyEvent event) { + if(event.getAction() == KeyEvent.ACTION_DOWN) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { + dispatchKeyEvent(BACKSPACE_KEY_VALUE); + } else if(event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { + dispatchKeyEvent(ENTER_KEY_VALUE); + } + } + return super.sendKeyEvent(event); + } + + private void dispatchKeyEventOrEnqueue(String key) { + if (mIsBatchEdit) { + mKey = key; + } else { + dispatchKeyEvent(key); + } + } + + private void dispatchKeyEvent(String key) { + if (key.equals(NEWLINE_RAW_VALUE)) { + key = ENTER_KEY_VALUE; + } + mEventDispatcher.dispatchEvent( + new ReactTextInputKeyPressEvent( + mEditText.getId(), + key)); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java new file mode 100644 index 00000000000000..7771a1d0e1d83c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java @@ -0,0 +1,53 @@ +/** + * 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 com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +/** + * Event emitted by EditText native view when key pressed + */ +public class ReactTextInputKeyPressEvent extends Event { + + public static final String EVENT_NAME = "topKeyPress"; + + private String mKey; + + ReactTextInputKeyPressEvent(int viewId, final String key) { + super(viewId); + mKey = key; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public boolean canCoalesce() { + // We don't want to miss any textinput event, as event data is incremental. + return false; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), serializeEventData()); + } + + private WritableMap serializeEventData() { + WritableMap eventData = Arguments.createMap(); + eventData.putString("key", mKey); + + return eventData; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index b4908385af81eb..4879fbcc6614d2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -138,6 +138,11 @@ public Map getExportedCustomBubblingEventTypeConstants() { MapBuilder.of( "phasedRegistrationNames", MapBuilder.of("bubbled", "onBlur", "captured", "onBlurCapture"))) + .put( + "topKeyPress", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", "onKeyPress", "captured", "onKeyPressCapture"))) .build(); }