satya164 / react-native-tab-view

A cross-platform Tab View component for React Native
MIT License
5.14k stars 1.07k forks source link

Long delay between tab transition end and tab header update #81

Closed viettranme closed 7 years ago

viettranme commented 7 years ago

Hello,

In the example below, I use two methods for tab navigation:

These two methods show different user experiences.

Note: Tested on a physical Android device via 'react-native run-android'

Due to this issue, now I have to disable tab view swiping.

Please advice how I can fix it.

class ExampleOfLongDelay extends React.Component {
  state = {
    index: 0,
    routes: [
      { key: '1', title: 'First', icon: 'ios-book' },
      { key: '2', title: 'Second', icon: 'ios-chatboxes' },
      { key: '3', title: 'Third', icon: 'ios-paper' },
    ],
  };

  _onChangeTabIndex = (index) => {
    this.setState({
      index,
    });
  };

  _renderLabel = ({ navigationState }) => ({ route, index }) => { 
    const selected = navigationState.index === index;

    return (
      <Text style={[ styles.label, selected ? styles.selected : styles.idle ]}>
        {route.title}
      </Text>
    );
  };

  _renderIcon = ({ navigationState }) => ({ route, index }: any) => {
    const selected = navigationState.index === index;

    return (
      <Icon
        name={selected ? route.icon : route.icon + '-outline'}
        size={24}
        style={[ selected ? styles.selected : styles.idle ]}
      />
    );
  };

   _renderPager = (props) => {
    return <TabViewPagerPan {...props} swipeEnabled={true} />;
  };

  _renderHeader = (props) => {
    return (
      <TabBarTop 
        {...props} 
        renderIcon={this._renderIcon(props)}
        renderLabel={this._renderLabel(props)}
        jumpToIndex={this._onChangeTabIndex}
        style={styles.tabbar} 
        activeOpacity={1}
      />
    )
  };

  _renderScene = ({route}) => {
    switch (route.key) {
      case '1':
        return <View style={[ styles.page, { backgroundColor: '#ff4081' } ]} />;
      case '2':
        return <View style={[ styles.page, { backgroundColor: '#673ab7' } ]} />;
      case '3':
        return <View style={[ styles.page, { backgroundColor: '#4caf50' } ]} />;
      default:
        return null;
    }
  };

  render() {
    return (
      <View style={styles.container}>
        <TabViewAnimated
          style={{ flex: 1 }}
          navigationState={this.state}
          renderScene={this._renderScene}
          renderHeader={this._renderHeader}
          renderPager={this._renderPager}
          onRequestChangeTab={this._onChangeTabIndex}
          initialLayout={initialLayout}
        />        
      </View>
    );
  }
}

var styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  page: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },  
  tabbar: {
    backgroundColor: '#008CC9',
  },
 idle: {
    backgroundColor: 'transparent',
    color: 'white',
  },
  selected: {
    backgroundColor: 'transparent',
    color: 'white',
  },
});

Thanks

satya164 commented 7 years ago

Tab transitions use a spring animations which takes some time to settle. The navigation state is not updated until the animation finishes.

If you want to style the selected tab differently, you should use the animated position prop which is passed into the callback. This way the text color/background color will change from one to the other while swiping.

Here's a simple example which changes the text color on tab change - https://github.com/react-native-community/react-native-tab-view/blob/master/example/src/ScrollViewsExample.js#L83

viettranme commented 7 years ago

@satya164: I know the navigation state is not updated until the animation finishes. The problem here is that there's quite long delay since the animation finishes til the tab header is updated (for example: it's style in this case; noticable delay changing selected tab icon from outline to solid style). When you run the example you can see the issue there.

satya164 commented 7 years ago

@viettranme The problem is you're relying on navigation state to update the tabs. When swiping, you don't know what's the next tab that'll be selected. We cannot update the navigation state. The tab header is not re-rendered until navigation state changes. We need to wait for the swipe to finish.

Now, we can just update everything when the swipe finishes, but this will cause a frame drop between the end of swipe and start of animation. That's why we wait for the animation to finish. And spring animations take long time to settle down.

The position prop is an animated value which represents the animation position. You can use it in your style like in the example I linked to. Please check the example.

viettranme commented 7 years ago

OK, I see. This is because of "spring animations take long time to settle down" issue.

In my app, there is another bar above the tabbar to show title + custom things related to the current tab. Is it possible to guess the target tab by using the position prop? Thanks

PS: I have tried with this before but encounter max stack calls limit issue as I try to setState inside that amination callback function.

satya164 commented 7 years ago

@viettranme You cannot directly use the position prop, but you can do this,

  1. Create an animated value in the parent const tabPosition = new Animated.Value(0), and pass it down
  2. Update it's value on onChangePosition callback, _handleChangePosition = value => this.props.tabPosition.setValue(value)
viettranme commented 7 years ago

Let me try with your suggestion. Thanks @satya164.

satya164 commented 7 years ago

@viettranme Lemme know.

viettranme commented 7 years ago

@satya164 I have created a wrapper with a state attribute tabPosition and a callback to guess tabPosition based on the animated postion props of the tab view (using Math.round).

const initialLayout = {
  height: 0,
  width: Dimensions.get('window').width,
};

class ExampleOfLongDelayWrapper extends React.Component {
  state = {
    tabPosition: 0
  };

  _handleChangePosition = (value) => {
    let next = Math.round(value);

    if (next != this.state.tabPosition) {
      this.setState({tabPosition: next});
    }  
  }  

  _renderTopBar = () => {
    const routes = [
      { key: '1', title: 'First', icon: 'ios-book' },
      { key: '2', title: 'Second', icon: 'ios-chatboxes' },
      { key: '3', title: 'Third', icon: 'ios-paper' },
    ];

    const title = routes[this.state.tabPosition].title;

    return (
      <View style={styles.headerBarContainer}>
        <Text style={styles.headerText}>{title}</Text>
      </View>
    );
  }

  render() {
    return (
      <View style={styles.container}>
        {this._renderTopBar()}
        <ExampleOfLongDelay
          onChangePosition={this._handleChangePosition}
        />
      </View>
    );  
  }
}

class ExampleOfLongDelay extends React.Component {
  state = {
    index: 0,
    routes: [
      { key: '1', title: 'First', icon: 'ios-book' },
      { key: '2', title: 'Second', icon: 'ios-chatboxes' },
      { key: '3', title: 'Third', icon: 'ios-paper' },
    ],
  };

  shouldComponentUpdate(nextProps, nextState) {
    return this.state != nextState;
  }

  _onChangeTabIndex = (index) => {
    this.setState({
      index,
    });
  };

  _renderLabel = ({ navigationState }) => ({ route, index }) => { 
    const selected = navigationState.index === index;

    return (
      <Text style={[ styles.label, selected ? styles.selected : styles.idle ]}>
        {route.title}
      </Text>
    );
  };

  _renderIcon = ({ navigationState }) => ({ route, index }: any) => {
    const selected = navigationState.index === index;

    return (
      <Icon
        name={selected ? route.icon : route.icon + '-outline'}
        size={24}
        style={[ selected ? styles.selected : styles.idle ]}
      />
    );
  };

   _renderPager = (props) => {
    return <TabViewPagerPan {...props} swipeEnabled={true} />;
  };

  _renderHeader = (props) => {
    return (
      <TabBarTop 
        {...props} 
        renderIcon={this._renderIcon(props)}
        renderLabel={this._renderLabel(props)}
        jumpToIndex={this._onChangeTabIndex}
        style={styles.tabbar} 
        activeOpacity={1}
      />
    )
  };

  _renderScene = ({route}) => {
    switch (route.key) {
      case '1':
        return <View style={[ styles.page, { backgroundColor: '#ff4081' } ]} />;
      case '2':
        return <View style={[ styles.page, { backgroundColor: '#673ab7' } ]} />;
      case '3':
        return <View style={[ styles.page, { backgroundColor: '#4caf50' } ]} />;
      default:
        return null;
    }
  };

  render() {
    return (
      <TabViewAnimated
        style={{ flex: 1 }}
        navigationState={this.state}
        renderScene={this._renderScene}
        renderHeader={this._renderHeader}
        renderPager={this._renderPager}
        onRequestChangeTab={this._onChangeTabIndex}
        initialLayout={initialLayout}
        onChangePosition={this.props.onChangePosition}
      />        
    );
  }
}

The result Now, my custom top bar can be updated very quickly on tab view swiping (as it is re-rendered based on the guessed tab position), so this is OK.

The issue is still with the original tabbar of the component as it still has a lag time. Could I have any option to intervene with the renderHeader callback so that I can use the guessed tab position value to update the tabbar quickly in advance?

satya164 commented 7 years ago

The issue is still with the original tabbar of the component as it still has a lag time

You can use the position prop there to change the styles. For icons you can have one on top of another and cross-fade. Check the example I linked to.

viettranme commented 7 years ago

Thanks @satya164, now I can change the styles of selected tab item. For the icons cross-fade animation, do you have an example somewhere? (I am quite new with react-native animation.) Thanks in advance

satya164 commented 7 years ago

@viettranme Don't have an example, right now, but you can just place 2 icons absolutely positioned above a=one another, and then give them opacity,

    const inputRange = props.navigationState.routes.map((x, i) => i);
    const opacity1 = props.position.interpolate({
      inputRange,
      outputRange: inputRange.map(inputIndex => inputIndex === index ? 1 : 0),
    });
    const opacity2 = props.position.interpolate({
      inputRange,
      outputRange: inputRange.map(inputIndex => inputIndex === index ? 0 : 1),
    });
viettranme commented 7 years ago

Thanks @satya164. It works now.

Just my 2 cents: While it's OK for now with this workaround fix, I am thinking of moving the renderHeader() and renderFooter() codes from the function TabViewAnimated._renderItems() to the function TabViewAnimated.render() so that we can update the tab header/footer before hand during tab view transition based on guessed target tab index.

satya164 commented 7 years ago

Moving the code to render won't matter because a render isn't triggered until state is updated. We avoid updating state between the transition because it makes the animation feel janky, especially on Android.

The proper way is to use position prop if we want good performance. That's what's used internally everywhere.

viettranme commented 7 years ago

Just to clarify my idea as it may be helpful for other readers:

I mean:

  1. In the TabAnimatedView.js, we move the code from the _renderItems() function to the render() function as following:
  _renderItems = (props: SceneRendererProps) => {
    const { renderPager, renderHeader, renderFooter } = this.props;

    return (
      <View style={styles.container}>
        {renderPager({
            ....
        })}
      </View>
    );
  };

  render() {
    return (
      <View style={styles.container}>
        <RENDER_HEADER_CODE>
        <TabViewTransitioner
          {...this.props}
          loaded={this.state.loaded}
          onChangePosition={this._handleChangePosition}
          render={this._renderItems}
        />
        <RENDER_FOOTER_CODE>
      </View>      
    );
  }
  1. The handler _handleChangePosition will use the position prop to guess the target tab position (by using Math.round) and update a state attribute tabIndex

  2. Due to this state change, the and parts in the render() function are re-rendered based on that tabIndex state attribute to reflect header and footer changes immediately; but in the TabViewTransitioner, this change is ignored so that it is not re-rendered, and it can continue its own transition animation.

That's details of my idea. Not sure if there's something wrong with it.

satya164 commented 7 years ago

Closing the issue since this is the expected behaviour. We should document this though to avoid confusion.