Skip to content
APPCITORThe Tech Blog
Esc

Reanimated Onboarding journey

May 18, 2023 10 min read

react native reanimated onboarding screen banner
On this page

Hello mobile devs, today I’m gonna build a onboarding screen using React Native Typescript.
In this example mainly we used React Native Reanimated library to manipulate the scenes. I’m not gonna write a huge description about the example, because i’m explaining each and every thing on reanimated deeply.(related to this example)

Quick access to the full code.


Humm, ok then, here is the screen record of working example.



Now you should have an idea what will be the final output,
Let’s start, First of all; create a blank React Native project using npx react-native@latest init AwesomeProject or if you are not much familiar with React Native refer their documentation.

In this project we are using two main 3rd party libraries,

React Native Reanimated , React Native SVG


Ok let’s install the Reanimated animation library from Software mansion
Simply copy and paste this command yarn add react-native-reanimated then you have to do add this into plugins section in the babel.config.js

module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
  plugins: ['react-native-reanimated/plugin'],
};

Then we have to install React Native SVG library, to install this simply run this command yarn add react-native-svg


Bonus Tip: We can use react-native-svg-transformer to import svg files as components in React Native.
if you’re planning to use this library, you can modify the metro.config.js like this

const {getDefaultConfig} = require('metro-config');

module.exports = (async () => {
  const {
    resolver: {sourceExts, assetExts},
  } = await getDefaultConfig();

  return {
    transformer: {
      babelTransformerPath: require.resolve('react-native-svg-transformer'),
      getTransformOptions: async () => ({
        transform: {
          experimentalImportSupport: false,
          inlineRequires: false,
        },
      }),
    },
    resolver: {
      assetExts: assetExts.filter(ext => ext !== 'svg'),
      sourceExts: [...sourceExts, 'svg'],
    },
  };
})();

If you are using Typescript, you have to add declarations.d.ts file to the root directory. and the content should be like this.

declare module '*.svg' {
  import React from 'react';
  import {SvgProps} from 'react-native-svg';
  const content: React.FC<SvgProps>;
  export default content;
}

Alright, Let’s start the coding part.
first we need to create entry point App.tsx

const App = () => {
  const translateX = useSharedValue(0);
  const listRef = useRef(null);
  const [index, setIndex] = useState(0);

  const scrollHandler = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
    translateX.value = event.nativeEvent.contentOffset.x;
    setIndex(
      Number((event.nativeEvent.contentOffset.x / WINDOW_WIDTH).toFixed()),
    );
  };

  const renderItems = ({item, index}: {item: ItemDataType; index: number}) => (
    <FlatListItem item={item} index={index} animatedVal={translateX} />
  );

  const slideToNext = () => {
    const nextSlideIndex = index + 1;
    if (onboardingItems.length - 1 > index) {
      listRef?.current?.scrollToIndex({
        animated: true,
        index: nextSlideIndex,
      });
      setIndex(nextSlideIndex);
    }
  };

  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        ref={listRef}
        data={onboardingItems}
        renderItem={renderItems}
        keyExtractor={item => item.id}
        horizontal
        pagingEnabled
        onScroll={scrollHandler}
        bounces={false}
        scrollEventThrottle={16}
      />
      <FloatingButton onPress={slideToNext} index={index} />
    </SafeAreaView>
  );
};

let’s see whats happening on App.tsx
We have returned a SafeAreaView as the wrapper component, we need this because we need to avoid bottom and top notch areas of the device otherwise our inner components will render below the notch areas.
Next the FlatList, this is nothing but a simple react native FlatList. we have added ref for identify the component, also entered onboardingItems to the data prop;

type ItemDataType = {
  id: string;
  title: string;
  description: string;
  image: string;
};

const onboardingItems: ItemDataType[] = [
  {
    id: '1',
    title: 'First Slide',
    description: 'First slider test description',
    image: 'https://picsum.photos/id/863/1000/1400?grayscale&blur=2',
  },
  {
    id: '2',
    title: 'Second Slide',
    description: 'Second slider test description',
    image: 'https://picsum.photos/id/875/1000/1400?grayscale&blur=2',
  },
  {
    id: '3',
    title: 'Third Slide',
    description: 'Third slider test description',
    image: 'https://picsum.photos/id/873/1000/1400?grayscale&blur=2',
  },
];

And keyExtractor is a very basic props of the FlatList so I’m not gonna explain all these things.
For our requirement we need scroll the UI horizontally because of the we used horizontal prop
pagingEnabled prop used to scroll item by item, this behaviour is similar to paging through a book.
we used bounces={false} prop because, we don’t need to bounce back when the user tries to scroll beyond its boundaries.
scrollEventThrottle prop is related to the scroll event, which means a scroll event will be emitted approximately every 16 milliseconds while scrolling.

Ok, Let me explain other major things of the flat list,

onScroll event, when scrolling the flat list we need to store the x position of the item and we need to store the current index on the state.
translateX.value = event.nativeEvent.contentOffset.x;
setIndex(Number((event.nativeEvent.contentOffset.x / WINDOW_WIDTH).toFixed()));

renderItem we use this to render single item, in this example we need to render,
<FlatListItem item={item} index={index} animatedVal={translateX} />
using this renderItem method. we need to pass item={item} index={index} animatedVal={translateX} to FlatListItem component.

slideToNext this is a custom method created by me to change the slide index when pressing the floating button, we need to pass this function as a prop to the FloatingButton component, when pressing the button it will call the scrollToIndex method of the flatlist, we can use the ref to do this. listRef?.current?.scrollToIndex, and we store the current index on the state.

thats all about the flat list, and i’ll explain the FloatingButton component on the end of the article.


Let me explain the FlatListItem component first.

const FlatListItem = ({
  item,
  index,
  animatedVal,
}: {
  item: ItemDataType;
  index: number;
  animatedVal: SharedValue<number>;
}) => {
  const inputRange = [
    (index - 1) * WINDOW_WIDTH,
    index * WINDOW_WIDTH,
    (index + 1) * WINDOW_WIDTH,
  ];

  const animatedItemStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateY: interpolate(animatedVal.value, inputRange, [100, 0, 100]),
        },
      ],
      opacity: interpolate(animatedVal.value, inputRange, [0, 1, 0]),
    };
  });

  return (
    <View style={[styles.itemContainer]}>
      <Animated.Image
        source={{uri: item.image}}
        style={[styles.image, animatedItemStyle]}
      />
      <Text style={styles.title}>{item.title}</Text>
      <Text style={styles.description}>{item.description}</Text>
    </View>
  );
};

In this component we need item prop to pass the onboardingItems items,
And we need index, animatedVal to animate the item component. I think you already saw the final result. so as you see, when scrolling the UI, images are moving slightly on the y axis and opacity is changing a bit, for that first we need to make the normal image component to Animated.Image component. then we need to add the animated styles to the component.

For that we can use useAnimatedStyle to calculate the animating things.
When considering the movement of the y axis, we can transform the object using these props.

transform: [
        {
          translateY: interpolate(animatedVal.value, inputRange, [100, 0, 100]),
        },
      ],

in the above code we use interpolate function to do the movement, we have to pass three props to the interpolate function.
first the shared value, for that we are using the animatedVal.value, interpolate function will trigger when changing the animatedVal.value, simply, this is the current progress of the animation.

next, inputRange, this is a array of input values that correspond to the range of the animatedVal that we want to interpolate.
next, the outputRange, [100, 0, 100] are the output range values corresponding to the input range values. Each value in the output range corresponds to the respective value in the input range.
As a summary,
interpolate function essentially maps the input range values to the output range values based on the current value of the animatedVal. This allows for smooth transitions and animations between the defined input and output ranges.

Changing opacity also the same,
opacity: interpolate(animatedVal.value, inputRange, [0, 1, 0])
I think it’s not necessary to explain this also.


Let me explain the FloatingButton component.
This is the code of the floating button component,

const RADIUS = 63;
const AnimatedCircle = Animated.createAnimatedComponent(Circle);

const FloatingButton = ({
  onPress,
  index,
}: {
  onPress: () => void;
  index: number;
}) => {
  const animatedCircleProps = useAnimatedProps(() => {
    return {
      strokeDashoffset: withTiming(
        2 * Math.PI * RADIUS -
          2 * Math.PI * RADIUS * ((index + 1) / onboardingItems.length),
      ),
    };
  });

  return (
    <View style={styles.floatingButton}>
      <Svg height={RADIUS} width={RADIUS} viewBox="0 0 136 136">
        <G rotation={'-90'} origin={'68,68'}>
          <Circle
            cx="50%"
            cy="50%"
            r={RADIUS}
            stroke="#A5D7E8"
            strokeWidth="8"
            fill="transparent"
          />
          <AnimatedCircle
            cx="50%"
            cy="50%"
            r={RADIUS}
            stroke="#0B2447"
            strokeWidth="8"
            fill="transparent"
            strokeDasharray={2 * Math.PI * RADIUS}
            animatedProps={animatedCircleProps}
            strokeLinecap="round"
          />
        </G>
      </Svg>
      <TouchableOpacity onPress={onPress} style={styles.floatingBtnIcon}>
        <Arrow />
      </TouchableOpacity>
    </View>
  );
};

Ok let’s see, what is happening in here,
we have declared the circle radius first, then we have use this statement, Animated.createAnimatedComponent(Circle), we use this because, normally Reanimated library is not supporting for each and every component, By creating an animated version of a component, you gain access to additional animated features and capabilities provided by the Reanimated library.

We need to create two separate Circle components, one is for the background and other is for the animating purpose.
In this example i’m not gonna explain how to create SVG components, because this article is related to the Reanimated animations.
I’ll create another separate article for that.

For now we only needs to understand the animatedProps that we used in here.

In this case, we need to specifically change the strokeDashoffset of the AnimatedCircle component,
when considering the requirement we have to go with useAnimatedProps hook.

The main thing we should learn from this place is,
useAnimatedProps animates component props, while useAnimatedStyle animates component styles.

  const animatedCircleProps = useAnimatedProps(() => {
    return {
      strokeDashoffset: withTiming(
        2 * Math.PI * RADIUS -
          2 * Math.PI * RADIUS * ((index + 1) / onboardingItems.length),
      ),
    };
  });

We can use this logic to calculate the filled area of the circle, 2 * Math.PI * RADIUS - 2 * Math.PI * RADIUS * ((index + 1) / onboardingItems.length))
But If we do it immediately it will not be nice to see, so we can give little slowness for that thing,
That’s why we used withTiming, it will add extra smoothness to the transition, we can add custom configurations for this, but now we do not need to add custom configurations.


After a huge type session we came to the end.
What do you think about this, mobile devs. let me know what do you need to know in further articles.

I’m planning to publish animation article series. let me know your thoughts.

Another smooth animation: FlatList animation

Thank you.

Full code

import React, {useRef, useState} from 'react';
import {
  Dimensions,
  FlatList,
  NativeScrollEvent,
  NativeSyntheticEvent,
  SafeAreaView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from 'react-native';
import Animated, {
  SharedValue,
  interpolate,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

import Arrow from './images/arrow-forward.svg';
import Svg, {Circle, G} from 'react-native-svg';

type ItemDataType = {
  id: string;
  title: string;
  description: string;
  image: string;
};

const onboardingItems: ItemDataType[] = [
  {
    id: '1',
    title: 'First Slide',
    description: 'First slider test description',
    image: 'https://picsum.photos/id/863/1000/1400?grayscale&blur=2',
  },
  {
    id: '2',
    title: 'Second Slide',
    description: 'Second slider test description',
    image: 'https://picsum.photos/id/875/1000/1400?grayscale&blur=2',
  },
  {
    id: '3',
    title: 'Third Slide',
    description: 'Third slider test description',
    image: 'https://picsum.photos/id/873/1000/1400?grayscale&blur=2',
  },
];

const WINDOW_WIDTH = Dimensions.get('window').width;
const RADIUS = 63;

// floating navigation button
const AnimatedCircle = Animated.createAnimatedComponent(Circle);

const FloatingButton = ({
  onPress,
  index,
}: {
  onPress: () => void;
  index: number;
}) => {
  const animatedCircleProps = useAnimatedProps(() => {
    return {
      strokeDashoffset: withTiming(
        2 * Math.PI * RADIUS -
          2 * Math.PI * RADIUS * ((index + 1) / onboardingItems.length),
      ),
    };
  });

  return (
    <View style={styles.floatingButton}>
      <Svg height={RADIUS} width={RADIUS} viewBox="0 0 136 136">
        <G rotation={'-90'} origin={'68,68'}>
          <Circle
            cx="50%"
            cy="50%"
            r={RADIUS}
            stroke="#A5D7E8"
            strokeWidth="8"
            fill="transparent"
          />
          <AnimatedCircle
            cx="50%"
            cy="50%"
            r={RADIUS}
            stroke="#0B2447"
            strokeWidth="8"
            fill="transparent"
            strokeDasharray={2 * Math.PI * RADIUS}
            animatedProps={animatedCircleProps}
            strokeLinecap="round"
          />
        </G>
      </Svg>
      <TouchableOpacity onPress={onPress} style={styles.floatingBtnIcon}>
        <Arrow />
      </TouchableOpacity>
    </View>
  );
};

// flatlist item component
const FlatListItem = ({
  item,
  index,
  animatedVal,
}: {
  item: ItemDataType;
  index: number;
  animatedVal: SharedValue<number>;
}) => {
  const inputRange = [
    (index - 1) * WINDOW_WIDTH,
    index * WINDOW_WIDTH,
    (index + 1) * WINDOW_WIDTH,
  ];

  const animatedItemStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateY: interpolate(animatedVal.value, inputRange, [100, 0, 100]),
        },
      ],
      opacity: interpolate(animatedVal.value, inputRange, [0, 1, 0]),
    };
  });

  return (
    <View style={[styles.itemContainer]}>
      <Animated.Image
        source={{uri: item.image}}
        style={[styles.image, animatedItemStyle]}
      />
      <Text style={styles.title}>{item.title}</Text>
      <Text style={styles.description}>{item.description}</Text>
    </View>
  );
};

// main component contains the flat list
const App = () => {
  const translateX = useSharedValue(0);
  const listRef = useRef(null);
  const [index, setIndex] = useState(0);

  const scrollHandler = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
    translateX.value = event.nativeEvent.contentOffset.x;
    setIndex(
      Number((event.nativeEvent.contentOffset.x / WINDOW_WIDTH).toFixed()),
    );
  };

  const renderItems = ({item, index}: {item: ItemDataType; index: number}) => (
    <FlatListItem item={item} index={index} animatedVal={translateX} />
  );

  const slideToNext = () => {
    const nextSlideIndex = index + 1;
    if (onboardingItems.length - 1 > index) {
      listRef?.current?.scrollToIndex({
        animated: true,
        index: nextSlideIndex,
      });
      setIndex(nextSlideIndex);
    }
  };

  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        ref={listRef}
        data={onboardingItems}
        renderItem={renderItems}
        keyExtractor={item => item.id}
        horizontal
        pagingEnabled
        onScroll={scrollHandler}
        bounces={false}
        scrollEventThrottle={16}
      />
      <FloatingButton onPress={slideToNext} index={index} />
    </SafeAreaView>
  );
};

export default App;

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
  },
  description: {
    fontSize: 14,
  },
  itemContainer: {
    flex: 1,
    width: WINDOW_WIDTH,
    alignItems: 'center',
    justifyContent: 'center',
  },
  floatingButton: {
    position: 'absolute',
    bottom: 50,
    right: 30,
    backgroundColor: '#0B2447',
    width: 50,
    height: 50,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 100,
  },
  image: {
    height: '150%',
    width: '100%',
    position: 'absolute',
  },
  floatingBtnIcon: {position: 'absolute'},
});

Share this article

Keep reading