Better handling of focus/blur events out of order

This commit is contained in:
Eric Vicenti
2018-10-29 18:27:15 -07:00
parent 145100d012
commit af888dccb3
4 changed files with 255 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ import { ListSection, Divider } from 'react-native-paper';
import SimpleStack from './src/SimpleStack';
import SimpleTabs from './src/SimpleTabs';
import EventsStack from './src/EventsStack';
// Comment/uncomment the following two lines to toggle react-native-screens
// import { useScreens } from 'react-native-screens';
@@ -23,6 +24,7 @@ I18nManager.forceRTL(false);
const data = [
{ component: SimpleStack, title: 'Simple Stack', routeName: 'SimpleStack' },
{ component: SimpleTabs, title: 'Simple Tabs', routeName: 'SimpleTabs' },
{ component: EventsStack, title: 'Events', routeName: 'EventsStack' },
];
// Cache images

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { Button, ScrollView, View, Text } from 'react-native';
import { withNavigation } from '@react-navigation/core';
import { createStackNavigator } from 'react-navigation-stack';
const getColorOfEvent = evt => {
switch (evt) {
case 'willFocus':
return 'purple';
case 'didFocus':
return 'blue';
case 'willBlur':
return 'brown';
default:
return 'black';
}
};
class FocusTagWithNav extends React.Component {
state = { mode: 'didBlur' };
componentDidMount() {
this.props.navigation.addListener('willFocus', () => {
this.setMode('willFocus');
});
this.props.navigation.addListener('willBlur', () => {
this.setMode('willBlur');
});
this.props.navigation.addListener('didFocus', () => {
this.setMode('didFocus');
});
this.props.navigation.addListener('didBlur', () => {
this.setMode('didBlur');
});
}
setMode = mode => {
if (!this._isUnmounted) {
this.setState({ mode });
}
};
componentWillUnmount() {
this._isUnmounted = true;
}
render() {
const key = this.props.navigation.state.key;
return (
<View
style={{
padding: 20,
backgroundColor: getColorOfEvent(this.state.mode),
}}
>
<Text style={{ color: 'white' }}>
{key} {String(this.state.mode)}
</Text>
</View>
);
}
}
const FocusTag = withNavigation(FocusTagWithNav);
class SampleScreen extends React.Component {
static navigationOptions = ({ navigation }) => ({
title: 'Lorem Ipsum',
headerRight: navigation.getParam('nextPage') ? (
<Button
title="Next"
onPress={() => navigation.navigate(navigation.getParam('nextPage'))}
/>
) : null,
});
componentDidMount() {
this.props.navigation.addListener('refocus', () => {
if (this.props.navigation.isFocused()) {
this.scrollView.scrollTo({ x: 0, y: 0 });
}
});
}
render() {
return (
<ScrollView
ref={view => {
this.scrollView = view;
}}
style={{
flex: 1,
backgroundColor: '#fff',
}}
>
<FocusTag />
<Text
onPress={() => {
this.props.navigation.push('PageTwo');
}}
>
Push
</Text>
<Text
onPress={() => {
const { push, goBack } = this.props.navigation;
push('PageTwo');
setTimeout(() => {
goBack(null);
}, 150);
}}
>
Push and Pop Quickly
</Text>
<Text
onPress={() => {
this.props.navigation.navigate('Home');
}}
>
Back to Examples
</Text>
</ScrollView>
);
}
}
const SimpleStack = createStackNavigator(
{
PageOne: {
screen: SampleScreen,
},
PageTwo: {
screen: SampleScreen,
},
},
{
initialRouteName: 'PageOne',
}
);
export default SimpleStack;

View File

@@ -458,3 +458,86 @@ test('child focus with immediate transition', () => {
expect(childWillBlurHandler.mock.calls.length).toBe(1);
expect(childDidBlurHandler.mock.calls.length).toBe(1);
});
const setupEventTest = (subscriptionKey, initialLastFocusEvent) => {
const parentSubscriber = jest.fn();
const emitEvent = payload => {
parentSubscriber.mock.calls.forEach(subs => {
if (subs[0] === payload.type) {
subs[1](payload);
}
});
};
const subscriptionRemove = () => {};
parentSubscriber.mockReturnValueOnce({ remove: subscriptionRemove });
const evtProvider = getChildEventSubscriber(
parentSubscriber,
subscriptionKey,
initialLastFocusEvent
);
const handlers = {};
evtProvider.addListener('action', (handlers.action = jest.fn()));
evtProvider.addListener('willFocus', (handlers.willFocus = jest.fn()));
evtProvider.addListener('didFocus', (handlers.didFocus = jest.fn()));
evtProvider.addListener('willBlur', (handlers.willBlur = jest.fn()));
evtProvider.addListener('didBlur', (handlers.didBlur = jest.fn()));
return { emitEvent, handlers, evtProvider };
};
test('immediate back with uncompleted transition will focus first screen again', () => {
const { handlers, emitEvent } = setupEventTest('key0', 'didFocus');
emitEvent({
type: 'action',
state: {
index: 1,
routes: [{ key: 'key0' }, { key: 'key1' }],
isTransitioning: true,
},
lastState: {
index: 0,
routes: [{ key: 'key0' }],
isTransitioning: false,
},
action: { type: 'Any action, does not matter here' },
});
expect(handlers.willFocus.mock.calls.length).toBe(0);
expect(handlers.didFocus.mock.calls.length).toBe(0);
expect(handlers.willBlur.mock.calls.length).toBe(1);
expect(handlers.didBlur.mock.calls.length).toBe(0);
emitEvent({
type: 'action',
state: {
index: 0,
routes: [{ key: 'key0' }],
isTransitioning: true,
},
lastState: {
index: 1,
routes: [{ key: 'key0' }, { key: 'key1' }],
isTransitioning: true,
},
action: { type: 'Any action, does not matter here' },
});
expect(handlers.willFocus.mock.calls.length).toBe(1);
expect(handlers.didFocus.mock.calls.length).toBe(0);
expect(handlers.willBlur.mock.calls.length).toBe(1);
expect(handlers.didBlur.mock.calls.length).toBe(0);
emitEvent({
type: 'action',
state: {
index: 0,
routes: [{ key: 'key0' }],
isTransitioning: false,
},
lastState: {
index: 0,
routes: [{ key: 'key0' }],
isTransitioning: true,
},
action: { type: 'Any action, does not matter here' },
});
expect(handlers.willFocus.mock.calls.length).toBe(1);
expect(handlers.didFocus.mock.calls.length).toBe(1);
expect(handlers.willBlur.mock.calls.length).toBe(1);
expect(handlers.didBlur.mock.calls.length).toBe(0);
});

View File

@@ -4,7 +4,11 @@
* Based on the 'action' events that get fired for this navigation state, this utility will fire
* focus and blur events for this child
*/
export default function getChildEventSubscriber(addListener, key) {
export default function getChildEventSubscriber(
addListener,
key,
initialLastFocusEvent = 'didBlur'
) {
const actionSubscribers = new Set();
const willFocusSubscribers = new Set();
const didFocusSubscribers = new Set();
@@ -53,11 +57,11 @@ export default function getChildEventSubscriber(addListener, key) {
});
};
// lastEmittedEvent keeps track of focus state for one route. First we assume
// lastFocusEvent keeps track of focus state for one route. First we assume
// we are blurred. If we are focused on initialization, the first 'action'
// event will cause onFocus+willFocus events because we had previously been
// considered blurred
let lastEmittedEvent = 'didBlur';
let lastFocusEvent = initialLastFocusEvent;
const upstreamEvents = [
'willFocus',
@@ -96,60 +100,72 @@ export default function getChildEventSubscriber(addListener, key) {
};
const isTransitioning = !!state && state.isTransitioning;
const previouslyLastEmittedEvent = lastEmittedEvent;
const previouslylastFocusEvent = lastFocusEvent;
if (lastEmittedEvent === 'didBlur') {
if (lastFocusEvent === 'didBlur') {
// The child is currently blurred. Look for willFocus conditions
if (eventName === 'willFocus' && isChildFocused) {
emit((lastEmittedEvent = 'willFocus'), childPayload);
emit((lastFocusEvent = 'willFocus'), childPayload);
} else if (eventName === 'action' && isChildFocused) {
emit((lastEmittedEvent = 'willFocus'), childPayload);
emit((lastFocusEvent = 'willFocus'), childPayload);
}
}
if (lastEmittedEvent === 'willFocus') {
if (lastFocusEvent === 'willFocus') {
// We are currently mid-focus. Look for didFocus conditions.
// If state.isTransitioning is false, this child event happens immediately after willFocus
if (eventName === 'didFocus' && isChildFocused && !isTransitioning) {
emit((lastEmittedEvent = 'didFocus'), childPayload);
emit((lastFocusEvent = 'didFocus'), childPayload);
} else if (
eventName === 'action' &&
isChildFocused &&
!isTransitioning
) {
emit((lastEmittedEvent = 'didFocus'), childPayload);
emit((lastFocusEvent = 'didFocus'), childPayload);
}
}
if (lastEmittedEvent === 'didFocus') {
if (lastFocusEvent === 'didFocus') {
// The child is currently focused. Look for blurring events
if (!isChildFocused) {
// The child is no longer focused within this navigation state
emit((lastEmittedEvent = 'willBlur'), childPayload);
emit((lastFocusEvent = 'willBlur'), childPayload);
} else if (eventName === 'willBlur') {
// The parent is getting a willBlur event
emit((lastEmittedEvent = 'willBlur'), childPayload);
emit((lastFocusEvent = 'willBlur'), childPayload);
} else if (
eventName === 'action' &&
previouslyLastEmittedEvent === 'didFocus'
previouslylastFocusEvent === 'didFocus'
) {
// While focused, pass action events to children for grandchildren focus
emit('action', childPayload);
}
}
if (lastEmittedEvent === 'willBlur') {
if (lastFocusEvent === 'willBlur') {
// The child is mid-blur. Wait for transition to end
if (eventName === 'action' && !isChildFocused && !isTransitioning) {
// The child is done blurring because transitioning is over, or isTransitioning
// never began and didBlur fires immediately after willBlur
emit((lastEmittedEvent = 'didBlur'), childPayload);
emit((lastFocusEvent = 'didBlur'), childPayload);
} else if (eventName === 'didBlur') {
// Pass through the parent didBlur event if it happens
emit((lastEmittedEvent = 'didBlur'), childPayload);
emit((lastFocusEvent = 'didBlur'), childPayload);
} else if (
eventName === 'action' &&
isChildFocused &&
!isTransitioning
) {
emit((lastFocusEvent = 'didFocus'), childPayload);
} else if (
eventName === 'action' &&
isChildFocused &&
isTransitioning
) {
emit((lastFocusEvent = 'willFocus'), childPayload);
}
}
if (lastEmittedEvent === 'didBlur' && !newRoute) {
if (lastFocusEvent === 'didBlur' && !newRoute) {
removeAll();
}
})