React-native: [Navigator] Binding the navigation bar with the underlying scene

Created on 9 Sep 2015  ·  25Comments  ·  Source: facebook/react-native

In iOS, we can change title and buttons of a navigation bar from the view controller, e.g. via the navigationItem property. This is useful, for example, to let the view controller itself handle the right/left buttons' event.

In React Native, the Navigator's navigationBar is rendered in the container (where <Navigator> is placed) and decoupled from the underlying scene. Buttons event handlers must be defined in the container, where we can only have access to a scene with the ref prop. More scenes there are, more the container becomes complicated. It get even more complex when the buttons handlers depend from the scene's state.

I'd prefer instead to define the navigation bar's buttons (and title) inside the scene itself, e.g. by rendering Navigator.NavigationBar as child of the scene component – but I couldn't get it working.

I wonder then what is the best approach: am I missing the sense of NavigationBar, since it seems designed just to pop/push routes? As alternative I could adopt a special flux store to help the communication between scene and the NavigatorBar's routeMapper, but it seems overly complicated for a common UI element like the Navigator.

Locked

Most helpful comment

Closing this out because we aren't changing the API of Navigator any more.

@maluramichael, you could use a flux library to subscribe your header components to changing data and allow your inner scene to communicate with the header.

All 25 comments

For example, here's a <LoginForm> with a submit() function, and an action button to start the login process from the navigation bar:

My code now looks like:

class Application extends Component {

  render() {

    const navigationBar = (
      <Navigator.NavigationBar routeMapper={{
        RightButton(route, navigator) {
          return (
            <NavButton
              text="Login" 
              onPress={ () => this.refs.loginScene.submit() } 
            />
          );
        }
      }} />
    )

    return (
      <Navigator
        renderScene={ () => <LoginForm ref="loginScene" /> } 
        navigationBar={ navigationBar }
      />
    );
  }

}

cc @ericvicenti @hedgerwang

It would be difficult to put contents of the nav bar within the scene, as they may have different lifespans.

You could try this, where the route owns the button press events:

class LoginRoute {
  constructor() {
    this.eventEmitter = new EventEmitter();
  }
  renderRightButton(navigator) {
    return (
      <NavButton
        text="Login" 
        onPress={ () => this.eventEmitter.emit('loginPress') } 
      />
    );
  }
  renderScene(navigator) {
    return (
      <LoginScene
        routeEvents={this.eventEmitter}
        navigator={navigator}
      />
    );
  }
}

class Application extends Component {
  static navBarRouteMapper = {
    RightButton(route, navigator) {
      return route.renderRightButton(navigator);
    }
  }
  render() {
    return (
      <Navigator
        initialRoute={ new LoginRoute() }
        renderScene={ (route, navigator) => route.renderScene(navigator) } 
        navigationBar={
          <Navigator.NavigationBar
            routeMapper={Application.navBarRouteMapper}
          />
        }
      />
    );
  }
}

Thanks @ericvicenti for the idea, this pattern works much better to split the routes from the navigator container 👍

However, it doesn't help so much to control the navigation bar according to the scene state. Take as example the iOS keyboard's settings: it seems to update the scene state (applying an animation), while changing the navigation bar as well:

qtxyd

The workaround I'd follow is to push the same route with an attribute which say the scene to "animate" its content once it is mounted. The navigation bar animation doesn't play as good as the native one, but I guess I can try to provide a custom one in the configureScene.

I agree that it looks like the state is stored in the scene, but it would actually be stored in the route because the scene is an isolated component.

You're right that the Navigator.NavigationBar isn't nearly as complete as the native one. We could extend it with the Animated API and start giving it more of these features.

@gpbl: I handle this by defining a generic navigationBarRouteMapper that takes attributes out of the route to render the navigation bar. Then, each scene is able to customize its navigation bar in componentWillMount(), which gets called (thankfully) before the navigationBarRouteMapper does its work.

So, for instance, I have a scene that configures its navigation bar like this (in componentWillMount()):

    this.props.route.title = "Scene Title";

    this.props.route.leftButtonText = "Cancel";
    this.props.route.onPressLeftButton = function() {
      this.props.navigator.pop();
    }.bind(this);

    this.props.route.rightButtonText = "Save";
    this.props.route.onPressRightButton = this.save.bind(this);

Then, the functions in the navigationBarRouteMapper look for these attributes in the route. For instance, here's Title():

    Title: function(route) {
      return (
        <Text style={[styles.navBarText, styles.navBarTitleText]}>
          {route.title}
        </Text>
      );
    }

Admittedly, it goes against the grain of React's top-down flow of data: I'm passing data up to the navigationBarRouteMapper through the route. Also, it only works because componentWillMount() gets called before the navigationBarRouteMapper functions.

But, on the flip side, things work mostly as they did in ObjC, when we could manipulate self.navigationController.navigationItem in viewDidLoad(). (I'm currently looking for a way to hide the navigation bar on a per-scene basis, which I don't seem to be able to do at the moment.)

Would be very interested in any feedback on this approach.

if it helps, gb-native-router lets you talk to your navBar from the scene using this.props.setRightProps and this.props.setLeftProps. You can check out index.js to see how it's done if you'd like.

Would be very interested in any feedback on this approach.

@jedlinlau This is interesting, but I really don't want to mutate this.props :smile: Also props are being frozen starting react 0.14

We could extend it with the Animated API and start giving it more of these features.

@ericvicenti Is this something you're waiting for the community to pick up? I've seen a Navigator example in the Animated documentation. I'd be interested in contributing here, to get better animations and to figure out the communication problem between the nav bar and the scene. I do like the route approach!

@ehd: Makes sense. I like @MikaelCarpenter 's suggestion of passing the required callbacks down as props.

Coming in way late on this, but we use events for communicating between the navbar and scene, simple and effective.

If you want to go a little further than events, I encourage you to use a flux architecture, you'll then just have to trigger the correct action in your Navigator or in your content View :smiley:

This is how I solved it, though I'm not really happy with it :confused:

import NavigationBar from 'react-native-navbar'

<Navigator
  initialRoute={{
    Component: InitialComponent,
    navigationBarProps: {
      title: 'First'
    }
  }}
  renderScene={(route, navigator) => {
    const {
      Component,
      passProps,
      navigationBarProps
    } = route

    if (!route.NavigationBar) {
      route.NavigationBar = NavigationBar
    }

    const props = {
      ...this.props,
      ...passProps,
      // XXX: this does not feel right oO
      setNavigationBarProps: props => {
        route.navigationBarProps = {
          ...route.navigationBarProps,
          ...props
        }
        setTimeout(() => this.forceUpdate(), 0)
      }
    }

    return (
      <View style={{flex: 1}}>
        <route.NavigationBar
          {...navigationBarProps}
          navigator={navigator}
          router={route}
          />
        <Component {...props} navigator={navigator} />
      </View>

InitialComponent:

onRenderSecond = () => {
  this.props.navigator.push({
    Component: Second,
    navigationBarProps: {
      title: 'Will be overridden by the component'
    }
  })
}

Second:

componentWillMount () {
  this.props.setNavigationBarProps({
    title: 'Second',
    nextTitle 'Save',
    onNext: () => {
      this.props.onSave(this.state)
      this.props.navigator.pop()
    }
  })

All I want to do is hide the NavigationBar for various components, e.g. Page A doesn't need navbar, but Page B does, what's the easiest way to do that? Can someone give me a code example?

@niftylettuce

What I do now is wrapping the NavigatorNavigationBar:

import React, {
  Navigator,
  PropTypes,
  Component
} from 'react-native'

export default class ExNavigationBar extends Component {
  static propTypes = {
    navState: PropTypes.object.isRequired,
    navigationBarHidden: PropTypes.bool
  }

  // this is important, if this is omitted, the navbar will render the old route again, not the new one
  updateProgress (...args) {
    this.state.navigationBar && this.state.navigationBar.updateProgress(...args)
  }

  setNavigationBarRef = navigationBar => {
    this.setState({
      navigationBar
    })
  }

  render () {
    if (this.props.navState.routeStack.slice(-1)[0].navigationBarHidden === true) {
      return null
    } else {
      return (
        <Navigator.NavigationBar
          ref={this.setNavigationBarRef}
          {...this.props}
          />
      )
    }
  }
}

That I pass to ExNavigator:

<ExNavigator
  {...this.props}
  renderNavigationBar={props => <ExNavigationBar {...props}/> }
  configureScene={route => Navigator.SceneConfigs.FloatFromBottom}
  initialRoute={{
      // this is for hiding the navbar
      navigationBarHidden: true,

      getSceneClass() {
        return require('./HomeScreen');
      },

      getTitle() {
        return 'Home'
      },
  }}
  />

In HomeScreen you can then push a route that does not hide the navbar:

// ...
nextScreen = () => {
  this.navigator.push({
    // could be omitted, since it defaults to false
    navigationBarHidden: false,

    getSceneClass() {
      return require('./NextSreen')
    },

    getTitle() {
      return 'Next Screen'
    }
  })
}
// ...

This is for illustration purposes, use a factory for the route creation. For more information about ExNavigator see the medium post.

If anything is unclear, please feel free to ask. I also wrote a Route class that makes it easier to create routes, defer rendering (like ExNavigator's _LoadingContainer_) and automatically hooks up an event emitter (and also disposes it) so the rendered scene can change the navbar's title, buttons, etc., but at the moment it's still integrated in out app and I haven't had the time to clean it up and put it into a module. But if there is enough interest I might do that next weekend.

What do you guys think about passing down RxObservable(with some action related with Navigation Bar) for the underlying component to subscribe to that observable?

var PlainNavigator = React.createClass({
  ...
  _navBarRouter: {
    Title: (route, navigator, index, navState) => {
     ...
    },
    LeftButton: (route, navigator, index, navState) => {
     ...
     return (<NavigationTextButton
                    buttonText={routes.getCurrentRoute().leftButtonText}
                    onPress={() => navigator.props.leftButtonSubject.onNext(routes)} />);
       ...
    },
    RightButton: (route, navigator, index, navState) => {
      ...
          return (<NavigationTextButton
                    buttonText={routes.getCurrentRoute().rightButtonText}
                    onPress={() => navigator.props.rightButtonSubject.onNext(routes)}/>);
       ...
    }
  },
  _renderScene: function(route, navigator) {
    ...
    <Screen
          //subscribe to these subjects if need to receive left,right button events
          leftButtonSubject={this._leftButtonSubject}
          rightButtonSubject={this._rightButtonSubject}
          routes={routes}
          navigator={navigator}
          api_domain={this.props.api_domain} />
      );
    ...
  },
  _leftButtonSubject: new Rx.Subject(),
  _rightButtonSubject: new Rx.Subject(),
  render: function() {
    return (
      <Navigator
        leftButtonSubject={this._leftButtonSubject}
        rightButtonSubject={this._rightButtonSubject}
        initialRouteStack={this.getInitialRouteStack(this.props.uri)}
        renderScene={this._renderScene}
        navigationBar={
          <Navigator.NavigationBar
            routeMapper={this._navBarRouter}/>
        }
      />
    );
  }
});

Using RxJs, I created RxSubjects so that bottom components of the Navigator can also receive button events by subscribing to the RxSubjects.

I solved this whole thing with a wrapper over RN's Navigator. It is available as an NPM Package here -- https://github.com/rahuljiresal/react-native-rj-navigator

BTW if you are using RN 0.16 you might reach a bug where elements inside navbar are not touchable.
I've fixed it in https://github.com/facebook/react-native/pull/4786

@gpbl I have a scene that configures its navigation bar like this in componentWillMount()

this.props.route.title = "Scene Title";

Then, the functions in the navigationBarRouteMapper

Title: function(route) { return ( <Text style={[styles.navBarText, styles.navBarTitleText]}> {route.title} </Text> ); }

but got an error: this.props attempted to assign to readonly property.

RN: 0.20.0

how to fix it?

@machard it's great ! Thank you !

Hey guys,

I've read through, checked other people's solution and wasn't really happy with with any of them. After some thinking I ended up with this:

var RegistrationPage = React.createClass({

    render: function() {
        return <Text>Done</Text>
    },

    statics: {
        leftButtonMapper: function(route, navigator, index, navState) {
            return <NavigationBackButton onPress={() =>
                navigator.pop()
            } />
        },
    },
})

And my navigation component contains these:

var AuthProcess = React.createClass({

    render: function() {
        return (
            <Navigator
                initialRoute={{page: 'login'}}
                renderScene={this.renderScene}
                navigationBar={this.navigationBar()}
            />
        )
    },

    renderScene: function(route, navigator) {
        // ...
    }

    navigationBar: function() {
        return <Navigator.NavigationBar
            routeMapper={{
                LeftButton: this.leftButtonMapper,
                RightButton: this.rightButtonMapper,
                Title: this.titleMapper
            }}
        />
    },

    leftButtonMapper: function(route, navigator, index, navState) {
        return this.navigatorItem('leftButtonMapper', route, navigator, index, navState);
    },

    rightButtonMapper: function(route, navigator, index, navState) {
        return this.navigatorItem('rightButtonMapper', route, navigator, index, navState);
    },

    titleMapper: function(route, navigator, index, navState) {
        return this.navigatorItem('titleMapper', route, navigator, index, navState);
    },

    navigatorItem: function(functionName, route, navigator, index, navState) {
        let classDefinition;
        classDefinition = this.classForRoute(route);

        if(classDefinition && classDefinition[functionName]) {
            return classDefinition[functionName](route, navigator, index, navState);
        } else {
            return null;
        }
    },

    classForRoute: function(route) {
        switch(route['page']) {
            case 'login':
                return LoginPage;
            case 'signUpWithEmail':
                return RegistrationPage;
            default:
                return null;

        }
    }
})

I'm new to RN, so maybe this is breaking some patterns. I'm quite curious for feedback.

Z.

My solution is instead of storing input data in scene component state, and call setSetate in onChangeText callback function, onChangeText calls a function passed from props which updates the route object. This solution even eliminates the local state in child component, which can then be make pure. However, I do notice lag when typing. Below is some part of my code:

// function to be called by onChangeText
const updateAddItemRoute = (navigator, newProp) => {
  navigator.replace(Object.assign({
    id: 'addItem',
    title: 'Add New Sth',
    text: '',
  }, newProp));
}

// renderScene
case 'addItem':
  return (
    <AddItem
      text={route.text}
      nav={nav}
      updateAddItemRoute={updateAddItemRoute}
    />
  );

// scene component render function
render() {
    const { text, updateAddItemRoute, nav } = this.props;
    return (
      <View style={styles.scene}>
      <Text style={styles.inputLabel}>
      Name
      </Text>
      <TextInput
        style={styles.inputBox}
        onChangeText={(text) => updateAddItemRoute(nav, {text})}
        value={text}
        autoFocus={true}
        placeholder={'Enter Task Name'}
        returnKeyType={'done'}
        />
      </View>
    )
  }

Still working on better solution :)

@pallzoltan This is almost perfect. Now i just need to call methods and setState from within the component. My problem is that its a static so i don't have the correct this context.

Does someone know how i can pass the current instances of the scene to the navigationBarMapper methods?

My app.js

static mappedRoutes = (route)=> {
        const routeMap = new Map([
            [Constants.Routes.CONTACT, Contact],
            [Constants.Routes.DASHBOARD, Dashboard],
            [Constants.Routes.DISCLAIMER, Disclaimer]
        ]);
        if (routeMap.has(route.name)) {
            return routeMap.get(route.name);
        } else {
            return null;
        }
    };

    getNavigatorItem(functionName, route, navigator, index, navState) {
// i need a way to call the 'leftButton' function from the scene instance and not the static leftButton function. How can i access the current rendered scene instance?
        const Route = App.mappedRoutes(route);
        if (Route && Route[functionName]) {
            return Route[functionName](route, navigator, index, navState);
        }
        return null;
    }

    renderScene(route, navigator) {
        const Route = App.mappedRoutes(route);
        if (Route) {
            const selector = Route.selector ? Route.selector : ()=> {
                return {}
            };

            const connectedRoute = connect(selector)(Route);
            return React.createElement(connectedRoute, {...route.passProps, navigator: navigator});
        }

        return <Text>404</Text>;
    }

    renderNavigationBar() {
        if (this.state.navigationBarHidden) {
            return <View/>;
        }

        const navigationBarStyle = {
            backgroundColor: this.state.navigationBarColor
        };

        return (
            <Navigator.NavigationBar
                routeMapper={this.createRouteMapper()}
                style={[Style.navigationBar, navigationBarStyle]}
            />
        );
    }

    mapTitleToRoute(route, navigator, index, navState) {
        return (
            <TextTitlebar title={route.title ? route.title.toUpperCase() : ''}
                          textStyle={[Style.titleBarText, route.titleTextStyle]}
                          containerStyle={[route.titleStyle]}
            />
        );
    }

    mapLeftButtonToRoute(route, navigator, index, navState) {
        if (index > 0) {
            return <BackButton onPress={this.onPressBackButton}/>;
        } else if (index === 0) {
            return <MenuButton onPress={this.onPressMenuButton}/>;
        }
    }

    mapRightButtonToRoute(route, navigator, index, navState) {
        const navigatorItem = this.getNavigatorItem('rightButton', route, navigator, index, navState);

        if (navigatorItem) {
            return navigatorItem;
        }

        switch (route.name) {
            case Routes.SHOPPING:
                return <MenuButton onPress={this.onPressMenuButton}/>;
            default:
                return <View/>;
        }
    }

    createRouteMapper() {
        return {
            LeftButton : this.mapLeftButtonToRoute,
            RightButton: this.mapRightButtonToRoute,
            Title      : this.mapTitleToRoute,
        }
    }

My dashboard component

class Dashboard extends Component {
    constructor(props) {
        super(props);
    }

    render() {
        return (
            <View style={Style.container}>
            </View>
        )
    }

    static leftButton(){
        return <Text>LEFT BUTTON</Text>
    }

    static rightButton(){
        // i want here something like this.setState({foo: 'bar'})
        return <Text>RIGHT BUTTON</Text>
    }

    static selector(state){
        return {
            User: state.User
        }
    }
}

export default Dashboard;

Closing this out because we aren't changing the API of Navigator any more.

@maluramichael, you could use a flux library to subscribe your header components to changing data and allow your inner scene to communicate with the header.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

anchetaWern picture anchetaWern  ·  3Comments

jlongster picture jlongster  ·  3Comments

grabbou picture grabbou  ·  3Comments

madwed picture madwed  ·  3Comments

oney picture oney  ·  3Comments