From f9d3e7887916f3f1a033c59e4bde2f6da4c8df4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Fri, 29 Jun 2018 16:34:11 +0200 Subject: [PATCH] Add component (#4188) * add NavigationEvents * expose TabsWithNavigationEvents in lib entrypoints * Add NavigationEvents example in playground * Add NavigationEvents example in playground * Add NavigationEvents tests * Add NavigationEvents Flow declarations * remove useless NavigationEvents constructor * NavigationEvents => make tests more readable by avoiding beforeEach callback * fix flow test error by adding to React.Component --- .../examples/NavigationPlayground/js/App.js | 7 + .../js/TabsWithNavigationEvents.js | 127 +++++++++ .../react-navigation/flow/react-navigation.js | 15 ++ .../react-navigation/src/react-navigation.js | 5 + .../src/react-navigation.web.js | 5 + .../src/views/NavigationEvents.js | 57 +++++ .../views/__tests__/NavigationEvents-test.js | 241 ++++++++++++++++++ 7 files changed, 457 insertions(+) create mode 100644 packages/react-navigation/examples/NavigationPlayground/js/TabsWithNavigationEvents.js create mode 100644 packages/react-navigation/src/views/NavigationEvents.js create mode 100644 packages/react-navigation/src/views/__tests__/NavigationEvents-test.js diff --git a/packages/react-navigation/examples/NavigationPlayground/js/App.js b/packages/react-navigation/examples/NavigationPlayground/js/App.js index 31274260..a517c357 100644 --- a/packages/react-navigation/examples/NavigationPlayground/js/App.js +++ b/packages/react-navigation/examples/NavigationPlayground/js/App.js @@ -36,6 +36,7 @@ import StackWithTranslucentHeader from './StackWithTranslucentHeader'; import SimpleTabs from './SimpleTabs'; import SwitchWithStacks from './SwitchWithStacks'; import TabsWithNavigationFocus from './TabsWithNavigationFocus'; +import TabsWithNavigationEvents from './TabsWithNavigationEvents'; import KeyboardHandlingExample from './KeyboardHandlingExample'; const ExampleInfo = { @@ -126,6 +127,11 @@ const ExampleInfo = { name: 'withNavigationFocus', description: 'Receive the focus prop to know when a screen is focused', }, + TabsWithNavigationEvents: { + name: 'NavigationEvents', + description: + 'Declarative NavigationEvents component to subscribe to navigation events', + }, KeyboardHandlingExample: { name: 'Keyboard Handling Example', description: @@ -166,6 +172,7 @@ const ExampleRoutes = { path: 'settings', }, TabsWithNavigationFocus, + TabsWithNavigationEvents, KeyboardHandlingExample, // This is commented out because it's rarely useful // InactiveStack, diff --git a/packages/react-navigation/examples/NavigationPlayground/js/TabsWithNavigationEvents.js b/packages/react-navigation/examples/NavigationPlayground/js/TabsWithNavigationEvents.js new file mode 100644 index 00000000..9b3af7a9 --- /dev/null +++ b/packages/react-navigation/examples/NavigationPlayground/js/TabsWithNavigationEvents.js @@ -0,0 +1,127 @@ +/** + * @flow + */ + +import React from 'react'; +import { FlatList, SafeAreaView, StatusBar, Text, View } from 'react-native'; +import { NavigationEvents } from 'react-navigation'; +import { createMaterialBottomTabNavigator } from 'react-navigation-material-bottom-tabs'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; + +const Event = ({ event }) => ( + + {event.type} + + {event.action.type.replace('Navigation/', '')} + {event.action.routeName ? '=>' + event.action.routeName : ''} + + +); + +const createTabScreen = (name, icon, focusedIcon) => { + class TabScreen extends React.Component { + static navigationOptions = { + tabBarLabel: name, + tabBarIcon: ({ tintColor, focused }) => ( + + ), + }; + + state = { eventLog: [] }; + + append = navigationEvent => { + this.setState(({ eventLog }) => ({ + eventLog: eventLog.concat(navigationEvent), + })); + }; + + render() { + return ( + + + Events for tab {name} + + + + `${this.state.eventLog.indexOf(item)}`} + renderItem={({ item }) => ( + + + + )} + /> + + + + + + + ); + } + } + + return TabScreen; +}; + +const TabsWithNavigationEvents = createMaterialBottomTabNavigator( + { + One: { + screen: createTabScreen('One', 'numeric-1-box-outline', 'numeric-1-box'), + }, + Two: { + screen: createTabScreen('Two', 'numeric-2-box-outline', 'numeric-2-box'), + }, + Three: { + screen: createTabScreen( + 'Three', + 'numeric-3-box-outline', + 'numeric-3-box' + ), + }, + }, + { + shifting: false, + activeTintColor: '#F44336', + } +); + +export default TabsWithNavigationEvents; diff --git a/packages/react-navigation/flow/react-navigation.js b/packages/react-navigation/flow/react-navigation.js index 77ad9a59..6d58b9b0 100644 --- a/packages/react-navigation/flow/react-navigation.js +++ b/packages/react-navigation/flow/react-navigation.js @@ -557,6 +557,21 @@ declare module 'react-navigation' { navigationOptions?: O, }>; + /** + * NavigationEvents component + */ + + declare type _NavigationEventsProps = { + navigation?: NavigationScreenProp, + onWillFocus?: NavigationEventCallback, + onDidFocus?: NavigationEventCallback, + onWillBlur?: NavigationEventCallback, + onDidBlur?: NavigationEventCallback, + }; + declare export var NavigationEvents: React$ComponentType< + _NavigationEventsProps + >; + /** * Navigation container */ diff --git a/packages/react-navigation/src/react-navigation.js b/packages/react-navigation/src/react-navigation.js index 500b4e56..e424d601 100644 --- a/packages/react-navigation/src/react-navigation.js +++ b/packages/react-navigation/src/react-navigation.js @@ -156,6 +156,11 @@ module.exports = { return require('./views/SwitchView/SwitchView').default; }, + // NavigationEvents + get NavigationEvents() { + return require('./views/NavigationEvents').default; + }, + // HOCs get withNavigation() { return require('./views/withNavigation').default; diff --git a/packages/react-navigation/src/react-navigation.web.js b/packages/react-navigation/src/react-navigation.web.js index 30f0464c..e4f0e22f 100644 --- a/packages/react-navigation/src/react-navigation.web.js +++ b/packages/react-navigation/src/react-navigation.web.js @@ -42,6 +42,11 @@ module.exports = { return require('./routers/SwitchRouter').default; }, + // NavigationEvents + get NavigationEvents() { + return require('./views/NavigationEvents').default; + }, + // HOCs get withNavigation() { return require('./views/withNavigation').default; diff --git a/packages/react-navigation/src/views/NavigationEvents.js b/packages/react-navigation/src/views/NavigationEvents.js new file mode 100644 index 00000000..84d7e918 --- /dev/null +++ b/packages/react-navigation/src/views/NavigationEvents.js @@ -0,0 +1,57 @@ +import React from 'react'; +import withNavigation from './withNavigation'; + +const EventNameToPropName = { + willFocus: 'onWillFocus', + didFocus: 'onDidFocus', + willBlur: 'onWillBlur', + didBlur: 'onDidBlur', +}; + +const EventNames = Object.keys(EventNameToPropName); + +class NavigationEvents extends React.Component { + componentDidMount() { + this.subscriptions = {}; + EventNames.forEach(this.addListener); + } + + componentDidUpdate(prevProps) { + EventNames.forEach(eventName => { + const listenerHasChanged = + this.props[EventNameToPropName[eventName]] !== + prevProps[EventNameToPropName[eventName]]; + if (listenerHasChanged) { + this.removeListener(eventName); + this.addListener(eventName); + } + }); + } + + componentWillUnmount() { + EventNames.forEach(this.removeListener); + } + + addListener = eventName => { + const listener = this.props[EventNameToPropName[eventName]]; + if (listener) { + this.subscriptions[eventName] = this.props.navigation.addListener( + eventName, + listener + ); + } + }; + + removeListener = eventName => { + if (this.subscriptions[eventName]) { + this.subscriptions[eventName].remove(); + this.subscriptions[eventName] = undefined; + } + }; + + render() { + return null; + } +} + +export default withNavigation(NavigationEvents); diff --git a/packages/react-navigation/src/views/__tests__/NavigationEvents-test.js b/packages/react-navigation/src/views/__tests__/NavigationEvents-test.js new file mode 100644 index 00000000..8a4c8968 --- /dev/null +++ b/packages/react-navigation/src/views/__tests__/NavigationEvents-test.js @@ -0,0 +1,241 @@ +import React from 'react'; +import { View } from 'react-native'; +import renderer from 'react-test-renderer'; +import NavigationEvents from '../NavigationEvents'; +import { NavigationProvider } from '../NavigationContext'; + +const createListener = () => payload => {}; + +// An easy way to create the 4 listeners prop +const createEventListenersProp = () => ({ + onWillFocus: createListener(), + onDidFocus: createListener(), + onWillBlur: createListener(), + onDidBlur: createListener(), +}); + +const createNavigationAndHelpers = () => { + // A little API to spy on subscription remove calls that are performed during the tests + const removeCallsAPI = (() => { + let removeCalls = []; + return { + reset: () => { + removeCalls = []; + }, + add: (name, handler) => { + removeCalls.push({ name, handler }); + }, + checkRemoveCalled: count => { + expect(removeCalls.length).toBe(count); + }, + checkRemoveCalledWith: (name, handler) => { + expect(removeCalls).toContainEqual({ name, handler }); + }, + }; + })(); + + const navigation = { + addListener: jest.fn((name, handler) => { + return { + remove: () => removeCallsAPI.add(name, handler), + }; + }), + }; + + const checkAddListenerCalled = count => { + expect(navigation.addListener).toHaveBeenCalledTimes(count); + }; + const checkAddListenerCalledWith = (eventName, handler) => { + expect(navigation.addListener).toHaveBeenCalledWith(eventName, handler); + }; + const checkRemoveCalled = count => { + removeCallsAPI.checkRemoveCalled(count); + }; + const checkRemoveCalledWith = (eventName, handler) => { + removeCallsAPI.checkRemoveCalledWith(eventName, handler); + }; + + return { + navigation, + removeCallsAPI, + checkAddListenerCalled, + checkAddListenerCalledWith, + checkRemoveCalled, + checkRemoveCalledWith, + }; +}; + +// We test 2 distinct ways to provide the navigation to the NavigationEvents (prop/context) +const NavigationEventsTestComp = ({ + withContext = true, + navigation, + ...props +}) => { + if (withContext) { + return ( + + + + ); + } else { + return ; + } +}; + +describe('NavigationEvents', () => { + it('add all listeners with navigation prop', () => { + const { + navigation, + checkAddListenerCalled, + checkAddListenerCalledWith, + } = createNavigationAndHelpers(); + const eventListenerProps = createEventListenersProp(); + const component = renderer.create( + + ); + checkAddListenerCalled(4); + checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur); + checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus); + checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur); + checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus); + }); + + it('add all listeners with navigation context', () => { + const { + navigation, + checkAddListenerCalled, + checkAddListenerCalledWith, + } = createNavigationAndHelpers(); + const eventListenerProps = createEventListenersProp(); + const component = renderer.create( + + ); + checkAddListenerCalled(4); + checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur); + checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus); + checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur); + checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus); + }); + + it('remove all listeners on unmount', () => { + const { + navigation, + checkRemoveCalled, + checkRemoveCalledWith, + } = createNavigationAndHelpers(); + const eventListenerProps = createEventListenersProp(); + + const component = renderer.create( + + ); + checkRemoveCalled(0); + component.unmount(); + checkRemoveCalled(4); + checkRemoveCalledWith('willBlur', eventListenerProps.onWillBlur); + checkRemoveCalledWith('willFocus', eventListenerProps.onWillFocus); + checkRemoveCalledWith('didBlur', eventListenerProps.onDidBlur); + checkRemoveCalledWith('didFocus', eventListenerProps.onDidFocus); + }); + + it('add a single listener', () => { + const { + navigation, + checkAddListenerCalled, + checkAddListenerCalledWith, + } = createNavigationAndHelpers(); + const listener = createListener(); + const component = renderer.create( + + ); + checkAddListenerCalled(1); + checkAddListenerCalledWith('didFocus', listener); + }); + + it('do not attempt to add/remove stable listeners on update', () => { + const { + navigation, + checkAddListenerCalled, + checkAddListenerCalledWith, + } = createNavigationAndHelpers(); + const eventListenerProps = createEventListenersProp(); + const component = renderer.create( + + ); + component.update( + + ); + component.update( + + ); + checkAddListenerCalled(4); + checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur); + checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus); + checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur); + checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus); + }); + + it('add, remove and replace (remove+add) listeners on complex updates', () => { + const { + navigation, + checkAddListenerCalled, + checkAddListenerCalledWith, + checkRemoveCalled, + checkRemoveCalledWith, + } = createNavigationAndHelpers(); + const eventListenerProps = createEventListenersProp(); + + const component = renderer.create( + + ); + + checkAddListenerCalled(4); + checkAddListenerCalledWith('willBlur', eventListenerProps.onWillBlur); + checkAddListenerCalledWith('willFocus', eventListenerProps.onWillFocus); + checkAddListenerCalledWith('didBlur', eventListenerProps.onDidBlur); + checkAddListenerCalledWith('didFocus', eventListenerProps.onDidFocus); + checkRemoveCalled(0); + + const onWillFocus2 = createListener(); + const onDidFocus2 = createListener(); + + component.update( + + ); + checkAddListenerCalled(6); + checkAddListenerCalledWith('willFocus', onWillFocus2); + checkAddListenerCalledWith('didFocus', onDidFocus2); + checkRemoveCalled(3); + checkRemoveCalledWith('didBlur', eventListenerProps.onDidBlur); + checkRemoveCalledWith('willFocus', eventListenerProps.onWillFocus); + checkRemoveCalledWith('didFocus', eventListenerProps.onDidFocus); + }); +});