Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement onKeyPress Android #14720

1 change: 0 additions & 1 deletion Libraries/Components/TextInput/TextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,6 @@ const TextInput = React.createClass({
* 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,
/**
Expand Down
8 changes: 7 additions & 1 deletion RNTester/js/TextInputExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class TextEventsExample extends React.Component {
curText: '<No Event>',
prevText: '<No Event>',
prev2Text: '<No Event>',
prev3Text: '<No Event>',
};

updateText = (text) => {
Expand All @@ -33,6 +34,7 @@ class TextEventsExample extends React.Component {
curText: text,
prevText: state.curText,
prev2Text: state.prevText,
prev3Text: state.prev2Text,
};
});
};
Expand All @@ -58,12 +60,16 @@ class TextEventsExample extends React.Component {
onSubmitEditing={(event) => this.updateText(
'onSubmitEditing text: ' + event.nativeEvent.text
)}
onKeyPress={(event) => this.updateText(
'onKeyPress key: ' + event.nativeEvent.key
)}
style={styles.singleLine}
/>
<Text style={styles.eventLabel}>
{this.state.curText}{'\n'}
(prev: {this.state.prevText}){'\n'}
(prev2: {this.state.prev2Text})
(prev2: {this.state.prev2Text}){'\n'}
(prev3: {this.state.prev3Text})
</Text>
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;

Expand Down Expand Up @@ -81,14 +83,15 @@ public class ReactEditText extends EditText {
private @Nullable SelectionWatcher mSelectionWatcher;
private @Nullable ContentSizeWatcher mContentSizeWatcher;
private @Nullable ScrollWatcher mScrollWatcher;
private final InputConnectionWrapper mInputConnectionWrapper;
private final InternalKeyListener mKeyListener;
private boolean mDetectScrollMovement = false;

private ReactViewBackgroundDrawable mReactBackgroundDrawable;

private static final KeyListener sKeyListener = QwertyKeyListener.getInstanceForFullKeyboard();

public ReactEditText(Context context) {
public ReactEditText(Context context, InputConnectionWrapper inputConnection) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am totally n00b in Android, but maybe we should call it inputConnectionWrapper instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'totally n00b in Android': that makes two of us then ;). Thank-you yes, good point!

super(context);
setFocusableInTouchMode(false);

Expand All @@ -108,6 +111,13 @@ public ReactEditText(Context context) {
mStagedInputType = getInputType();
mKeyListener = new InternalKeyListener();
mScrollWatcher = null;
mInputConnectionWrapper = inputConnection;
}

@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
mInputConnectionWrapper.setTarget(super.onCreateInputConnection(outAttrs));
return mInputConnectionWrapper;
}

// After the text changes inside an EditText, TextView checks if a layout() has been requested.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* 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.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import android.widget.EditText;

import com.facebook.react.bridge.ReactContext;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.uimanager.events.EventDispatcher;

/**
* This class wraps the {@link InputConnection} as returned by
* {@link EditText#onCreateInputConnection(EditorInfo)} of the underlying {@link ReactEditText}
* of {@link ReactTextInputManager}
* The job of this class is to determine the key pressed by the soft keyboard.
*
* Firstly, we can make some deductions about a soft keyPress based on changes to the position of the
* input cursor before and after the edit/change. We know that if there was no text selection before
* the edit, and the cursor moves backwards, then it must be a delete; equally if it moves forwards
* by a character, then we deduce the key input is the character preceding the new cursor position.
* We also know if there was no text selection before the edit and the cursor was at the beginning of the input before,
* and still is after,then it must also be a delete, i.e. an 'empty delete' where no text actually is deleted.
* N.B. we are making the assumption that {@link InputConnection#endBatchEdit()} will fire in this case.
*
* In cases where there was a text selection before the edit, if the start of the selection is the same
* after the edit as it was before, then we know it is a delete, if it is not the same, i.e.
* it has moved forward a character, then we take that character to be the key input, i.e. a user
* has selected some text and pressed a character to replace the selected text.
*
* With {@link EditText}s, text can be in two different states in the input itself, 'committed' &
* currently 'composing'. N.B there is no composing text state when auto-correct is disabled, text
* will be committed straight away character by character.
* When a user is composing a word we get a callback to {@link InputConnection#setComposingText(CharSequence, int)}
* with the entire word being composed. For example, composing 'hello' would result callbacks with
* 'h', 'he', 'hel' 'hell', 'hello'. Our above logic for deriving the keyPress based on cursor position
* handles this case. However we need additional logic surrounding the case whereby text can be committed.
*
* It is up to the IME to decide when text changes state from 'composing' to 'committed',
* however the stock Android keyboard, for example, changes text being composed to be committed
* when a user selects an auto-correction from the bar above the keyboard or presses 'space' or 'enter
* to complete the word or text. In this case, our above logic with cursor positions does not apply,
* as our cursor could be anywhere within the word being composed when a correction is selected,
* and clearly selecting a single character from this correction would be the wrong thing to do.
* It's fairly arbitrary, but we can set our keyPress to be the correction itself as this is what
* the iOS implementation of 'onKeyPress' does.
* In the case where a user commits with a space or enter, the stock IME first commits the composing text,
* and then commits the user input of space or return afterwards, as a secondary commit within the batch edit. We of course
* want the keyPress entered by the user, so we take the second of these two commits as the keyPress.
*
* A final case is the case whereby a user has committed some text, and their cursor comes straight
* after the word they have just committed with no trailing space, as is the default behavior. If a user
* is to input a character as to start a new word, the stock IME will first commit a space to the
* input, and then set the composing text to be the character the user entered. In this case we
* choose our onKeyPress to be the new composing character.
*/
class ReactTextInputInputConnection extends InputConnectionWrapper {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

space character before class ...

public static final String NewLineRawValue = "\n";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public static final fields should be all caps with underscores: NEW_LINE_RAW_VALUE etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

public static final String BackspaceKeyValue = "Backspace";
public static final String EnterKeyValue = "Enter";

private @Nullable ReactEditText mEditText;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use @Nullable annotation you should make sure to do null checks before calling methods on that object or use assertNoNull. Although as per my other comment about removing setEditText I think if you follow that comment you won't need @Nullable here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, with your suggested changes, @Nullable no longer necessary!

private EventDispatcher mEventDispatcher;
private int mPreviousSelectionStart;
private int mPreviousSelectionEnd;
private String mCommittedText;
private String mComposedText;

public ReactTextInputInputConnection(
InputConnection target,
boolean mutable,
final ReactContext reactContext) {
super(target, mutable);
mEventDispatcher = reactContext.getNativeModule(UIManagerModule.class).getEventDispatcher();
}

public void setEditText(final ReactEditText editText) {
mEditText = editText;
}

@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
mComposedText = text.toString();
return super.setComposingText(text, newCursorPosition);
}

@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
mCommittedText = text.toString();
return super.commitText(text, newCursorPosition);
}

@Override
public boolean beginBatchEdit() {
mPreviousSelectionStart = mEditText.getSelectionStart();
mPreviousSelectionEnd = mEditText.getSelectionEnd();
mCommittedText = null;
mComposedText = null;
return super.beginBatchEdit();
}

@Override
public boolean endBatchEdit() {
String key;
int selectionStart = mEditText.getSelectionStart();
if (mCommittedText == null) {
if ((noPreviousSelection() && selectionStart < mPreviousSelectionStart)
|| (!noPreviousSelection() && selectionStart == mPreviousSelectionStart)
|| (mPreviousSelectionStart == 0 && selectionStart == 0)) {
key = BackspaceKeyValue;
} else {
char enteredChar = mEditText.getText().charAt(selectionStart - 1);
key = String.valueOf(enteredChar);
}
}
else {
key = mComposedText != null ? mComposedText : mCommittedText;
}
key = keyValueFromString(key);
mEventDispatcher.dispatchEvent(
new ReactTextInputKeyPressEvent(
mEditText.getId(),
key));
return super.endBatchEdit();
}

private boolean noPreviousSelection() {
return mPreviousSelectionStart == mPreviousSelectionEnd;
}

private String keyValueFromString(final String key) {
String returnValue;
switch (key) {
case "": returnValue = BackspaceKeyValue;
break;
case NewLineRawValue: returnValue = EnterKeyValue;
break;
default:
returnValue = key;
break;
}
return returnValue;
}
}
Original file line number Diff line number Diff line change
@@ -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<ReactTextInputEvent> {

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ public String getName() {

@Override
public ReactEditText createViewInstance(ThemedReactContext context) {
ReactEditText editText = new ReactEditText(context);
ReactTextInputInputConnection inputConnection = new ReactTextInputInputConnection(null, true, context);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic of instantiating InputConnection seems overly complicated. If you don't need to keep a reference to inputConnection outside of ReactEditText class why not instantiate it directly in ReactEditText#onCreateInputConnection ? This will make setEditText and setTarget methods no longer needed and result in a cleaner contract for ReactTextInputInputConnection.

a) setEditText will not be necessary if you are instantiating it from onCreateInputConnection because you can have the reference to ReactEditText there, just use this.
b) setTarget can be replaced by just passing InputConnection we want to wrap directly via constructor of ReactTextInputInputConnection

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, very good point!

ReactEditText editText = new ReactEditText(context, inputConnection);
inputConnection.setEditText(editText);
int inputType = editText.getInputType();
editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
editText.setReturnKeyType("done");
Expand Down Expand Up @@ -141,6 +143,11 @@ public Map<String, Object> getExportedCustomBubblingEventTypeConstants() {
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onBlur", "captured", "onBlurCapture")))
.put(
"topKeyPress",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onKeyPress", "captured", "onKeyPressCapture")))
.build();
}

Expand Down