Search code examples
react-nativeanimationcss-animationsreact-native-reanimated

Animating a paginator indicator into a button


I have been developing a personal application to build a finance app. At the moment I'm creating an Onboarding screen, with is successfully working. Although I want to add some styles to it, I have created an animated paginator, but I want to make the last page indicator turn into a Touchable button.
At the moment the paginator looks like this: enter image description here

When it reaches the last one: enter image description here

I want that last animation turn into a button.
This is my code for Paginator:

import React from 'react';
import { 
    Container,
    CurrentSelectedPageIndicator,
    ButtonContainer
} from './styles';
import { useWindowDimensions } from 'react-native';

interface PaginatorProps {
    data: any;
    scrollX: any;
    currentIndex: any;
}

export function Paginator({ data, scrollX, currentIndex }: PaginatorProps){

    const { width } = useWindowDimensions();

    return (
        <Container>
            {data.map((_: any, index: any) => {
                const inputRange = [(index - 1) * width, index * width, (index + 1) * width];

                let dotWidth = scrollX.interpolate({
                    inputRange,
                    outputRange: [10, 20, 10],
                    extrapolate: 'clamp'
                });

                const opacity = scrollX.interpolate({
                    inputRange,
                    outputRange: [0.3, 1, 0.3],
                    extrapolate: 'clamp'
                });     

                if (currentIndex.toString() === '2') {
                    dotWidth = scrollX.interpolate({
                        [1,2,3],
                        outputRange: [10, 20, 10],
                        extrapolate: 'clamp'
                    });
                }

                return <CurrentSelectedPageIndicator key={index.toString()} style={{ width: dotWidth, opacity }} />;
            })}
        </Container>
    );
}

Styles:

import { RFValue } from "react-native-responsive-fontsize";
import styled from "styled-components/native";
import { Animated } from 'react-native';

export const Container = styled.View`
    flex-direction: row;
    height: ${RFValue(64)}px;
`;

export const CurrentSelectedPageIndicator = styled(Animated.View).attrs({
    shadowOffset: { width: 1, height: 3 }
})`
    shadow-color: ${({ theme }) => theme.colors.text_dark };
    elevation: 1;
  shadow-opacity: 0.3;
  shadow-radius: 1px;
    height: ${RFValue(10)}px;
    width: ${RFValue(10)}px;
    border-radius: 10px;
    background-color: ${({ theme }) => theme.colors.blue };
    margin-horizontal: ${RFValue(8)}px;
`;

export const ButtonContainer = styled(Animated.View)`
    width: 100%;
    height: ${RFValue(50)}px;
    background-color: ${({ theme }) => theme.colors.blue};
    border-radius: 10px;
    align-items: center;
    justify-content: center;
`;

export const ButtonTitle = styled.Text`
    font-family: ${({ theme }) => theme.fonts.medium};
    font-size: ${RFValue(14)}px;
    color: ${({ theme }) => theme.colors.shapeColor};
`;

I tried implementing this logic, but there was no animation. Of course.
I want it to turn into something like this: enter image description here This is the page with calls the paginator:

import React, { useState, useRef } from 'react';
import { 
    Container,
    FlatListContainer
} from './styles';
import {
    FlatList,
    Animated
} from 'react-native'
import OnboardingData from '../../utils/onboarding';
import { OnboardingItem } from '../../components/OnboardingItem';
import { Paginator } from '../../components/Paginator';

export function Onboarding(){

    const [currentIndex, setCurrentIndex] = useState(0);
    const scrollX = useRef(new Animated.Value(0)).current;
    const onboardingDataRef = useRef(null);

    const viewableItemsChanged = useRef(({ viewableItems }: any) => {
        setCurrentIndex(viewableItems[0].index);
    }).current;

    const viewConfig = useRef({ viewAreaCoveragePercentThreshold: 50 }).current;

    return (
        <Container>
            <FlatListContainer>
                <FlatList 
                    data={OnboardingData} 
                    renderItem={({ item }) => <OnboardingItem image={item.image} title={item.title} description={item.description}/>}
                    horizontal
                    showsHorizontalScrollIndicator={false}
                    pagingEnabled={true}
                    bounces={false}
                    keyExtractor={(item) => String(item.id)}
                    onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } }}], {
                        useNativeDriver: false
                    })}
                    scrollEventThrottle={32}
                    onViewableItemsChanged={viewableItemsChanged}
                    viewabilityConfig={viewConfig}
                    ref={onboardingDataRef}
                />
            </FlatListContainer>

            <Paginator data={OnboardingData} scrollX={scrollX} currentIndex={currentIndex}/>
        </Container>
    );
}

Formation mistake:
enter image description here


Solution

  • The key points were:

    1. When we scroll from n-1th to nth page,
      1. All indicators except nth need to be adjusted. The adjustment could be either of
        1. Shrink content+margin of all other indicators to 0 width. ( preferred )
        2. Move all indicators to left by calculated amount.
      2. The nth element should grow to occupy full width. The contents should also change opacity from 0 to 1.

    With this points in mind, it should be easy to understand following changes in Paginator code.

    import React from 'react';
    import {
      Container,
      CurrentSelectedPageIndicator,
      ButtonContainer,
      ButtonTitle,
    } from './styles';
    import { useWindowDimensions } from 'react-native';
    import { RFValue } from 'react-native-responsive-fontsize';
    interface PaginatorProps {
      data: any;
      scrollX: any;
      currentIndex: any;
    }
    
    const inactiveSize = RFValue(10)
    const activeSize = RFValue(20)
    
    export function Paginator({ data, scrollX, currentIndex }: PaginatorProps) {
      const { width } = useWindowDimensions();
    
      return (
        <Container>
          {data.map((_: any, index: any) => {
            const inputRange = Array(data.length)
              .fill(0)
              .map((_, i) => i * width);
            const isLastElement = index === data.length - 1;
            const widthRange = Array(data.length)
              .fill(inactiveSize)
              .map((v, i) => {
                if (i === data.length - 1) {
                  if (isLastElement) return width;
                  return 0;
                }
                if (i === index) return activeSize;
                return v;
              });
            // optionally, reduce the length of inputRange & widthRange
            // while loop may be removed
            let i = 0;
            while (i < inputRange.length - 1) {
              if (widthRange[i] === widthRange[i + 1]) {
                let toRemove = -1;
                if (i === 0) toRemove = i;
                else if (i === inputRange.length - 2) toRemove = i + 1;
                else if (
                  i < inputRange.length - 2 &&
                  widthRange[i] === widthRange[i + 2]
                )
                  toRemove = i + 1;
                if (toRemove > -1) {
                  inputRange.splice(toRemove, 1);
                  widthRange.splice(toRemove, 1);
                  continue;
                }
              }
              i++;
            }
            console.log(index, inputRange, widthRange);
            let height = inactiveSize;
            let buttonOpacity = 0;
            let dotWidth = scrollX.interpolate({
              inputRange,
              outputRange: widthRange,
              extrapolate: 'clamp',
            });
    
            const opacity = scrollX.interpolate({
              inputRange,
              outputRange: widthRange.map((v) => ( v? v >= activeSize ? 1 : 0.3: 0)),
              extrapolate: 'clamp',
            });
    
            if (isLastElement) {
              dotWidth = scrollX.interpolate({
                inputRange,
                outputRange: widthRange,
                extrapolate: 'clamp',
              });
              height = dotWidth.interpolate({
                inputRange: [inactiveSize, width],
                outputRange: [inactiveSize, RFValue(50)],
                extrapolate: 'clamp',
              });
              buttonOpacity = dotWidth.interpolate({
                inputRange: [inactiveSize, width],
                outputRange: [0, 1],
                extrapolate: 'clamp',
              });
            }
            const marginHorizontal = dotWidth.interpolate({
              inputRange: [0, inactiveSize],
              outputRange: [0, RFValue(8)],
              extrapolate: 'clamp',
            });
    
            return (
              <CurrentSelectedPageIndicator
                key={index.toString()}
                style={{ width: dotWidth, opacity, marginHorizontal, height }}>
                {isLastElement && (
                  <ButtonContainer
                    style={{ opacity: buttonOpacity, backgroundColor: '#5636D3' }}>
                    <ButtonTitle style={{ color: 'white' }}>NEXT</ButtonTitle>
                  </ButtonContainer>
                )}
              </CurrentSelectedPageIndicator>
            );
          })}
        </Container>
      );
    }