Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Navigator] Binding the navigation bar with the underlying scene #2615

Closed
gpbl opened this issue Sep 9, 2015 · 25 comments
Closed

[Navigator] Binding the navigation bar with the underlying scene #2615

gpbl opened this issue Sep 9, 2015 · 25 comments
Labels
Resolution: Locked This issue was locked by the bot.

Comments

@gpbl
Copy link
Contributor

gpbl commented Sep 9, 2015

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.

@gpbl
Copy link
Contributor Author

gpbl commented Sep 9, 2015

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 }
      />
    );
  }

}

@brentvatne
Copy link
Collaborator

cc @ericvicenti @hedgerwang

@gpbl gpbl changed the title [Navigator] How to bind the navigation bar with the underlying scene [Navigator] Binding the navigation bar with the underlying scene Sep 9, 2015
@ericvicenti
Copy link
Contributor

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}
          />
        }
      />
    );
  }
}

@gpbl
Copy link
Contributor Author

gpbl commented Sep 10, 2015

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.

@ericvicenti
Copy link
Contributor

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.

@jedlau
Copy link
Contributor

jedlau commented Sep 11, 2015

@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.

@MikaelCarpenter
Copy link

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.

@seidtgeist
Copy link

Would be very interested in any feedback on this approach.

@jedlinlau This is interesting, but I really don't want to mutate this.props 😄 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!

@jedlau
Copy link
Contributor

jedlau commented Sep 16, 2015

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

@dhrrgn
Copy link
Contributor

dhrrgn commented Sep 19, 2015

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

@ahanriat
Copy link
Contributor

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 😃

@despairblue
Copy link
Contributor

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

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()
    }
  })

@niftylettuce
Copy link
Contributor

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?

@despairblue
Copy link
Contributor

@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.

@jihopark
Copy link

jihopark commented Nov 5, 2015

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?

@jihopark
Copy link

jihopark commented Nov 6, 2015

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.

@rahuljiresal
Copy link

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

@gre
Copy link
Contributor

gre commented Dec 15, 2015

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 #4786

@machard
Copy link
Contributor

machard commented Jan 19, 2016

You can check my solution here:

https://github.com/machard/react-native-advanced-navigation
especially the scene binding example is here https://github.com/machard/react-native-advanced-navigation/blob/master/src/containers/BindingExample.js

@jsntghf
Copy link

jsntghf commented Feb 26, 2016

@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?

@oldashes
Copy link

oldashes commented Jun 1, 2016

@machard it's great ! Thank you !

@pallzoltan
Copy link

pallzoltan commented Jul 14, 2016

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.

@missingtrailingcomma
Copy link

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 :)

@maluramichael
Copy link

@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;

@ericvicenti
Copy link
Contributor

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.

@facebook facebook locked as resolved and limited conversation to collaborators Jul 21, 2018
@react-native-bot react-native-bot added the Resolution: Locked This issue was locked by the bot. label Jul 21, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Resolution: Locked This issue was locked by the bot.
Projects
None yet
Development

No branches or pull requests