The Widlarz Group Blog

Custom color picker animation with React Native Reanimated v2

September 08, 2021

react native reanimated

reanimated

color picker

react native

mobile

Introduction

A while ago, I had the pleasure of implementing a feature that allows users to choose their favorite color which will be associated with their profile.

This article will be dedicated to how I went about creating such a feature and boosting it with some cool animations using Reanimated v2 in React Native.

Article demo animation

Content

Project Setup

To make things easier, I prepared a repository with the starter and the completed code. See link

The master branch contains the final animation, while the starter branch features basic setup for starters.

First things first, let’s clone the repo:

git clone -b starter https://github.com/TheWidlarzGroup/reanimated-color-picker.git

Install packages:

cd reanimated-color-picker && yarn install

For iOS also:

cd ios && pod install

Run the project:

yarn ios
// or
yarn android

At this point the project looks like this, there’s no animation, the bubbles render on top of each other and we can’t move them around just yet.

Before animation demo

Before We Begin

Please note that this project uses TypeScript, however you do not need to be familiar with TypeScript in order to follow and understand the content of this article.

There are two components that we’ll focus on in this article:

  • BubbleContainer - wrapper for our bubbles and drop area.
  • Bubble - each separate bubble.

React Context is used to keep the information about the user’s chosen color available to all the components (See documentation).

All dependencies have already been added to the project, including react-native-reanimated. Refer to the official docs for the installation and configuration guidelines.

Render Bubbles

Let’s start with rendering the bubbles in random positions with a random delay. We can achieve this by implementing two simple functions.

First, to calculate the initial position of each bubble within our screen view.

const randomFromRange = (min: number, max: number) =>
  Math.floor(Math.random() * (max - min + 1) + min);

Then, to calculate render delay of each bubble. Max delay set for this animation is 600ms, feel free to adjust it to your preferences.

const randomDelay = Math.floor(Math.random() * 600);

Let’s move to BubbleContainer.tsx, where we begin with parsing variable COLORS to have an additional property: position. Initially, COLORS is an array of objects in format:

type ColorProps = {
  id: number;
  color: string;
};
const COLORS: ColorProps[] = 
  [
    {
      color: 'red',
      id: 1,
    }
    // ...
  ]

Parsed and saved to state:

// BubbleContainer.tsx

import { useWindowDimensions } from 'react-native';
import { COLORS } from '../utils/mockedColors';
import { CONSTANTS as C } from '../utils/helpers';

type BubbleProps = {
  position: Position;
  id: number | string;
  color: string;
};
type Position = {
  x: number;
  y: number;
};
export const BubbleContainer = () => {
  const { height, width } = useWindowDimensions();
  // ...
  const initBubbles = COLORS.map(color => ({
    ...color,
    position: {
      x: randomFromRange(C.BUBBLES_OFFSET_LEFT, width - C.BUBBLE_SIZE),
      y: randomFromRange(C.BUBBLES_OFFSET_TOP, height - C.BUBBLES_OFFSET_BOTTOM),
    },
  }));

  const [bubbles] = useState<BubbleProps[]>(initBubbles);
  // ...
}

Here’s where our function randomFromRange comes into use. We set the range for x-axis to be from 0px (C.BUBBLES_OFFSET_LEFT) to the width of the window reduced by 56px (C.BUBBLE_SIZE). The range for y-axis varies from 130px (that’s the space that the description message takes - C.BUBBLES_OFFSET_TOP) to the height of the window reduced by the height of the drop area plus the size of the bubble (C.BUBBLES_OFFSET_BOTTOM). By doing so, we prevent rendering any bubble outside of the screen view.

At this point our bubbles render in random positions within the dark grey space.

Now, let’s move to implementing a random render delay.

We move to Bubble.tsx component.

First, we need to change View from react-native to Animated.View from react-native-reanimated and wrap it all with PanGestureHandler from react-native-gesture-handler, so the component looks like this:

// Bubble.tsx

import { PanGestureHandler } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
// ...

return (
  <PanGestureHandler>
    <Animated.View
      style={[
        {
          backgroundColor: color,
        },
      ]}
    />
  </PanGestureHandler>
)

Now, we implement the actual animation and update the return method accordingly.

Here, useAnimatedStyle comes in handy. As per the documentation, it allows to create an association between Shared Values and View properties and subscribes to any changes and styles. It takes in a function as an argument and returns an object with new style properties. We use two animation helpers withDelay and withTiming to smoothly animate any style changes.

To create an impression that the bubbles render asynchronously, their initial size is set to 0px and we animate the change of width, height and borderRadius. This is controlled by the hook useSharedValue that creates the reference to Shared Value, which can then be modified by worklets.

// Bubble.tsx

import { useSharedValue } from 'react-native-reanimated';

const bubbleSize = useSharedValue(0);

To read the data from the Shared Value reference, we use .value attribute.

// Bubble.tsx

import { randomDelay } from '../utils/helpers';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withTiming,
} from 'react-native-reanimated';

type Position = {
  x: number;
  y: number;
};

type BubbleProps = {
  color: string;
  diameter: number;
  position: Position;
  dropAreaTop: number;
};

export const Bubble = ({color, diameter, position, dropAreaTop}: BubbleProps) => {
  const bubbleSize = useSharedValue(0);

  const BubbleStyle = useAnimatedStyle(() => ({
    width: withDelay(
      randomDelay,
      withTiming(bubbleSize.value, {duration: 600}),
    ),
    height: withDelay(
      randomDelay,
      withTiming(bubbleSize.value, {duration: 600}),
    ),
    borderRadius: withDelay(
      randomDelay,
      withTiming(bubbleSize.value, {duration: 600}),
    ),
  }));

  useEffect(() => {
    bubbleSize.value = diameter;
  }, [bubbleSize, diameter]);

  return (
    <PanGestureHandler>
        <Animated.View
          style={[
            BubbleStyle,
            {
              backgroundColor: color,
            },
          ]}
        />
    </PanGestureHandler>
  );
}

At this point our animation looks like this:

Random render of bubbles

The bubbles render in random places with random delay (up to 600ms) but we can’t move them around just yet. Let’s implement that now.

Drag and Drop Bubbles

To allow the user to drag the bubbles around, we need to use another hook useAnimatedGestureHandler. This hook requires react-native-gesture-handler added and configured in our project. Read more

First, we pass onGestureEvent prop to PanGestureHander, like so:

// Bubble.tsx

<PanGestureHandler onGestureEvent={gestureHandler}>
  // ...
</PanGestureHandler>

useAnimatedGestureHandler takes in an object with worklets - gesture handlers that are defined under certain keys and triggered based on the current state of the animation. In this article we’ll cover onStart, onActive and onEnd. Each of them receive event and context as arguments. Event objects hold the event payload and context is able to store some data and keep them available for all worklet handlers.

To make an animated transition of each bubble, we create animation objects (translateX and translateY) that run updates on Shared Value resulting in a smooth change between the two values.

See the code below:

// Bubble.tsx

import {PanGestureHandlerGestureEvent} from 'react-native-gesture-handler';
import {useAnimatedGestureHandler} from 'react-native-reanimated';

  // ...
  const translateX = useSharedValue(position.x);
  const translateY = useSharedValue(position.y);

  const viewStyle = useAnimatedStyle(() => ({
    transform: [{translateX: translateX.value}, {translateY: translateY.value}],
  }));

  const gestureHandler = useAnimatedGestureHandler<
    PanGestureHandlerGestureEvent,
    {
      offsetY: number;
      offsetX: number;
    }
  >({
    onStart: (_, ctx) => {
      ctx.offsetX = translateX.value;
      ctx.offsetY = translateY.value;
    },
    onActive: (event, ctx) => {
      translateX.value = ctx.offsetX + event.translationX;
      translateY.value = ctx.offsetY + event.translationY;
    },
  });

    return (
    <PanGestureHandler>
      <Animated.View style={viewStyle}>
        <Animated.View
          style={[
            BubbleStyle,
            {
              backgroundColor: color,
            },
          ]}
        />
      </Animated.View>
    </PanGestureHandler>
  );

At this point we can already drag around our bubbles but they stop immediately once we release the press, which makes the whole experience quite unnatural.

Let’s try to improve that.

To do so, we can add the onEnd method to gestureHandler and use it with withDecay animation helper to smoothly decelerate the bubble’s speed after the user’s press has been released. We set the velocity to be the same as the velocity of the animation, which basically means that the faster we drag the bubble, the longer it’ll take for it to stop fully.

By using clamp prop, we provide animation the boundaries, which guarantee that the bubble will not leave the screen. The logic for defining boundries is similar to the one already used in randomFromRange.

  // Bubble.tsx

  // ...
    onEnd: ({velocityX, velocityY}) => {
      translateX.value = withDecay({
        velocity: velocityX,
        clamp: [C.BUBBLES_OFFSET_LEFT, width - diameter],
      });
      translateY.value = withDecay({
        velocity: velocityY,
        clamp: [C.BUBBLES_OFFSET_TOP, height - diameter],
      });
    },

To indicate that the user can trigger some action by dropping the bubble over the drop area, we can modify its scale and make it slightly bigger.

Bubble size change

To achieve this effect, we’ll add some logic to the onActive method in gestureHandler. We’ll check if the current position of the dragged bubble on y-axis is below the top of the drop area (that information is passed from BubbleContainer component via props). If so, we’ll change the shared value of the draggedBubbleScale to be 1.2. If not, it’ll stay or come back to the initial 1. To smooth the animation, we’ll use withTiming and duration of 200ms.

We cannot forget to pass the transform prop to BubbleStyle.

  // Bubble.tsx

  const draggedBubbleScale = useSharedValue(1);

  const gestureHandler = useAnimatedGestureHandler({
    //...

    onActive: (event, ctx) => {
      translateX.value = ctx.offsetX + event.translationX;
      translateY.value = ctx.offsetY + event.translationY;
      if (translateY.value > dropAreaTop) {
        draggedBubbleScale.value = withTiming(1.2, {duration: 200});
      } else {
        draggedBubbleScale.value = withTiming(1, {duration: 200});
      }
    },

    //...
  });

  const BubbleStyle = useAnimatedStyle(() => ({
    //...
    transform: [{scale: draggedBubbleScale.value}],
  }));

Save color

Now, that we have a fully responsive animation, it’s time to implement some logic to allow the user to actually save their chosen color.

The idea of this feature is to drag the bubble and drop it in the light grey drop area at the bottom of the screen.

In order for our light grey area to change color and grow to full screen, we modify the drop area from View to Animated.View in wrapper component BubbleContainer:

// BubbleContainer.tsx

const [dropColor, setDropColor] = useState('grey');
// ...
<Animated.View
  style={[
    styles.dropArea,
    {
      backgroundColor: dropColor,
      left: width / 2 - C.DROP_AREA_INIT_SIZE / 2,
      zIndex: dropColor === 'grey' ? 0 : 3,
    },
    ]}
  />
// ...

To implement the logic, we can simply add a conditional statement to onEnd in gestureHandler. We check if the current position of the dragged bubble is on top of the drop area and if its scale is bigger than 1 (in this case it should equal 1.2), we trigger handleSelection function.

In order to call a function from JS thread, we need to use runOnJS from Reanimated. Read more

// Bubble.tsx

import {runOnJS} from 'react-native-reanimated';
//...
    onEnd: ({velocityX, velocityY}) => {
      translateX.value = withDecay({
        velocity: velocityX,
        clamp: [C.BUBBLES_OFFSET_LEFT, width - diameter],
      });
      translateY.value = withDecay({
        velocity: velocityY,
        clamp: [C.BUBBLES_OFFSET_TOP, height - diameter],
      });
      if (translateY.value > dropAreaTop && draggedBubbleScale.value > 1) {
        runOnJS(handleSelection)();
      }
    },

handleSelection function sets the user’s color and navigates to the previous screen.

// Bubble.tsx

  import {useNavigation} from '@react-navigation/native';
  import {useUserContext} from '../context/UserContext';

  //...

  const {setUserColor} = useUserContext();
  const {goBack} = useNavigation();

  const handleSelection = () => {
    setUserColor(color);
    goBack()
  }; 

If you’d like to know more about how the navigation in React Native works, please refer to the official documentation or have a look at one of the articles in our blog that covers this topic.

Drop Area Animation

To take our feature one step further, we will animate the drop area (changing the color and growing to full screen). In addition, as a nice touch, we’ll change the selected bubble’s opacity, so it blends nicely.

To change the opacity, we’ll create a shared value bubbleOpacity and set its initial value to 1.

Once the color is selected, the bubble will disappear as we change its opacity to 0.

Feel free to add some additional animations here.

// Bubble.tsx

  const bubbleOpacity = useSharedValue(1);

  const handleSelection = () => {
    setUserColor(color);
    bubbleOpacity.value = 0;
    goBack()
  };

  const BubbleStyle = useAnimatedStyle(() => ({
    //...
    opacity: bubbleOpacity.value,
  }));

Let’s focus on the drop area now.

In BubbleContainer we set shared values for the drop area’s top position and size (height). The trick is to move dropAreaTop from initial bottom offset of 175px (C.DROP_AREA_OFFSET) to 50px above the top of the screen (here C.DROP_AREA_OFFSET_TOP equals -50px) while changing its size simultaneously.

We pass animateDropArea via props to the Bubble component so we can call it inside handleSelection.

// BubbleContainer.tsx 

  const dropAreaTop = useSharedValue(height - C.DROP_AREA_OFFSET);
  const dropHeight = useSharedValue(C.DROP_AREA_INIT_SIZE);

  const animateDropArea = (color) => {
    setDropColor(color)
    dropAreaTop.value = withTiming(C.DROP_AREA_OFFSET_TOP, {duration: 600});
    dropHeight.value = withTiming(1.5 * height, {duration: 600});
  };

  const animatedDrop = useAnimatedStyle(() => ({
    top: dropAreaTop.value,
    height: dropHeight.value,
  }));

  return (
    //...
     <Animated.View
        style={[
          styles.dropArea,
          animatedDrop,
          {
            backgroundColor: dropColor,
            left: width / 2 - C.DROP_AREA_INIT_SIZE / 2,
            zIndex: dropColor === 'grey' ? 0 : 3,
          },
        ]}
      />
      {bubbles.map(bubble => (
        <View style={{position: 'absolute'}} key={bubble.id}>
          <Bubble
            {...bubble}
            diameter={C.BUBBLE_INIT_SIZE}
            dropAreaTop={dropAreaTop.value}
            animateDropArea={animateDropArea}
          />
        </View>
      ))}
    //...
  )

To change the color of the drop area after selection, we call animateDropArea in handleSelection function. We wrap goBack() with setTimeout() in order to change the screen just after the animation is completed.

// Bubble.tsx

type BubbleProps = {
  color: string;
  diameter: number;
  position: Position;
  dropAreaTop: number;
  animateDropArea: (arg0: string) => void;
};

export const Bubble = ({
  color,
  diameter,
  position,
  dropAreaTop,
  animateDropArea,
}: BubbleProps) => {
  //...
  const handleSelection = () => {
    setUserColor(color);
    bubbleOpacity.value = 0;
    animateDropArea(color);
    setTimeout(() => navigation.goBack(), 800);
  };
  //...
}

Summary

We’ve reached the end of this article. I hope you’ve found some of the information useful.

You are very welcome to play around with my code. You can clone this repository from GitHub.

React Native Reanimated is a powerful library that gives endless possibilities.


Written by Magda Jaśkowska.