Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Implement onKeyPress Android
Summary:
This implements onKeyPress for Android on TextInputs and addresses #1882.
**N.B. that this PR has not yet addressed hardware keyboard inputs**, but doing will be fairly trivial. The main challenge was doing this for soft keyboard inputs.

I've tried to match the style as much as I could. Will happily make any suggested edits be they architectural or stylistic design (edit: and of course implementation), but hopefully this is a good first pass :).
I think important to test this on the most popular keyboard types; maybe different languages too.
I have not yet added tests to test implementation, but will be happy to do that also.

- Build & run RNTester project for Android and open TextInput.
- Enter keys into 'event handling' TextInput.
- Verify that keys you enter appear in onKeyPress below the text input
- Test with autocorrect off, on same input and validate that results are the same.

Below is a gif of PR in action.
![onkeypressandroid](https://user-images.githubusercontent.com/1807207/27512892-3f95c098-5949-11e7-9364-3ce9437f7bb9.gif)
Closes #14720

Differential Revision: D6661592

Pulled By: hramos

fbshipit-source-id: 5d53772dc2d127b002ea5fb84fa992934eb65a42
  • Loading branch information
joshjhargreaves authored and facebook-github-bot committed Jan 4, 2018
1 parent ddd65f1 commit c9ff0bc
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 4 deletions.
1 change: 0 additions & 1 deletion Libraries/Components/TextInput/TextInput.js
Expand Up @@ -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,
/**
Expand Down
9 changes: 8 additions & 1 deletion RNTester/js/TextInputExample.android.js
Expand Up @@ -27,6 +27,7 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> {
curText: '<No Event>',
prevText: '<No Event>',
prev2Text: '<No Event>',
prev3Text: '<No Event>',
};

updateText = (text) => {
Expand All @@ -35,6 +36,7 @@ class TextEventsExample extends React.Component<{}, $FlowFixMeState> {
curText: text,
prevText: state.curText,
prev2Text: state.prevText,
prev3Text: state.prev2Text,
};
});
};
Expand All @@ -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(
Expand All @@ -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}
/>
<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
Expand Up @@ -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
Expand Down
@@ -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));
}
}
@@ -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;
}
}
Expand Up @@ -138,6 +138,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

0 comments on commit c9ff0bc

Please sign in to comment.