Skip to content

Commit

Permalink
Link picker dismisses iOS keyboard to ensure smooth animation
Browse files Browse the repository at this point in the history
Focus of the URL text input caused animation stutter while the iOS
keyboard visibility toggled from hide to show to hide. To address this,
the Android-specific keyboard dismiss logic is now applied to iOS as
well.

When the keyboard visibility changes, the `KeyboardingAvoidingView`
configures a `LayoutAnimation` to manage the change. The quick toggle of
hide to show to hide could result in an unconsumed `LayoutAnimation`
lingering as no changes occurred which would consume the
`LayoutAnimation`.

Unconsumed `LayoutAnimations` appear to cause issues for React Native's
Modal component on iOS. Specifically, it can result in a transparent,
non-dismissible modal sitting atop the rest of the app UI. Upgrading to
React Native 0.66 appears to have increased the frequency of this
occurring, for an unknown reason.

- https://git.io/J1W3z
- https://git.io/J1W32
- https://git.io/J1W3a
- https://git.io/J1W3r
- https://git.io/J1W36
  • Loading branch information
dcalhoun committed Nov 18, 2021
1 parent 60a7ae2 commit 134470f
Show file tree
Hide file tree
Showing 5 changed files with 285 additions and 28 deletions.
10 changes: 10 additions & 0 deletions packages/block-library/src/image/test/edit.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ jest.mock( '@wordpress/data-controls', () => {
};
} );

/**
* Immediately invoke delayed functions. A better alternative would be using
* fake timers and test the delay itself. However, fake timers does not work
* with our custom waitFor implementation.
*/
jest.mock( 'lodash', () => {
const actual = jest.requireActual( 'lodash' );
return { ...actual, delay: ( cb ) => cb() };
} );

const apiFetchPromise = Promise.resolve( {} );
apiFetch.mockImplementation( () => apiFetchPromise );

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* External dependencies
*/
import { Keyboard } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { delay } from 'lodash';

/**
* WordPress dependencies
Expand All @@ -18,14 +20,20 @@ const LinkPickerScreen = ( { returnScreenName } ) => {
const route = useRoute();

const onLinkPicked = ( { url, title } ) => {
navigation.navigate( returnScreenName, {
inputValue: url,
text: title,
} );
Keyboard.dismiss();
delay( () => {
navigation.navigate( returnScreenName, {
inputValue: url,
text: title,
} );
}, 100 );
};

const onCancel = () => {
navigation.goBack();
Keyboard.dismiss();
delay( () => {
navigation.goBack();
}, 100 );
};

const { inputValue } = route.params;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { Keyboard, Platform } from 'react-native';
import { render, fireEvent, waitFor } from 'test/helpers';

/**
* Internal dependencies
*/
import LinkSettingsNavigation from '../link-settings-navigation';

beforeAll( () => {
jest.useFakeTimers();
jest.spyOn( Keyboard, 'dismiss' );
Keyboard.dismiss.mockImplementation( () => {
'>>> WHY';
} );
} );

afterAll( () => {
Keyboard.dismiss.mockRestore();
} );

const subject = (
<LinkSettingsNavigation
setAttributes={ () => {} }
hasPicker
options={ {
url: {
label: 'Link URL',
placeholder: 'Add URL',
autoFocus: false,
},
} }
isVisible
withBottomSheet
/>
);

describe( 'Android', () => {
it( 'ensures smooth back animation', async () => {
const screen = render( subject );
fireEvent.press( screen.getByText( 'Link to' ) );
fireEvent.press(
screen.getByA11yLabel( 'Link to, Search or type URL' )
);
// Await back button to allow async state updates to complete
const backButton = await waitFor( () =>
screen.getByA11yLabel( 'Go back' )
);
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );

it( 'ensures smooth apply animation', async () => {
const screen = render( subject );
fireEvent.press( screen.getByText( 'Link to' ) );
// Await back button to allow async state updates to complete
const backButton = await waitFor( () =>
screen.getByA11yLabel( 'Apply' )
);
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( 'iOS', () => {
const originalPlatform = Platform.OS;
beforeAll( () => {
Platform.OS = 'ios';
} );

afterAll( () => {
Platform.OS = originalPlatform;
} );

it( 'ensures smooth back animation', async () => {
const screen = render( subject );
fireEvent.press( screen.getByText( 'Link to' ) );
// Await back button to allow async state updates to complete
const backButton = await waitFor( () =>
screen.getByA11yLabel( 'Go back' )
);
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );

it( 'ensures smooth apply animation', async () => {
const screen = render( subject );
fireEvent.press( screen.getByText( 'Link to' ) );
// Await back button to allow async state updates to complete
const backButton = await waitFor( () =>
screen.getByA11yLabel( 'Apply' )
);
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );
} );
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { Platform, Keyboard } from 'react-native';
import { Keyboard } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { delay } from 'lodash';
/**
Expand All @@ -20,31 +20,20 @@ const LinkPickerScreen = () => {
const navigation = useNavigation();
const route = useRoute();
const onLinkPicked = ( { url, title } ) => {
if ( Platform.OS === 'android' ) {
Keyboard.dismiss();
delay( () => {
navigation.navigate( linkSettingsScreens.settings, {
inputValue: url,
text: title,
} );
}, 100 );
return;
}
navigation.navigate( linkSettingsScreens.settings, {
inputValue: url,
text: title,
} );
Keyboard.dismiss();
delay( () => {
navigation.navigate( linkSettingsScreens.settings, {
inputValue: url,
text: title,
} );
}, 100 );
};

const onCancel = () => {
if ( Platform.OS === 'android' ) {
Keyboard.dismiss();
delay( () => {
navigation.goBack();
}, 100 );
return;
}
navigation.goBack();
Keyboard.dismiss();
delay( () => {
navigation.goBack();
}, 100 );
};

const { inputValue } = route.params;
Expand Down
144 changes: 144 additions & 0 deletions packages/format-library/src/link/test/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* External dependencies
*/
import { Keyboard, Platform } from 'react-native';
import { render, fireEvent, waitFor } from 'test/helpers';

/**
* WordPress dependencies
*/
import { Slot, SlotFillProvider } from '@wordpress/components';

/**
* Internal dependencies
*/
import { link } from '../index';

const { edit: LinkEdit } = link;

// Simplified tree to render link format component
const LinkEditSlot = ( props ) => (
<SlotFillProvider>
<Slot name="RichText.ToolbarControls.link" />
<LinkEdit { ...props } />
</SlotFillProvider>
);

beforeAll( () => {
jest.useFakeTimers();
jest.spyOn( Keyboard, 'dismiss' );
Keyboard.dismiss.mockImplementation();
} );

afterAll( () => {
Keyboard.dismiss.mockRestore();
} );

describe( 'Android', () => {
it( 'ensures smooth back animation', async () => {
const screen = render(
<LinkEditSlot
activeAttributes={ {} }
onChange={ () => {} }
value={ {
text: '',
formats: [],
replacements: [],
} }
/>
);
fireEvent.press( screen.getByA11yLabel( 'Link' ) );
fireEvent.press(
screen.getByA11yLabel( 'Link to, Search or type URL' )
);
// Await back button to allow async state updates to complete
const backButton = await waitFor( () =>
screen.getByA11yLabel( 'Go back' )
);
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );

it( 'ensures smooth apply animation', async () => {
const { getByA11yLabel } = render(
<LinkEditSlot
activeAttributes={ {} }
onChange={ () => {} }
value={ {
text: '',
formats: [],
replacements: [],
} }
/>
);
fireEvent.press( getByA11yLabel( 'Link' ) );
fireEvent.press( getByA11yLabel( 'Link to, Search or type URL' ) );
// Await back button to allow async state updates to complete
const backButton = await waitFor( () => getByA11yLabel( 'Apply' ) );
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( 'iOS', () => {
const originalPlatform = Platform.OS;
beforeAll( () => {
Platform.OS = 'ios';
} );

afterAll( () => {
Platform.OS = originalPlatform;
} );

it( 'ensures smooth back animation', async () => {
const screen = render(
<LinkEditSlot
activeAttributes={ {} }
onChange={ () => {} }
value={ {
text: '',
formats: [],
replacements: [],
} }
/>
);
fireEvent.press( screen.getByA11yLabel( 'Link' ) );
fireEvent.press(
screen.getByA11yLabel( 'Link to, Search or type URL' )
);
// Await back button to allow async state updates to complete
const backButton = await waitFor( () =>
screen.getByA11yLabel( 'Go back' )
);
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );

it( 'ensures smooth apply animation', async () => {
const { getByA11yLabel } = render(
<LinkEditSlot
activeAttributes={ {} }
onChange={ () => {} }
value={ {
text: '',
formats: [],
replacements: [],
} }
/>
);
fireEvent.press( getByA11yLabel( 'Link' ) );
fireEvent.press( getByA11yLabel( 'Link to, Search or type URL' ) );
// Await back button to allow async state updates to complete
const backButton = await waitFor( () => getByA11yLabel( 'Apply' ) );
Keyboard.dismiss.mockClear();
fireEvent.press( backButton );

expect( Keyboard.dismiss ).toHaveBeenCalledTimes( 1 );
} );
} );

0 comments on commit 134470f

Please sign in to comment.