Prevent navigating twice when clicking a button quickly
nihgwu opened this issue Β· 122 comments
When we double-click(or multi-click) a link quickly, it will navigate to the same screen twice, so when we want to goBack, we need click the back button twice to go back to parent screen, that's insane.
I use ExperimentalNavigation
before, and if I double-click a link quickly, the app crashes because we are pushing a route with the same key, which is not allowed by ExperimentalNavigation
, you can try the UIExplorer
to reproduce, so I make a check for it:
- newKey == latestKey, ignore this action
- routes.contains(newKey), jumpTo(newKey)
Now in react-navigation
it won't crash because every time we dispatch a new action, we get a different key
So my propose:
- don't generate key if provided in action when dispatching and check for existing
- generate key according to route params, but I'm afraid that will be buggy because the screen relies on more params expect routeParams
Any thoughts on this?
export function routerReducer(state, action) {
if (action.type.startsWith('Navigation/')) {
const { type, routeName } = action
const lastRoute = state.routes[state.routes.length - 1]
if (type == lastRoute.type && routeName == lastRoute.routeName) return state
}
return AppNavigator.router.getStateForAction(action, state)
}
workaround for my case, I know we can custom nearly everything, but IMO this issue should be fixed in core
Another option is to just debounce the function you're dispatching from.
cc @ericvicenti
but debounce would make transition a bit laggy and we should let user to dispatch them own keys
dispatch(NavigationActions.navigate({routeName: 'Detail', key: 'user-123'}))
Now in react-navigation it won't crash because every time we dispatch a new action, we get a different key
To me it's a feature. Imagine a screen, like userProfile
that is generic and accepts user details to display as a JSON. It can be in the stack as many times as I want.
@grabbou But I don't think we should override the provided key, at least show a warn about overriding or key generation strategy
@nihgwu : but debounce would make transition a bit laggy
Maybe I'm missing something, but why would it?
cc @ericvicenti What do you think about this? This is has been a common issue I have faced in all of the apps I have worked on so far.
Mostly I used NavigationExperimental
or ExNavigation
. In case of former I did changes in reducer to not push same routes with same params twice
ExNavigation had a debouncing mechanism to prevent navigating twice in quick succession. cc @skevy
@satya164 my mistake, a debounce would be fine, but we should stop overriding custom keys
I take the control of the default action flow of react-navigation, and apply a debounce, here is an example using dva:
https://github.com/nihgwu/react-native-dva-starter/blob/master/app/models/router.js#L24-L33
@nihgwu @satya164 @grabbou
I am facing the same issus, and I believe everyone who uses this library will eventually have to address this issue.
I created #768, but closed as duplicate as I found this thread.
Here's my original post and a possible idea to fix this.
Pressing on the navigation header right button quickly for multiple times causes navigating to the new route multiple times.
I think it should be easy to prevent this behavior as it can be very common behavior that we do not want.
Possible solutions would be
Add transitioning property to navigation prop so that header button can disable itself if there is another navigation transition taking in place.
navigationOptions: {
headers: ({navigate, transitioning}) => ({
right:
<Button
icon="ios-settings-outline"
disabled={transitioning}
onPress={() => navigate('Settings')}
/>,
})
},
@satya164 What do you think about adding a new property, transitioning
?
It can be useful when you want to disable multiple buttons as transition occurs.
A prop will cause a re-render which will make transition slower. You can use InteractionManager.runAfterInteractions
instead
I guess a lodash.debounce()
can solve this already?
Currently I create a function to override navigate
from addNavigationHelpers
as below:
_addNavigationHelpers(navigation) {
const original = addNavigationHelpers(navigation);
let debounce;
return {
...original,
navigateWithDebounce: (routeName, params, action) => {
let func = () => {
clearTimeout(debounce);
debounce = setTimeout(() => {
navigation.dispatch(NavigationActions.navigate({
routeName,
params,
action
}));
}, 200)
}
return func();
}
}
}
And
<VocabTabs navigation={this._addNavigationHelpers({
dispatch: this.props.dispatch,
state: this.props.nav,
})}/>
Then you can use this.props.navigation.navigateWithDebounce
instead of this.props.navigation.navigate
This, of course is a temporary approach. It'd better to have a life-cycle hook to detect when screen is in transition progress then prevent pushing another route.
I used your method to good effect. I changed it around slightly because I needed long delays (page transitions can be quite slow) and I didn't want to delay the navigation.
_addNavigationHelpers = (navigation) => {
const original = addNavigationHelpers(navigation);
let debounce;
return {
...original,
navigateWithDebounce: (routeName, params, action) => {
let func = () => {
if (debounce) {
return;
}
navigation.dispatch(NavigationActions.navigate({
routeName,
params,
action
}));
debounce = setTimeout(() => {
debounce = 0;
}, 1000)
};
return func();
}
}
};
Nice approach @microwavesafe π
@microwavesafe Where do I need to put that to get it working?
@dzuncoi Do you mean inside library files? Because I don't use addNavigationHelpers anywhere in my code
@cosivox if you don't use addNavigationHelpers
then you can override NavigationActions.navigate
function, no need to edit lib files
Lets go with this approach, which solves the same problem: #135
@dzuncoi May I ask for an example with NavigationActions.navigate
? I can't seem to find it in this repo.
@Doko-Demo-Doa
You can find it here
It's a basic action, you can use it as below:
NavigationActions.navigate({
routeName,
params,
action
})
It's just a action creator, and return a payload, includes routeName, params and action.
You can see this to see how it works
Thank you, but what about this.props.navigation.navigate
? Right now I don't use addNavigationHelpers (because I don't use redux, for now) so does that mean I have to modify the lib?
As I said above, you can override navigate
action, no need to modify libs.
Something like:
NavigationActions.overridedNavigate = (routeName, params, action) => {
// some override logic
return NavigationActions.navigate(routeName, params, action)
}
I finally found a way to solve my problem. I had to edit the lib after all because I use this.props.navigation.navigate a lot and I don't want to modify every single line of that.
Edit the node_modules/react-navigation/src/addNavigationHelpers.js
/**
* @flow
*
* Helpers for navigation.
*/
import type {
NavigationAction,
NavigationProp,
NavigationParams,
} from './TypeDefinition';
import NavigationActions from './NavigationActions';
export default function<S: *> (navigation: NavigationProp<S, NavigationAction>) {
let debounce = true; // Add this.
return {
...navigation,
goBack: (key?: ?string): boolean => navigation.dispatch(NavigationActions.back({
key: key === undefined ? navigation.state.key : key,
})),
navigate: (
routeName: string,
params?: NavigationParams,
action?: NavigationAction): boolean => {
// And this conditional check.
if (debounce) {
debounce = false;
navigation.dispatch(NavigationActions.navigate({
routeName,
params,
action,
}));
setTimeout(() => {
debounce = true;
}, 600);
}
},
// End check
/**
* For updating current route params. For example the nav bar title and
* buttons are based on the route params.
* This means `setParams` can be used to update nav bar for example.
*/
setParams: (params: NavigationParams): boolean =>
navigation.dispatch(NavigationActions.setParams({
params,
key: navigation.state.key,
})),
};
}
600 ms works well for me, may vary for other people.
Was a solution ever officially implemented or do we still need to use a workaround?
I would prefer the default would be to prevent navigating to the same view twice if all the route parameters are unchanged. I can't think of a scenario where rendering the same view (w/ same parameters) twice, as is the case w/ double tap, would ever be desirable.
update - This page from the docs ended up helping me out the most. I just override my navigators state for navigate actions whose action routeName and params match the last navigation state.
Has anyone successfully implemented this when using Redux action creators?
I have an app using a push action of this style, mostly for NavigationExperimental compatibility:
export const push: Action = (route) => {
return {
type: 'Navigation/NAVIGATE',
routeName: route.template,
params: {
...route,
},
};
};
Then my connected components receive this action in from mapDispatchToProps
.
I can't think of a good place to implement a debounce without manually passing the navigation prop all over the place and rewriting all of my push
actions.
Edit:
I successfully implemented this by checking for duplication of routeName and params in my navigation reducer.
Here's an example:
// isEqual is a shallow object comparison of your choice. I'm using is-equal-shallow here.
function navReducer(previousState, action) {
switch (action.type) {
// Most navigation actions are handled by the navigation router supplied by React Navigation
default:
if (action.type === 'Navigation/NAVIGATE') {
const { routes, index } = previousState;
const { routeName, params } = action;
const currentTab = routes[index];
const lastScene = currentTab.routes[currentTab.routes.length - 1];
// Check for duplication
if (lastScene.routeName === routeName && isEqual(lastScene.params, params)) {
return previousState;
}
}
return TabsRoot.router.getStateForAction(action, previousState);
}
}
No, i didn't use REDUX. I use react-native just few daysοΌand i don't understand the react-navigation how to route my screen, like @Doko-Demo-Doa , i use this.props.navigation.navigate
,what should i do
If so, you can create an override function, like my comment here
Then instead of using this.props.navigation.navigate
, you should use this.props.navigation. overridedNavigate
. I didn't test this approach, but hope it will work :)
@wellyshen what if I want to dispatch the Navigation action directly? I think the right way is to handle the debounce in reducers, see #271 (comment)
I am using nested navigators and based my workaround on BradRyan's and nihgwu's comments.
const Index = StackNavigator({...});
const App = StackNavigator({
Index: {
screen: Index
},
...
});
const navigateOnce = (getStateForAction) => (action, state) => {
const {type, routeName} = action;
return (
state &&
type === NavigationActions.NAVIGATE &&
routeName === state.routes[state.routes.length - 1].routeName
) ? null : getStateForAction(action, state);
// you might want to replace 'null' with 'state' if you're using redux (see comments below)
};
App.router.getStateForAction = navigateOnce(App.router.getStateForAction);
Index.router.getStateForAction = navigateOnce(Index.router.getStateForAction);
Is there a recommended solution to this currently?
A simple solution that worked for me was adding a flag on the component that triggers the navigation, and toggling it when navigating. Then using setTimeout like this:
setTimeout(this.toggleNavigation.bind(this),500);
to toggle the flag back to normal once it triggers navigation.
Here's my solution, I'm using redux (please focus on the "prevent multiple call") :
/* @flow */
import { NavigationActions } from 'react-navigation';
import _ from 'lodash';
import type { Nav, Action } from '../types';
import Navigator from '../navigator/configNavigator';
type State = Nav;
// Prevent screen called multitple times by quickly tap
const navigateOnce = getStateForAction => (action, state): ?Object => {
const { type, routeName } = action;
return state &&
type === NavigationActions.NAVIGATE &&
routeName === state.routes[state.routes.length - 1].routeName
? null
: getStateForAction(action, state);
};
Navigator.router.getStateForAction = navigateOnce(Navigator.router.getStateForAction);
export default (state: State, action: Action): State => {
const nextState = Navigator.router.getStateForAction(action, state);
return newState = nextState || state;
};
@asleepace Would you mind giving a more in depth code explanation/example when you get a chance? I'm still not connecting the dots as to how you'd implement that.
@gregblass sure here is the more in depth solution:
constructor(props) {
super(props);
this.state = {
isNavigating: false,
}
}
viewPadlet() {
if (this.state.isNavigating == false) {
this.state.isNavigating = true;
this.props.navigate('View',{ padlet:this.props.padlet });
setTimeout(this.toggleNavigation.bind(this), 500);
}
}
toggleNavigation() {
this.state.isNavigating = false;
}
The method viewPadlet is invoked from a touchable highlight on a cell in a ListView. Also on the ListView itself since it is the main component in this file I pass the navigation item as a prop in the renderRow prop:
renderRow={(data) => <PadletCell navigate={navigate} padlet={data}/>}
Is there is any solution to prevent second navigation when we click on "Back" button?
@asleepace : I don't like that solution (i have tried before). I have 4 components can trig a navigation on a screen. So, i dont want to duplicate over and over. @Palisand 's solution is better right now (hopefully it will be official via an option like navigateOne: true
)
@Palisand After digging through the problems with nested navigators and trying to get it working with debounce
, I stumbled on your solution. But I needed to replace null
with state
inside your navigateOnce
call to make it work for me. Curious why you chose to return null
...I'm using react-navigation@1.0.0-beta.11
(in case their API/semantics changed).
@mikelambert I chose to return null
because I based my solution on this (the link was originally provided by @BradRyan here). True, that particular example pertains to NavigationActions.BACK
rather than NAVIGATE
, but it worked for me so I thought I might as well post it here. @wellyshen's solution uses my navigateOnce
but with the same change you have implemented. I will update my answer with the suggestion to replace null
with state
. What exactly wasn't working when you used null
, were you still navigating twice?
@Palisand I was doing a double-navigate to the same routeName, and my redux state was obliterated and replaced with null
, then triggering errors elsewhere down the line that operated off the state.
Though I was doing a TabNavigator containing StackNavigators (with navigateOnce
applied), not a StackNavigator of StackNavigators like in your example, which I think makes the difference. The parent StackRouter
does this code:
const route = childRouter.getStateForAction(action, childRoute);
if (route === null) {
return state; // What your code triggers
}
if (route && route !== childRoute) {
return StateUtils.replaceAt(state, childRoute.key, route); // What my fix would trigger, I think this should work still...?
}
Whereas the TabRouter
has this code:
const activeTabState = activeTabRouter.getStateForAction(
action.action || action,
activeTabLastState
);
if (!activeTabState && inputState) {
return null; // What your code triggers, blowing up my redux state
}
if (activeTabState && activeTabState !== activeTabLastState) {
const routes = [...state.routes];
routes[state.index] = activeTabState;
return { // What my fix would trigger, which works fine
...state,
routes,
};
}
I filed a bug about another instance of return null
here: #1852 , but I think I should file a new bug to mention the case above, since it feels like a bad divergence between StackRouter and TabRouter. Filed as #1939
@Palisand, @mikelambert I was using nested stack navigators and if I replaced null
with state
it wasn't prevent double navigation when routing inside the nested navigator.
I also had to update my redux reducer to properly handle null
state returns, which is inline with the docs (which seem to expect the value to be null on occasion) https://reactnavigation.org/docs/guides/redux#Redux-Integration:
const nextState = ApplicationNavigator.router.getStateForAction(
action,
state
);
return nextState || state;
Another approach to the issue, only allows navigating once per 200 ms.
let timeout = null;
export const navigationReducer = (state, action) => {
if (action.type.startsWith('Navigation/')) {
if (timeout) {
return state;
}
timeout = setTimeout(() => timeout = null, 200);
}
const newState = router.getStateForAction(action, state);
return (newState ? newState : state);
};
I saw above suggest but none of them work for me. I have a list of items at the Left drawer, and user can click on each item to open the screen Profile
. I also have a screenA
which has a buttonX
to show Profile
. I want to prevent prevent display multiple same-Profile
when user rapidly taps on buttonX
. So what I do is:
Note: I use redux.
The workaround is:
- Any forward navigation, I will add a time in ms at params, it's named
navigateAt
- Override getStateForAction get the previous route info and do a simple check.
The reducer:
const initialState = AppNavigator.router.getStateForAction(AppNavigator.router.getActionForPathAndParams('Splash'))
export default NavReducer = (state = initialState, action) => {
let nextState
switch (action.type) {
case "ACTIONS.GO_BACK":
// we don't prevent back so we don't need any wire thing here
nextState = AppNavigator.router.getStateForAction(NavigationActions.back(), state)
break
case "OTHER_CASE": break
case "ACTIONS.GOTO_PROFILE":
nextState = AppNavigator.router.getStateForAction(NavigationActions.navigate({
routeName: 'Profile',
params: {
...action.params,
navigateAt: new Date().getTime() // HERE IS THE KEY. ANY ACTION SHOULD HAVE IT
}
}), state)
break
default:
nextState = AppNavigator.router.getStateForAction(action, state)
break
}
return nextState || state
}
And the router
const App = StackNavigator({...})
const preventMultiTaps = (getStateForAction) => (action, state) => {
const {type, routeName, params} = action
return (
state &&
type === NavigationActions.NAVIGATE &&
routeName === state.routes[state.routes.length - 1].routeName &&
params.navigateAt - state.routes[state.routes.length - 1].params.navigateAt < 500 // PREVENT IN 500ms
) ? null : getStateForAction(action, state)
};
App.router.getStateForAction = preventMultiTaps(App.router.getStateForAction);
@Palisand Your solution work great but in case, with a tabbar, i can't tap on my last tab :/ !
@giautm Yes i'm stupid sorry ahah
guys, have you tried goBack and reset, there still "yellow bar" comes out, especially if you have a Modal on the next View.
A little birdie told me that this may be prioritized soon with the V1 push.
I have been having decent luck with this if your not hooking up to redux or feeling fancy.
I use it in static navigationOptions and pass it the navigate.navigate() func.
It's not an overly robust fix. It doesn't fix back buttons.
Inspired from stack overflow: https://stackoverflow.com/a/41215941/2784950
import _ from 'lodash';
class HeaderButton extends Component {
constructor(props) {
super(props);
this.debouncedOnpress = _.debounce(this._onPressFunc, 2000, {'leading': true, 'trailing': false,});
}
_onPressFunc = () => {
this.props.onPress();
console.log("Debounced!");
}
render() {
return (
<TouchableOpacity
onPress={this.debouncedOnpress}
style={styles.button}>
<Image source={require('../images/plus.png')}
style={styles.image}/>
</TouchableOpacity>
);
}
}
Excited for the fix in core!
Any ETA on this fix in core?
I'm wondering if it's worth implementing these workarounds if a fix is coming down the pipes in the coming weeks?
Here is my hack using setTimeout
<TouchableOpacity disabled={this.state.disable} onPress={() => {this.navFunction();navigate('MobileNumber');}}>
And here first disabling the touchableopacity then enabling it using setTimeout
navFunction() {
this.setState({disable : true});
setTimeout(() => {this.setState({disable: false})}, 2000)
}
+1 please fix.
Here's the workaround I came up with until this is addressed. I'm storing a navigating
flag in my redux store:
action:
export const setNavigating = (value) => ({
type: SET_NAVIGATING,
payload: value
})
reducer:
import {
SET_NAVIGATING,
} from '../actions/types'
const INITIAL_STATE = false
export default (state = INITIAL_STATE, action) => {
switch (action.type) {
case SET_NAVIGATING:
return action.payload
default:
return state
}
}
nav util:
// Workaround for React Navigation's double tap bug
import { store } from '../store'
import { setNavigating } from '../actions'
debounceNav = (navigation, screen, options) => {
let isNavigating = store.getState().navigating
if (isNavigating == false) {
store.dispatch(setNavigating(true))
requestAnimationFrame(
() => navigation.navigate(screen, options)
)
setTimeout(
() => store.dispatch(setNavigating(false)),
500
)
}
}
export { debounceNav }
Then I refactored every place in my application where navigation.navigate
was happening with debounceNav(navigation, 'screen-name', options)
.
Works for now. Looking forward to the official fix and being able to remove all that.
@microwavesafe Thanks!
This is the solution i came up with:
- works with Redux
- dig recursively into the
NavigationState
until I get the currentrouteName
, - then I do the same for the next
NavigationState
and get the nextrouteName
. - If it's the same route I simply return the current state, otherwise i let the navigation happen
This is the smallest modification needed and works with nested navigators too!
navigation_reducer.js
import { AppNavigator } from './../AppNavigator';
const getCurrentRouteName = (state) => {
const route = state.routes[state.index];
return typeof route.index === 'undefined' ? route.routeName : getCurrentRouteName(route);
}
export default (state, action) => {
const nextState = AppNavigator.router.getStateForAction(action, state);
// prevents navigating twice to the same route
if (state && nextState) {
const stateRouteName = getCurrentRouteName(state);
const nextStateRouteName = getCurrentRouteName(nextState);
return stateRouteName === nextStateRouteName ? state : nextState;
}
// Simply return the original `state` if `nextState` is null or undefined.
return nextState || state;
};
What do you think?
There's some overkill solutions and (not so nice) workarounds, for what should be a simple fix to the core lib.
My problem with the previously provided solutions: the current state hasn't changed yet at the point of interception - meaning I can't compare newState vs prevState.
Ideally the library would provide an interceptor, which would allow the developer to provide custom logic, which allows/rejects the _onNavigationStateChange
.
Until that solution, I've hacked in the following code (to prevent adding two routes of the same name onto the stack, in a row).
Before this line... added this code:
const prevNav = state.nav
if (prevNav.routes[prevNav.index].routeName == action.routeName) {
console.log('block double navigation to the same route - routeName=' + action.routeName)
return false
}
@samw2k00 are you referring to my solution (the comment before yours)?
Are you using that code as reducer of Redux?
@sun2rise sorry, i was referring to the solution proposed by @satya164 @nihgwu
where you implement using redux and monitor the previous state.
i had the same problem as @arnsa where the route state keep showing the last tab bar item.
care to explain how can I implement this with a TabNavigator? Because state.routes[state.routes.length - 1] is always going to be last tab bar item for me.
Expanding on @sun2rise's answer, this works for me using the navigator as a view component:
<MainTabNavigator
onNavigationStateChange={(prevState, currentState, action) => {
const defaultGetStateForAction = MainTabNavigator.router.getStateForAction;
const getCurrentRouteName = (state) => {
const route = state.routes[state.index];
return typeof route.index === 'undefined' ? route.routeName : getCurrentRouteName(route);
}
MainTabNavigator.router.getStateForAction = (action, state) => {
if(action.routeName == getCurrentRouteName(state)){
return null;
}
return defaultGetStateForAction(action, state);
};
}}
/>
Hi all, I am new to React-Native so many things I don't quite understand.
I create a custom workaround on the button behavior to prevent double click.
Here is my code, hopefully you can find it useful.
function _safeClick(obj,ref,evt){
//maintain an array of last click time stamp
if(!obj.lastClick) { obj.lastClick = Array();}
//check if array with ref as key exist (ever been click before)
if(!obj.lastClick[ref]) { obj.lastClick[ref] = new Date(0); }
//trigger event when at least 1000ms time difference since last click
if((new Date()).getTime() - obj.lastClick[ref].getTime() > 1000){
evt();
obj.lastClick[ref]=new Date();
}
}
and here is how I use the function in a TouchableOpacity component
<TouchableOpacity onPress={()=>{_safeClick(this,'btn1', ()=>{navigate('Page1');})}} >
<Text >Go To Page 1</Text>
</TouchableOpacity>
<TouchableOpacity onPress={()=>{_safeClick(this,'btn2', ()=>{navigate('Page2');})}} >
<Text >Go To Page 2</Text>
</TouchableOpacity>
Here's a general solution for Redux users. Works if you're navigating between different nested navigators.
reducers/nav.js
import { NavigationActions } from 'react-navigation';
import AppNavigator from '../../screens';
// eslint-no-prototype-builtins
function hasProp(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
// Gets the current route name
function getCurrentRouteName(nav) {
if (!hasProp(nav, 'index') || !hasProp(nav, 'routes')) return nav.routeName;
return getCurrentRouteName(nav.routes[nav.index]);
}
// Gets the destination route name
function getActionRouteName(action) {
const hasNestedAction = Boolean(
hasProp(action, 'action') && hasProp(action, 'type') && typeof action.action !== 'undefined',
);
const nestedActionWillNavigate = Boolean(hasNestedAction && action.action.type === NavigationActions.NAVIGATE);
if (hasNestedAction && nestedActionWillNavigate) {
return getActionRouteName(action.action);
}
return action.routeName;
}
const initialState = AppNavigator.router.getStateForAction(
NavigationActions.navigate({ routeName: 'LoginScreen' })
);
// Navigation Reducer
const navReducer = (state = initialState, action) => {
const { type } = action;
if (type === NavigationActions.NAVIGATE) {
// Return current state if no routes have changed
if (getActionRouteName(action) === getCurrentRouteName(state)) {
return state;
}
}
// Else return new navigation state or the current state
return AppNavigator.router.getStateForAction(action, state) || state;
}
export default navReducer;
Thank you @jamsch redux fix works perfect but I think you have a mistake in the condition on this line if (!routeHasChanged) {
@tomaskazatel Whoops my bad. I used a '===' instead of a '!==' on the previous line to indicate that the route name was different.
I fixed this bug by creating a module which calls a function only once in the passed interval.
Example: If you wish to navigate from Home -> About
And you press the About button twice in say 400 ms
.
navigateToAbout = () => dispatch(NavigationActions.navigate({routeName: 'About'}))
const pressHandler = callOnce(navigateToAbout,400);
<TouchableOpacity onPress={pressHandler}>
...
</TouchableOpacity>
The module will take care that it calls navigateToAbout only once
in 400 ms.
Here is the link to the NPM module: https://www.npmjs.com/package/call-once-in-interval
FYI I found that my solution didn't apply when navigating between different nested navigators. I've updated the code with a new function getActionRouteName
to solve this issue.
I just wrote the redux middleware for react-navigation debouncing, https://github.com/parakhod/react-navigation-redux-debouncer
Just add it and it will block all the unnecessary navigation actions
Hi Guys, update my code for handle this issue with redux. Try it and tell me if it works for your scenario or not ;)
Thanks to @Palisand's workaround, I got inspired to improve upon and made another workaround that works in my use cases ( I have a lot of nested navigators )
import {
NavigationActions
} from 'react-navigation'
import _ from 'lodash'
var previousAction = null;
var previousState = null;
export default (navigator) => {
const navigateOnce = (getStateForAction) => (action, state) => {
if (
action && action.type === NavigationActions.NAVIGATE &&
_.isEqual(previousAction, action) &&
action.routeName === state.routes[state.index].routeName
) {
return null
} else {
if (action && action.type === NavigationActions.NAVIGATE) {
previousAction = action
if (state) {
previousState = state.routes[state.index]
}
}
}
return getStateForAction(action, state)
}
navigator.router.getStateForAction = navigateOnce(navigator.router.getStateForAction)
return navigator
}
This is how I solved it. It uses it helper function on the root navigator router to determine the absolute route name.
const { getStateForAction, getPathAndParamsForState } = RawRootNavigator.router;
const initialState = getStateForAction(NavigationActions.init(), null);
export default function navigationReducer(state = initialState, action) {
switch (action.type) {
case NavigationActions.BACK:
case NavigationActions.INIT:
case NavigationActions.NAVIGATE:
case NavigationActions.RESET:
case NavigationActions.SET_PARAMS:
case NavigationActions.URI:
const nextState = getStateForAction(action, state);
// Return current state if the next route name is the same as the current
if (nextState === null || getPathAndParamsForState(nextState).path === getPathAndParamsForState(state).path) {
return state;
}
return nextState;
default:
return state;
}
}
Thanks @gregblass! Your solution is very simple, avoids getting into the guts of react-navigation, and works like a charm.
The react-navigation website says: Easy-to-Use Navigators -- Start quickly with built-in navigators that deliver a seamless out-of-the box experience.
After spending 3 hours reading through this entire thread and several days evaluating different workarounds, I no longer believe that claim.
Disallowing double tapping of a menu item should be the default behavior for any navigation library, no ifs, ands, or buts about it.
I guess you can say they are "working towards" being easy to use and work out of box. But I agree at this current state it is not what it claims to be.
Even though there is no super clear parent child relationship when navigating to arbitrary screens, React Navigation provides a way to pass along props and methods by piggy-backing on the navigation props. This will be much closer to how you would normally write idiomatic React. Solutions with setTimeout or debouncing should be avoided.
In this example, the RootComponent passes a method through this.props.navigation.state.params that will reset a boolean responsible for disabling your button. Because the next screen you navigate to will mount and unmount, you can use the second screen's componentWillUnmount to reset the value.
class RootScreen extends Component {
this.state = {
disabled: false
}
resetDisabled = () => {
this.setState({
disabled: false
})
}
handlePress = () => {
this.setState({ disabled: true })
}
componentDidUpdate() {
// you can pass along props and methods in the second parameter
// of the navigate function
if (this.state.disabled) {
this.props.navigation.navigate('SomeScreen', {
resetDisabled: this.resetDisabled
})
}
}
render() {
return(
<Button disabled={this.state.disabled} onPress={this.handlePress} />
)
}
}
class SomeScreen extends Component {
componentWillUnmount() {
const { navigation: { state: { params: { resetDisabled } } } } = this.props;
resetDisabled();
}
....
}
@andrewnaeve state changes cause a render, which should be avoided while transitioning.
@koenpunt In this case, the state change & render occurs before and after the transition animation. Navigating forward, the navigation is triggered by componentDidUpdate which triggers post render. On the way backward, componentWillUnmount isn't triggered until the animation is complete, and the component is no longer visible at all. In practice, my frame rate is the same with or without it, so it seems to be performant.
Guys, have this issue ( Prevent navigating twice when clicking a button quickly ) been resolved in the latest version, if yes, how to implement it without Redux ?
My solution for use without redux:
import deepDiffer from 'react-native/lib/deepDiffer';
import { NavigationActions, StateUtils } from 'react-navigation';
export const getActiveRouteForState = navigationState =>
navigationState.routes
? getActiveRouteForState(navigationState.routes[navigationState.index])
: navigationState;
export const isEqualRoute = (route1, route2) => {
if (route1.routeName !== route2.routeName) {
return false;
}
return !deepDiffer(route1.params, route2.params);
};
const PATTERN_DRAWER_ROUTE_KEY = /^Drawer(Open|Close|Toggle)$/;
export const isDrawerRoute = route => PATTERN_DRAWER_ROUTE_KEY.test(route.routeName);
export const withNavigationPreventDuplicate = getStateForAction => {
const defaultGetStateForAction = getStateForAction;
const getStateForActionWithoutDuplicates = (action, state) => {
if (action.type === NavigationActions.NAVIGATE) {
const previousRoute = getActiveRouteForState(StateUtils.back(state));
const currentRoute = getActiveRouteForState(state);
const nextRoute = action;
if (isDrawerRoute(currentRoute) && isEqualRoute(previousRoute, nextRoute)) {
return StateUtils.back(state); // Close drawer
}
if (isEqualRoute(currentRoute, nextRoute)) {
return null;
}
}
return defaultGetStateForAction(action, state);
};
return getStateForActionWithoutDuplicates;
};
Then overwrite the getStateForAction
method of your root navigator:
const StackNavigation = StackNavigator({ ... });
StackNavigation.router.getStateForAction = withNavigationPreventDuplicate(
StackNavigation.router.getStateForAction
);
:)
@parakhod I tweaked your code a bit and it worked! Great fix for a redux implementation.
Glad I'm not the only one facing this issue :)
.. learning a lot looking through these work arounds
Solutions comparing route names don't work for back button and for navigating to the same screen.
I solved this by adding "from" property to navigation actions and by ignoring the same "from" navigation actions, which means they have been triggered in the same component instance.
This should work because every navigated screen, even the same screen, has its unique own and child component instances and it cannot navigate to itself.
First, override the navigate and back action creators.
const { navigate, back } = NavigationActions;
NavigationActions.navigate = ({ from, ...options }) => ({ ...navigate(options), from });
NavigationActions.back = ({ from, ...options }) => ({ ...back(options), from });
And, ignore the same "from" navigation actions in a reducer.
const nextState = AppNavigator.router.getStateForAction(action, state);
if (nextState) {
const { from } = action;
nextState.from = from;
if (from && from === state.from) {
// navigation from the same component instance
return state;
}
}
Then, in any component, dispatch navigation actions like this..
// Navigation
dispatch(NavigationActions.navigate({ routeName: 'ScreenName', from: this }));
// Back
dispatch(NavigationActions.back({ from: this }));
I hope this is helpful.
I get code from @nihgwu and edit to:
import {StackBaseApp} from '../Navigation';
export const NavReducer = (state: any, action: any) => {
if (action.type.startsWith('Navigation/')) {
const {routeName} = action;
const lastRoute = state.routes[state.routes.length - 1];
if (routeName == lastRoute.routeName) return state
}
const nextState = StackBaseApp.router.getStateForAction(action, state);
return nextState || state
};
It's works! :)
Why is it closed?
This behavior is clearly a π for a navigation library.
The solution @Doko-Demo-Doa suggested (where he edits the lib) doesn't seem to work for me.
Does anyone know of an updated solution to edit the lib and just add a delay / debounce for like 500 ms every time I use navigate?
Also, why was this closed if it's still an issue? or is this considered a clicking issue and not a react-navigation issue?
Any progress for this issue officially?
+1 for an official solution
I am having the same issue, but was playing around on a production applications from very large unnamed company that I believe uses React Native (Think Multiple Billion in Market Cap), and was able to reproduce the issue on there (and from my understanding they are using a different nav library); so I do not think the issue is specific to solely react-navigation, and may be more broad common than we think across various libs.
However that being said I would love an official solution, but in the interim I will post what I end up deciding on for anyone's future reference, but I think most of the redux solutions above look pretty good (I am not a fan of the timers personally but that is just personal opinion I am sure it works great).
I am looking to see if I can do anything with the onTransitionStart onTransitionEnd functions for those of us who are not currently routing all our navigation actions through redux, but ultimately this looks like the redux solution may be best option.
@phumaster This solution works!