How we deal with the animation at LoMoStar

Thomas Gazzoni

9 min read

The advantages of using React Native nowadays are quite obvious, one code base, same JavaScript language and web development concept applied the native app platform, including animation to some extent.

Final result

Table of Contents#

What can and should animate#

Like the web, we shouldn't animate any of the available styles property, but animate only the non-layout properties to avoid repainting like: opacity and transform (scale, translate, rotate).

Animating only those property allow us to take advantage of the native engine, by setting useNativeDriver: true to the animation config. This tells React Native to send the animation to the native side, avoiding the continuously communication between the JS thread and the Native thread thus avoid blocking UI and skipped frames.

If we need to animate, for example, a backgroundColor instead, we can't take advantage of the native animation (useNativeDriver) and the animation might result a bit laggy but we could achieve the same result by animating the opacity of a View instead, by using position absolute with zIndex: -1 and different backgroundColor.

Another thing to remember is that for all the components that we want animate or the one that are triggering an animation, we need to use the Animated version of the it. For example, for View we need to use Animated.View, for Text we use Animated.Text, and so on. If react native doesn't provide the Animated version for the component out of the box, we can create one by using the createAnimatedComponent function like this:

import {
  Animated,
  FlatList,
} from 'react-native';

const FlatListAnimated = Animated.createAnimatedComponent(FlatList);

export FlatListAnimated;

LoMoStar Header Slide Up Animation#

Now that we have some basics about animations, let's have a look at how we automatically handle the LoMoStar Header animation in any pages without the need to re-write the code every time.

In LoMoStar every page has a Container, Header and Content.

  render() {
    return (
      <Container>
        <Header title="Page Title" />
        <Content>
          <Text>Page content</Text>
        </Content>
      </Container>
    );
  }

The scrolling component (ether a ViewScroll or FlatList) is placed inside the page Content, so if we want to animate the Header we need to have a way that automatically propagate the scrolling event from the Content to the Header.

By making this 3 components working together we can have the Header slide up effect out of the box without the need to attach onScroll listener to a FlatList/ScrollView in every page.

Let's see then how those 3 components are implemented.

Container#

The best place to make all the three part (Container, Header, Content) working together is the Container, that for this propose will act as a bridge.

We will traverse all the children of the Container to find the Header and Content.

For the Header we keep a reference.

if (childType && childType.displayName === 'Header') {
  return React.cloneElement<any>(child, { ref: this.setHeaderRef });
}

For the Content we set a callback to our custom onScroll event that will tell us when the page is scrolling

if (childType && childType.displayName === 'Content') {
  return React.cloneElement<any>(child, {
    ...child.props,
    onScroll: this.setHeaderScrollPosition,
  });
}

The onScroll callback will then set in the Header a reference of the scrollY Animated Value, since is a reference we just need to set it once, the Header will keep track of further scroll changes.

// Function called by the Content onScroll callback only once
setHeaderScrollPosition = (scrollY: Animated.Value) => {
  if (this.headerRef && !this.headerScrollNotified) {
    this.headerRef.setHeaderScrollPosition(scrollY);
    this.headerScrollNotified = true;
  }
};

The Content#

Let's see then how the Content component custom onScroll event is implemented.

The Content is just a wrapper for any pages main content, it can be a simple View (if no scrolling is needed) or a ScrollView if we need the entire page to scroll, this mode is controlled by the scrollable prop.

If the Content is scrollable, we just attach the onScroll listener to the Content's ScrollView component.

Since in the Content children we can have a FlatList, we need to use the FlatList onScroll instead of the ScrollView one, thus we need to parse the children, find the FlatList and attach a onScroll listener to it.

Since android doesn't support two scrolling component together in the same page, if the Content has a FlatList in is children, the Content can't be ScrollView (scrollable must be false).

Since the onScroll is needed for handle animations, the FlatList and ScrollView must be an Animated FlatList and Animated ScrollView respectively.

For animation purpose React Native has a special syntax to extract an Animated.Value directly from the onScroll event:

onScroll: Animated.event(
  [{ nativeEvent: { contentOffset: { y: this.scrollY } } }],
  {
    useNativeDriver: true,
    listener: this.updateScrollPosition,
  },
);

If instead of this syntax we have mapped an animated scroll event (onScroll) into the state, on every scroll, the render method will be called, leading to an unnecessary overhead.

Using this syntax instead we directly obtain an Animated.Value and set it in the scrollY variable. Farther more in this case we use a class variable this.scrollY instead of a state variable because in this component we don't need to animate anything. We just use a listener (updateScrollPosition) to set in the Header component a reference to the scrollY Animated.Value so the header will know of any changes of the Y position.

Another thing to notice are the contentContainerStyle and scrollIndicatorInsets props. Since the Header position is set to absolute (due to the animation requirements) we need to move down the content of the ScrollView and FlatList, we do by adjusting the FlatList and ScrollView styles with a paddingTop.

The Header#

The Header is where all the visual animations are taking place.

In LoMoStar the Header is quite height (108px), in order to make more space for the Content we want to make the header half of is height, 54px, when the user scroll down the page.

For convenience we set two constants HEADER_MIN_HEIGHT and HEADER_MAX_HEIGHT, the first is the minimum height of the Header when we scroll down pass the HEADER_MAX_HEIGHT point, while the second is the normal Header height before we scroll do any scrolling.

NOTE: this Header can handle different modes, like half header (headerTop), full header (headerBottom) header with filters (headerFilters), header actions, etc, we just consider the full header mode that is equal to the HEADER_MAX_HEIGHT.

The first step we need to do is to interpolate, thus maps input ranges to output ranges. In this case we first map the input from 0 (top) to HEADER_MAX_HEIGHT to the output of 0 to 1, this is done to normalize the range and make the sequential animation run with the same pace.

const animationRange = scrollY.interpolate({
  inputRange: [0, headerHeight],
  outputRange: [0, 1],
  extrapolate: 'clamp',
});

Then we create a transform style and set it to the variable animateY. Using the previously created range (0-1) we will translate the Y axis up (using a negative value) to half the HEADER_HEIGHT. We use extrapolate: clamp since we want the animation to stop as soon it reached the two extremes (the output ranges).

We also pass the animationRange to the AnimatedText component to perform some animation to the title while scrolling (we will see it later in the HeaderTitle).

Finally we apply the animateY to the Header main View, the result when scrolling is the Header moving up, out of the screen, by half of it's height leaving half of it visible.

To note that those two function renderBackButton and renderHeaderActions, that respectively will render the page back button and right actions if any, are placed outside the Header main View, we did this on propose since we don't want to hide those buttons when we move up the header.

The Header Title#

Finally we have a look at the HeaderTitle component that is in charge of displaying the page Title. This component need an animatedRange that is the one we create in the Header (with the range go from 0 to 1) and a title.

We need to animate the Title because the Back button is in a fixed position on the top left, so when we scroll down the page and move up the Header, we need to move the title slightly to the right. We also make the title smaller (by scaling down) to have more space for a eventual action button on the right and apply a little fade effect to it.

To achieve those animations we need to measure the sizes of the container View and the title Text.

To calculate the transition to the left we need the container View's starting X point to then subtract the width of the back button (in this case 48px) and, since we apply a scaling to the title Text to make it smaller, we need to compensate this by subtracting a horizontalScalingDiff.

To calculate the horizontalScalingDiff we need the width of the Text element, from were we take a 20% (0.2) of this width and divided by two (since both side, left and right, are shirking).

const scaleTo = 0.8;
const complementScaling = 1 - scaleTo; // 0.2
const horizontalScalingDiff = (complementScaling * complementScaling) / 2;

We also animate the opacity, by decreasing it while we move the text up on the right till 0.2 and bring it back to 1 when the animationRange get to 1 (we reach the HEADER_HEIGHT / 2)

Tips for smooth animations#

Always delay loading async data after the animation finished to run (including the react-navigation page slide in transition), by using one of those methods: setTimeout, requestAnimationFrame or the InteractionManager.

 componentDidMount() {

    requestAnimationFrame(this.loadData);

    setTimeout(this.loadData, 200);

    InteractionManager.runAfterInteractions(this.loadData);
  }

  loadData() {
    fetch('https://api.com/getList');
  }

Most of the time requestAnimationFrame will be the right choice, in any way by doing so we will not block the main thread and the animation will run smoothly. Only after the next available time frame/tick we will execute the loadData function and load the data, for example, from an API.


Subscribe to the newsletter