Skip to content

Commit

Permalink
[RNMobile] Avoid keyboard dismiss when interacting text blocks (#57070)
Browse files Browse the repository at this point in the history
* Use debounce in Aztec's blur function

* Execute `focus` UI block before other blocks

* Add `hideAndroidSoftKeyboard` to RN bridge

* Add `blurOnUnmount` to Aztec input state manager.

This function will help us to deal with the special case of unfocusing an Aztec view upon unmounting.

* Dismiss keyboard when Aztec view unmounts

This was previously handled in the `RichText` component.

* Fix unit test related to `AztecInputState` after adding debounce to `blur` function

* Remove console warning from `hideAndroidSoftKeyboard`

* Update inline comments of `blurOnUnmount` function

* [Mobile] - Android - Bring the Keyboard back when closing modals (#57069)

* React Native Bridge - Android - Introduces showAndroidSoftKeyboard to show the keyboard if there's a focused TextInput

* Mobile - Bottom Sheet - Adds usage of showAndroidSoftKeyboard when closing the Modal so it shows the Keyboard on Android for focused TextInputs

* React Native Bridge - Android - Introduces hideAndroidSoftKeyboard to hide the keyboard without triggering blur events

* React Native Bridge - Remove console warnings for unsupported methods, as their names are self-explanatory.

* Update showAndroidSoftKeyboard to take into account when the window focus changed, when we show the Modals these are shown on top of the editor activity.

It also adds an option to delay this for full screen modals

* Mobile - BottomSheet - Enable hardwareAccelerated and useNativeDriverForBackdrop props to improve performance on Android

* Update snapshots

* Removes hasWindowFocus condition as it is not being called hence not needed

* Refactor showAndroidSoftKeyboard to split into several functions, it also removes the delay functionality as it is no longer needed. It fixes an issue where mKeyboardRunnable was not being set.

It removes the delay logic from the Bottom Sheet component and the bridge.

* Updates createShowKeyboardRunnable to get the activity within the runnable instead of getting it as an param

* Remove unneeded check

* Update `react-native-editor` changelog

---------

Co-authored-by: Gerardo Pacheco <[email protected]>
  • Loading branch information
fluiddot and Gerardo Pacheco authored Dec 18, 2023
1 parent bf6d1dc commit b81790d
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -824,12 +824,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.3
- [**] Fix duplicate/unresponsive options in font size settings. [#56985]
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

0 comments on commit b81790d

Please sign in to comment.