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

[RNMobile] Avoid keyboard dismiss when interacting text blocks #57070

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -823,12 +823,6 @@ export class RichText extends Component {
}
}

componentWillUnmount() {
if ( this._editor.isFocused() ) {
this._editor.blur();
}
}

componentDidUpdate( prevProps ) {
const { style, tagName } = this.props;
const { currentFontSize } = this.state;
Expand Down
16 changes: 15 additions & 1 deletion packages/components/src/mobile/bottom-sheet/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import SafeArea from 'react-native-safe-area';
/**
* WordPress dependencies
*/
import { subscribeAndroidModalClosed } from '@wordpress/react-native-bridge';
import {
subscribeAndroidModalClosed,
showAndroidSoftKeyboard,
} from '@wordpress/react-native-bridge';
import { Component } from '@wordpress/element';
import { withPreferredColorScheme } from '@wordpress/compose';

Expand Down Expand Up @@ -215,6 +218,11 @@ class BottomSheet extends Component {
if ( this.androidModalClosedSubscription ) {
this.androidModalClosedSubscription.remove();
}

if ( this.props.isVisible ) {
showAndroidSoftKeyboard();
}

if ( this.safeAreaEventSubscription === null ) {
return;
}
Expand Down Expand Up @@ -315,6 +323,9 @@ class BottomSheet extends Component {
onDismiss() {
const { onDismiss } = this.props;

// Restore Keyboard Visibility
showAndroidSoftKeyboard();

if ( onDismiss ) {
onDismiss();
}
Expand Down Expand Up @@ -368,6 +379,7 @@ class BottomSheet extends Component {
onHardwareButtonPress() {
const { onClose } = this.props;
const { handleHardwareButtonPress } = this.state;

if ( handleHardwareButtonPress && handleHardwareButtonPress() ) {
return;
}
Expand Down Expand Up @@ -528,6 +540,8 @@ class BottomSheet extends Component {
}
onAccessibilityEscape={ this.onCloseBottomSheet }
testID="bottom-sheet"
hardwareAccelerated={ true }
useNativeDriverForBackdrop={ true }
{ ...rest }
>
<KeyboardAvoidingView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ exports[`LinksUI LinksUI renders 1`] = `
backdropOpacity={0.2}
backdropTransitionInTiming={50}
backdropTransitionOutTiming={50}
hardwareAccelerated={true}
isVisible={true}
onAccessibilityEscape={[Function]}
onBackButtonPress={[Function]}
Expand All @@ -18,6 +19,7 @@ exports[`LinksUI LinksUI renders 1`] = `
preferredColorScheme="light"
swipeDirection="down"
testID="link-settings-modal"
useNativeDriverForBackdrop={true}
>
<View
behavior={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ public class RCTAztecViewManager: RCTViewManager {
return view
}

/// This method is similar to `executeBlock` but prepends the block to execute it before other pending blocks.
func executeBlockBeforeOthers(viewTag: NSNumber, block: @escaping (RCTAztecView) -> Void) {
self.bridge.uiManager.prependUIBlock { (uiManager, viewRegistry) in
let view = viewRegistry?[viewTag]
guard let aztecView = view as? RCTAztecView else {
return
}
block(aztecView)
}
}

func executeBlock(viewTag: NSNumber, block: @escaping (RCTAztecView) -> Void) {
self.bridge.uiManager.addUIBlock { (uiManager, viewRegistry) in
let view = viewRegistry?[viewTag]
Expand Down Expand Up @@ -69,7 +80,7 @@ public class RCTAztecViewManager: RCTViewManager {

@objc
func focus(_ viewTag: NSNumber) -> Void {
self.executeBlock(viewTag: viewTag) { (aztecView) in
self.executeBlockBeforeOthers(viewTag: viewTag) { (aztecView) in
aztecView.reactFocus()
}
}
Expand Down
45 changes: 44 additions & 1 deletion packages/react-native-aztec/src/AztecInputState.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
/**
* External dependencies
*/
import { Platform } from 'react-native';
import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState';

/**
* WordPress dependencies
*/
import { debounce } from '@wordpress/compose';
import { hideAndroidSoftKeyboard } from '@wordpress/react-native-bridge';

/** @typedef {import('@wordpress/element').RefObject} RefObject */

const focusChangeListeners = [];
Expand Down Expand Up @@ -131,21 +138,57 @@ export const focusInput = ( element ) => {
* @param {RefObject} element Element to be focused.
*/
export const focus = ( element ) => {
// If other blur events happen at the same time that focus is triggered, the focus event
// will take precedence and cancels pending blur events.
blur.cancel();
// Similar to blur events, we also need to cancel potential keyboard dismiss.
dismissKeyboardDebounce.cancel();

TextInputState.focusTextInput( element );
notifyInputChange();
};

/**
* Unfocuses the specified element.
* This function uses debounce to avoid conflicts with the focus event when both are
* triggered at the same time. Focus events will take precedence.
*
* @param {RefObject} element Element to be unfocused.
*/
export const blur = ( element ) => {
export const blur = debounce( ( element ) => {
TextInputState.blurTextInput( element );
setCurrentCaretData( null );
notifyInputChange();
}, 0 );

/**
* Unfocuses the specified element in case it's about to be unmounted.
*
* On iOS text inputs are automatically unfocused and keyboard dismissed when they
* are removed. However, this is not the case on Android, where text inputs are
* unfocused but the keyboard remains open.
*
* For dismissing the keyboard, we use debounce to avoid conflicts with the focus
* event when both are triggered at the same time.
*
* Note that we can't trigger the blur event, as it's likely that the Aztec view is no
* longer available when the event is executed and will produce an exception.
*
* @param {RefObject} element Element to be unfocused.
*/
export const blurOnUnmount = ( element ) => {
if ( getCurrentFocusedElement() === element ) {
// If a blur event was triggered before unmount, we need to cancel them to avoid
// exceptions.
blur.cancel();
if ( Platform.OS === 'android' ) {
dismissKeyboardDebounce();
}
}
};

const dismissKeyboardDebounce = debounce( () => hideAndroidSoftKeyboard(), 0 );

/**
* Unfocuses the current focused element.
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native-aztec/src/AztecView.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class AztecView extends Component {
this.focus = this.focus.bind( this );
}

componentWillUnmount() {
AztecInputState.blurOnUnmount( this.aztecViewRef.current );
}

dispatch( command, params ) {
params = params || [];
UIManager.dispatchViewManagerCommand(
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native-aztec/src/test/AztecInputState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const updateCurrentFocusedInput = ( value ) => {
notifyInputChange();
};

jest.useFakeTimers();

describe( 'Aztec Input State', () => {
it( 'listens to focus change event', () => {
const listener = jest.fn();
Expand Down Expand Up @@ -96,6 +98,7 @@ describe( 'Aztec Input State', () => {

it( 'unfocuses an element', () => {
blur( ref );
jest.runAllTimers();
expect( TextInputState.blurTextInput ).toHaveBeenCalledWith( ref );
} );
} );
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package org.wordpress.mobile.ReactNativeGutenbergBridge;

import android.app.Activity;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.provider.Settings;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.inputmethod.InputMethodManager;

import androidx.annotation.Nullable;

Expand Down Expand Up @@ -41,6 +45,7 @@ public class RNReactNativeGutenbergBridgeModule extends ReactContextBaseJavaModu
DeferredEventEmitter.JSEventEmitter {
private final ReactApplicationContext mReactContext;
private final GutenbergBridgeJS2Parent mGutenbergBridgeJS2Parent;
private Runnable mKeyboardRunnable;

private static final String EVENT_NAME_REQUEST_GET_HTML = "requestGetHtml";
private static final String EVENT_NAME_UPDATE_HTML = "updateHtml";
Expand Down Expand Up @@ -550,4 +555,72 @@ private ConnectionStatusCallback requestConnectionStatusCallback(final Callback
}
};
}

@ReactMethod
public void showAndroidSoftKeyboard() {
Activity currentActivity = mReactContext.getCurrentActivity();
if (isAnyViewFocused()) {
// Cancel any previously scheduled Runnable
if (mKeyboardRunnable != null) {
currentActivity.getWindow().getDecorView().removeCallbacks(mKeyboardRunnable);
}

View currentFocusedView = getCurrentFocusedView();
currentFocusedView.getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
if (hasFocus) {
mKeyboardRunnable = createShowKeyboardRunnable();
currentActivity.getWindow().getDecorView().post(mKeyboardRunnable);
currentFocusedView.getViewTreeObserver().removeOnWindowFocusChangeListener(this);
}
}
});
}
}

private Runnable createShowKeyboardRunnable() {
return new Runnable() {
@Override
public void run() {
try {
Activity activity = mReactContext.getCurrentActivity();
View activeFocusedView = getCurrentFocusedView();
if (activeFocusedView != null && activity.getWindow().getDecorView().isShown()) {
InputMethodManager imm =
(InputMethodManager) mReactContext.getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(activeFocusedView, InputMethodManager.SHOW_IMPLICIT);
}
} catch (Exception e) {
// Noop
}
}
};
}

private View getCurrentFocusedView() {
Activity activity = mReactContext.getCurrentActivity();
if (activity == null) {
return null;
}
return activity.getCurrentFocus();
}

private boolean isAnyViewFocused() {
View getCurrentFocusedView = getCurrentFocusedView();
return getCurrentFocusedView != null;
}

@ReactMethod
public void hideAndroidSoftKeyboard() {
Activity currentActivity = mReactContext.getCurrentActivity();
if (currentActivity != null) {
View currentFocusedView = currentActivity.getCurrentFocus();
if (currentFocusedView != null) {
InputMethodManager imm =
(InputMethodManager) mReactContext.getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(currentFocusedView.getWindowToken(), 0);
}
}
}
}
27 changes: 27 additions & 0 deletions packages/react-native-bridge/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,33 @@ export function sendEventToHost( eventName, properties ) {
);
}

/**
* Shows Android's soft keyboard if there's a TextInput focused and
* the keyboard is hidden.
*
* @return {void}
*/
export function showAndroidSoftKeyboard() {
if ( isIOS ) {
return;
}

RNReactNativeGutenbergBridge.showAndroidSoftKeyboard();
}

/**
* Hides Android's soft keyboard.
*
* @return {void}
*/
export function hideAndroidSoftKeyboard() {
if ( isIOS ) {
return;
}

RNReactNativeGutenbergBridge.hideAndroidSoftKeyboard();
}

/**
* Generate haptic feedback.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For each user feature we should also add a importance categorization label to i
- [*] Guard against an Image block styles crash due to null block values [#56903]
- [**] Fix crash when sharing unsupported media types on Android [#56791]
- [**] Fix regressions with wrapper props and font size customization [#56985]
- [***] Avoid keyboard dismiss when interacting with text blocks [#57070]

## 1.109.2
- [**] Fix issue related to text color format and receiving in rare cases an undefined ref from `RichText` component [#56686]
Expand Down
2 changes: 2 additions & 0 deletions test/native/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ jest.mock( '@wordpress/react-native-bridge', () => {
subscribeOnRedoPressed: jest.fn(),
useIsConnected: jest.fn( () => ( { isConnected: true } ) ),
editorDidMount: jest.fn(),
showAndroidSoftKeyboard: jest.fn(),
hideAndroidSoftKeyboard: jest.fn(),
editorDidAutosave: jest.fn(),
subscribeMediaUpload: jest.fn(),
subscribeMediaSave: jest.fn(),
Expand Down
Loading