React Native Periscope Hearts Animation

Introduction

I was contacted asking if it was possible to recreate the periscope heart animations in react-native. I was also linked to someone rebuilding the same effect in Framer.js, you can check out the linked video here https://www.youtube.com/watch?v=qFUXxqzZytU.

Periscope not only has an iPhone/Android app but it also has a web app with the same heart effect. I could take a look at the animations being done on it but we’ll just eye ball it.

What we are building

Live code: https://rnplay.org/apps/8VhSjw

What it looks like on Periscope

If you don’t know what I’m talking about, the heart animation looks like this.

Break Down

In order to replicate the animation we have to break it down into it’s parts.

  • Heart appears and scales up then quickly scales back down. Animation Scale 0 => Scale 1.2 => Scale 1
  • Heart moves upwards with a slight sway to the left then back to the right. X/Y from 0 => deviceHeight/2
  • Slight rotation of the heart through out each sway rotate -15deg => 0 => 15deg
  • Heart opacity fades out over entire animation opacity 1 => 0

We likely won’t need Animated values for all of these, the goal of Animating is to attempt to interpolate other values from one Animated.Value. In our case that one constant is the X/Y values. All of the other animations are dependent on where the heart is currently located.

The opacity is derived from the X/Y that it can be an interpolated value. The scale could be interpolated too, with 3 small input ranges, and output ranges of [0, 1.2, 1]. The rotation can also be interpolated based upon the X value. Even the X can be interpolated based upon the Y if we determine that we want 2 sways to happen before the animation is complete.

Make the Heart

Now we have to decide how to make the heart. One option is to use an <Image /> however this means I have to open up an image editor and I’m a developer, not a designer! If you haven’t seen The Shapes of CSS I recommend checking it out. For basics shapes CSS will work great, and in our case a heart is a simple shape.

It’s composed of 2 objects overlayed on top of each other. Two squares, with top left / top right border radiuses and then drop them on top of each other.

Apart they look like this

Then together we get a heart!

This also gives us control over the color more easily, the sizing, and anything else you can do with a simple view.

Setup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var React = require('react-native');
var Dimensions = require('Dimensions');

var {
  width: deviceWidth,
  height: deviceHeight
} = Dimensions.get('window');

var {
  AppRegistry,
  StyleSheet,
  View,
  Animated,
  TouchableWithoutFeedback
} = React;

var ANIMATION_END_Y = Math.ceil(deviceHeight * .5);
var NEGATIVE_END_Y = ANIMATION_END_Y * -1;
var startCount = 1;

We’ll bring in the necessary includes. The ANIMATION_END_Y and the reverse NEGATIVE_END_Y will become apparent as what they are later. Due to some interpolation we’ll need to do some trickery to make our animation interpolations make more sense.

Create The Heart

As we showed before the heart is 2 pieces. These pieces will have to be absolutely positioned so lets creating a wrapping view, and 2 pieces. We’ll style each piece leftHeart and rightHeat. Then setup some styles.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var Heart = React.createClass({
    render: function() {
        return (
            <View {...this.props} style={[styles.heart, this.props.style]}>
                <View style={styles.leftHeart} />
                <View style={styles.rightHeart} />
            </View>
        )
    }
})

//Styles

  heart: {
    width: 50,
    height: 50
  },
  heartShape: {
    width: 30,
    height: 45,
    position: 'absolute',
    top: 0,
    borderTopLeftRadius: 15,
    borderTopRightRadius: 15,
    backgroundColor: '#6427d1',
  },
  leftHeart: {
    transform: [
        {rotate: '-45deg'}
    ],
    left: 5
  },
  rightHeart: {
    transform: [
        {rotate: '45deg'}
    ],
    right: 5
  }

We set a width and height on the wrapping heart so it takes up space. We move all default styling into heartShape style and give it a nice purple color. Then we just adjust each left and right heart. The leftHeart piece will be on the left side, and rotated -45deg aka 45deg towards the left, and the right will be the reverse.

Setup Base Rendering

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var HeartFloater = React.createClass({
  getInitialState: function() {
    return {};
  },

  render: function() {
    return (
      <View style={styles.container}>
        <Animated.View style={styles.heartWrap}>
          <Heart />
        </Animated.View>
      </View>
    );
  }
});


var styles = StyleSheet.create({
  container: {
    flex: 1
  },
  heartWrap: {
      position: 'absolute',
      bottom: 50,
      right: (deviceWidth/2) - 25
  },
  heart: {
    width: 50,
    height: 50
  },
  heartShape: {
    width: 30,
    height: 45,
    position: 'absolute',
    top: 0,
    borderTopLeftRadius: 15,
    borderTopRightRadius: 15,
    backgroundColor: '#6427d1',
  },
  leftHeart: {
    transform: [
        {rotate: '-45deg'}
    ],
    left: 5
  },
  rightHeart: {
    transform: [
        {rotate: '45deg'}
    ],
    right: 5
  }
});

Nothing much to call out here, except we add a new heartWrap class. We’ll leave the heart be a static heart and then just animate a wrapper. The heartWrap is positioned absolutely and centered.

Move the Heart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var HeartFloater = React.createClass({
  getInitialState: function() {
    return {
      position: new Animated.Value(0)
    };
  },
  componentDidMount: function() {
    Animated.timing(this.state.position, {
      duration: 2000,
      toValue: NEGATIVE_END_Y
    }).start();

  },
  getHeartAnimationStyle: function() {
    return {
      transform: [
        {translateY: this.state.position},
      ]
    }
  },
  render: function() {
    return (
      <View style={styles.container}>
        <Animated.View style={[styles.heartWrap, this.getHeartAnimationStyle()]}>
          <Heart />
        </Animated.View>
      </View>
    );
  }
});

We only setup a basic Animated.Value instead of an Animated.ValueXY because we will interpolate all the necessary effects and even the X position from the Y value.

We kick off an animation when the component mounts to animate the heart from 0 to negative half deviceHeight. We do this since translateY moves up when it is negative, and moves down when it is positive. We do this animation for 2 seconds.

Fade the Heart

Now this is where we are going to get a little tricky with interpolation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  componentWillMount: function() {
    this._yAnimation = this.state.position.interpolate({
      inputRange: [NEGATIVE_END_Y, 0],
      outputRange: [ANIMATION_END_Y, 0]
    });

    this._opacityAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y],
      outputRange: [1, 0]
    })

  },

  getHeartAnimationStyle: function() {
    return {
      transform: [
        {translateY: this.state.position},
      ],
      opacity: this._opacityAnimation
    }
  },

You can interpolate on an interpolated value. So we’ll map our negative values, directly to positive values and also flip the step scale.

Our animation would typically go from 0 to -300 but our new _yAnimation will flip that and stay we are animation goes from 0 to 300.

This allows our animations like opacity to make more sense. We can interpolate on our this._yAnimation and specify [0, ANIMATION_END_Y] is tied to the opacity values [1,0]. So 0 aka the start is at opacity of 1 and moves to 0 which is tied to the ANIMATION_END_Y (roughly 300 depending on your device).

Then we pass the interpolated opacity value in opacity.

Scale the Heart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
this._scaleAnimation = this._yAnimation.interpolate({
  inputRange: [0, 15, 30],
  outputRange: [0, 1.2, 1],
  extrapolate: 'clamp'
})

getHeartAnimationStyle: function() {
return {
  transform: [
    {translateY: this.state.position},
    {scale: this._scaleAnimation}
  ],
  opacity: this._opacityAnimation
}
  },

Now we’ll add another animation below the other that will handle the scale. Because our now interpolated value runs positively we can just define pixel scale steps. So 0 maps to 0 so on creation the heart doesn’t exist. The heart will quickly scale from 0 to 1.2x it’s size over the first 15 pixels it travels, then from 15 to 30 pixels it’ll scale back down from 1.2 to 1.

This gives us a quick little pulse of the heart. We must add the extrapolate: clamp otherwise the heart will start scaling down and eventually go negative causing the heart to flip.

Rotate/Sway the Heart

Almost done! Now lets make it sway a little bit. This one is a little difficult to fine tune, but I errored on the less dramtic side of things.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
this._xAnimation = this._yAnimation.interpolate({
  inputRange: [0, ANIMATION_END_Y/2, ANIMATION_END_Y],
  outputRange: [0, 15, 0]
})

this._rotateAnimation = this._yAnimation.interpolate({
  inputRange: [0, ANIMATION_END_Y/4, ANIMATION_END_Y/3, ANIMATION_END_Y/2, ANIMATION_END_Y],
  outputRange: ['0deg', '-2deg', '0deg', '2deg', '0deg']
});

getHeartAnimationStyle: function() {
  return {
    transform: [
      {translateY: this.state.position},
      {translateX: this._xAnimation},
      {scale: this._scaleAnimation},
      {rotate: this._rotateAnimation}
    ],
    opacity: this._opacityAnimation
  }
},

This causes one sway to happen for 15 pixels then come back to the center. There are three rotations that happen that will make it look like it’s wobbling a bit more.

The _xAnimation input range is from 0 to half the animation, to the end. Since our value of the _xAnimation interpolation is the end value of the animation we don’t need to extrapolate: clamp here.

The _rotateAnimation takes 5 different steps. We just divide by each step. We start at 0, then divide by 4 for quarter of the height animation, then a third of animation, half of the animation, then finally rotate back to 0 to finish the animation off. I pulled these out of thin air, and it looks okay but could use some fine tuning.

Make AnimatedHeart component

Now lets make it show a bunch of hearts when we press down. Each press should put another heart onto an array, and when the heart is done animating we should remove it.

First move all the code to an AnimatedHeart like so.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
var AnimatedHeart = React.createClass({
  getDefaultProps: function() {
    return {
      onComplete: function() {}
    };
  },
  getInitialState: function() {
    return {
      position: new Animated.Value(0)
    };
  },
  componentWillMount: function() {
    this._yAnimation = this.state.position.interpolate({
      inputRange: [NEGATIVE_END_Y, 0],
      outputRange: [ANIMATION_END_Y, 0]
    });

    this._opacityAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y],
      outputRange: [1, 0]
    });

    this._scaleAnimation = this._yAnimation.interpolate({
      inputRange: [0, 15, 30],
      outputRange: [0, 1.2, 1],
      extrapolate: 'clamp'
    });

    this._xAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y/2, ANIMATION_END_Y],
      outputRange: [0, 15, 0]
    })

    this._rotateAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y/4, ANIMATION_END_Y/3, ANIMATION_END_Y/2, ANIMATION_END_Y],
      outputRange: ['0deg', '-2deg', '0deg', '2deg', '0deg']
    });
  },
  componentDidMount: function() {
    Animated.timing(this.state.position, {
      duration: 2000,
      toValue: NEGATIVE_END_Y
    }).start(this.props.onComplete);
  },
  getHeartAnimationStyle: function() {
    return {
      transform: [
        {translateY: this.state.position},
        {translateX: this._xAnimation},
        {scale: this._scaleAnimation},
        {rotate: this._rotateAnimation}
      ],
      opacity: this._opacityAnimation
    }
  },
  render: function() {
    return (
        <Animated.View style={[styles.heartWrap, this.getHeartAnimationStyle(), this.props.style]}>
          <Heart />
        </Animated.View>
    )
  }
})

We modified a few things. We adjusted our style to include this.props.style. Also we added a callback when the animation is finished.

1
2
3
4
5
6
  componentDidMount: function() {
    Animated.timing(this.state.position, {
      duration: 2000,
      toValue: NEGATIVE_END_Y
    }).start(this.props.onComplete);
  },

Add AnimatedHeart on press

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random
function getRandomNumber(min, max) {
  return Math.random() * (max - min) + min;
}

var HeartFloater = React.createClass({
  getInitialState: function() {
    return {
      hearts: [] 
    };
  },
  addHeart: function() {
    startCount += 1;
    this.state.hearts.push({
      id: startCount,
      right: getRandomNumber(50, 150)
    });
    this.setState(this.state);
  },
  removeHeart: function(v) {
    var index = this.state.hearts.findIndex(function(heart) { return heart.id === v});
    this.state.hearts.splice(index, 1);
    this.setState(this.state);
  },
  render: function() {
    return (
      <View style={styles.container}>
        <TouchableWithoutFeedback style={styles.container} onPress={this.addHeart}>
          <View style={styles.container}>
            {
              this.state.hearts.map(function(v, i) {
                return (
                    <AnimatedHeart 
                      key={v.id}
                      onComplete={this.removeHeart.bind(this, v.id)}
                      style=
                    />
                ) 
              }, this)
            }
          </View>
        </TouchableWithoutFeedback>
      </View>
    );
  }
});

Our HeartFloater, now has a hearts array to hold our hearts for state. We’ve adjusted our render to use TouchableWithoutFeedback to call addHeart on press.

Each press will increase our startCount which we use as an id generator. Also we add a random right style. a

Our render just loops over each heart, and renders the AnimatedHeart with a key which is essential for performance, an onComplete which is called when our animation is finished, and the right style we randomly generated.

On complete we call remove. Which finds the index of the heart, splices it, and sets state.

Fix Background Colors and Styles

1
2
3
4
5
6
7
8
9
10
  heartWrap: {
      position: 'absolute',
      bottom: 30,
      backgroundColor: 'transparent'
  },
  heart: {
    width: 50,
    height: 50,
    backgroundColor: 'transparent'
  },

Without setting the background to transparent the background will be white causing one heart to clip another. This will prevent that.

We also removed the right position since we generate random positions between 50 and 150.

Final

Thank you to Anthony Webb for the submission. As always check out the live code on RNPlay.

Live code: https://rnplay.org/apps/8VhSjw

If you any other inquiries do let me know and I’ll show you how to build them.

Final Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
var React = require('react-native');
var Dimensions = require('Dimensions');

var {
  width: deviceWidth,
  height: deviceHeight
} = Dimensions.get('window');

var {
  AppRegistry,
  StyleSheet,
  View,
  Animated,
  TouchableWithoutFeedback
} = React;

var ANIMATION_END_Y = Math.ceil(deviceHeight * .5);
var NEGATIVE_END_Y = ANIMATION_END_Y * -1;
var startCount = 1;

function getRandomNumber(min, max) {
  return Math.random() * (max - min) + min;
}

var Heart = React.createClass({
    render: function() {
        return (
            <View {...this.props} style={[styles.heart, this.props.style]}>
                <View style={[styles.leftHeart, styles.heartShape]} />
                <View style={[styles.rightHeart, styles.heartShape]} />
            </View>
        )
    }
});

var AnimatedHeart = React.createClass({
  getDefaultProps: function() {
    return {
      onComplete: function() {}
    };
  },
  getInitialState: function() {
    return {
      position: new Animated.Value(0)
    };
  },
  componentWillMount: function() {
    this._yAnimation = this.state.position.interpolate({
      inputRange: [NEGATIVE_END_Y, 0],
      outputRange: [ANIMATION_END_Y, 0]
    });

    this._opacityAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y],
      outputRange: [1, 0]
    });

    this._scaleAnimation = this._yAnimation.interpolate({
      inputRange: [0, 15, 30],
      outputRange: [0, 1.2, 1],
      extrapolate: 'clamp'
    });

    this._xAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y/2, ANIMATION_END_Y],
      outputRange: [0, 15, 0]
    })

    this._rotateAnimation = this._yAnimation.interpolate({
      inputRange: [0, ANIMATION_END_Y/4, ANIMATION_END_Y/3, ANIMATION_END_Y/2, ANIMATION_END_Y],
      outputRange: ['0deg', '-2deg', '0deg', '2deg', '0deg']
    });
  },
  componentDidMount: function() {
    Animated.timing(this.state.position, {
      duration: 2000,
      toValue: NEGATIVE_END_Y
    }).start(this.props.onComplete);
  },
  getHeartAnimationStyle: function() {
    return {
      transform: [
        {translateY: this.state.position},
        {translateX: this._xAnimation},
        {scale: this._scaleAnimation},
        {rotate: this._rotateAnimation}
      ],
      opacity: this._opacityAnimation
    }
  },
  render: function() {
    return (
        <Animated.View style={[styles.heartWrap, this.getHeartAnimationStyle(), this.props.style]}>
          <Heart />
        </Animated.View>
    )
  }
})

var HeartFloater = React.createClass({
  getInitialState: function() {
    return {
      hearts: [] 
    };
  },
  addHeart: function() {
    startCount += 1;
    this.state.hearts.push({
      id: startCount,
      right: getRandomNumber(50, 150)
    });
    this.setState(this.state);
  },
  removeHeart: function(v) {
    var index = this.state.hearts.findIndex(function(heart) { return heart.id === v});
    this.state.hearts.splice(index, 1);
    this.setState(this.state);
  },
  render: function() {
    return (
      <View style={styles.container}>
        <TouchableWithoutFeedback style={styles.container} onPress={this.addHeart}>
          <View style={styles.container}>
            {
              this.state.hearts.map(function(v, i) {
                return (
                    <AnimatedHeart 
                      key={v.id}
                      onComplete={this.removeHeart.bind(this, v.id)}
                      style=
                    />
                ) 
              }, this)
            }
          </View>
        </TouchableWithoutFeedback>
      </View>
    );
  }
});

var styles = StyleSheet.create({
  container: {
    flex: 1
  },
  heartWrap: {
      position: 'absolute',
      bottom: 30,
      backgroundColor: 'transparent'
  },
  heart: {
    width: 50,
    height: 50,
    backgroundColor: 'transparent'
  },
  heartShape: {
    width: 30,
    height: 45,
    position: 'absolute',
    top: 0,
    borderTopLeftRadius: 15,
    borderTopRightRadius: 15,
    backgroundColor: '#6427d1',
  },
  leftHeart: {
    transform: [
        {rotate: '-45deg'}
    ],
    left: 5
  },
  rightHeart: {
    transform: [
        {rotate: '45deg'}
    ],
    right: 5
  }
});

AppRegistry.registerComponent('animate_slide', () => HeartFloater);
Tagged under animation, hearts, periscope, react, react-native

Comments