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.
If you don't know what I'm talking about, the heart animation looks like this.
In order to replicate the animation we have to break it down into it's parts.
Scale 0 => Scale 1.2 => Scale 1
X/Y from 0 => deviceHeight/2
rotate -15deg => 0 => 15deg
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.
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.
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 * 0.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.
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.
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.
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.
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.
Now this is where we are going to get a little tricky with interpolation.
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
.
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.
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.
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.
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.
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.
componentDidMount: function() { Animated.timing(this.state.position, { duration: 2000, toValue: NEGATIVE_END_Y }).start(this.props.onComplete); },
//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={{ right: this.state.hearts[i].right }} /> ); }, 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.
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
.
Thank you to Anthony Webb for the submission. If you any other inquiries do let me know and I'll show you how to build them.
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 * 0.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={{ right: this.state.hearts[i].right }} /> ); }, 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);