Facebook reactions is a new liking system that Facebook is trialing on a limited basis. Why wait for them to roll it out when we can roll it out ourselves
Thanks to Engadget who created a gif of the animations from a youtube video. After a little slicing up and conversion from white to transparent we have a nice bunch of terrible animated gifs... but they're animated so deal with it. Like/Love aren't transparent since imagmagick was destroying the white in them.
Update - Android:
Animated Gifs are coming to React Native Android in .13. Also Android does not support overflow: visible
so in order to get android to work we would need to get creative. On open they sit within the overflow: hidden
container, once slid in we'd have to move them outside. Although they are gifs, so you'd have to find a way to sync em up.
Maybe 2 sets, one slides in, the other is rendered but hidden, then you toggle which is hidden and slide the other up. Fun stuff!
While holding down a button a pop up will show up, animated gifs will slide up from the bottom. While sliding and hovering over an item it will slide up slightly and scale up in size. On release of the finger, that item is selected, the images slide down and the pop up disappears.
This doesn't sound too bad, there are a few slight issues we'll run into that I'll call out. The main one is just the border around the container. We get creative with the component structure so the images will animate over the top of the border and not underneath it.
var React = require("react-native"); var { StyleSheet, Text, Image, View, PanResponder, TouchableOpacity, Animated, } = React; var images = [ { id: "like", img: "http://i.imgur.com/LwCYmcM.gif" }, { id: "love", img: "http://i.imgur.com/k5jMsaH.gif" }, { id: "haha", img: "http://i.imgur.com/f93vCxM.gif" }, { id: "yay", img: "http://i.imgur.com/a44ke8c.gif" }, { id: "wow", img: "http://i.imgur.com/9xTkN93.gif" }, { id: "sad", img: "http://i.imgur.com/tFOrN5d.gif" }, { id: "angry", img: "http://i.imgur.com/1MgcQg0.gif" }, ]; var App = React.createClass({ getInitialState: function () { return { selected: "", open: false, }; }, componentWillMount: function () { this._imgLayouts = {}; this._imageAnimations = {}; this._scaleAnimation = new Animated.Value(0); images.forEach((img) => { this._imageAnimations[img.id] = { position: new Animated.Value(55), scale: new Animated.Value(1), }; }); this._panResponder = PanResponder.create({ onStartShouldSetPanResponder: (evt, gestureState) => true, onStartShouldSetPanResponderCapture: (evt, gestureState) => true, onMoveShouldSetPanResponder: (evt, gestureState) => true, onMoveShouldSetPanResponderCapture: (evt, gestureState) => true, }); }, render: function () { return ( <View style={styles.container}> <View style={styles.center} {...this._panResponder.panHandlers}> <Text>Like</Text> <Text>You selected: {this.state.selected}</Text> <Animated.View style={[styles.likeContainer, this.getLikeContainerStyle()]} > <View style={styles.borderContainer} /> <View style={styles.imgContainer}>{this.getImages()}</View> </Animated.View> </View> </View> ); }, });
We setup our state, we have a selected
which we will use to just display some selected text. Then we have an open
which we set to false. I am not showing it yet, but we'll need to use open
to dynamically control overflow
for the container that holds our images so they can slide around correctly.
In our componentWillMount
we setup some objects and create some animations. We'll need the coordinates of each image for selection purposes, we'll also have a map of our image animations, and we'll setup _scaleAnimation
which we'll use to scale up the container when a user presses down.
Then we loop over each image, create a position, and scale animation for each. Why 55
and why not ValueXY
? Well we are only animating the Y value so no need to have an extra animation. The 55
is our initial position, which we pass to translateY
meaning, start this image 55
pixels down so we can eventually slide it up.
Finally we'll create a PanResponder to handle users pressing.
So we'll put our panHandlers
on our wrapping view so our coordinates are correct relatively for the images. That looks like this
<View style={styles.center} {...this._panResponder.panHandlers}> ///Other views </View>
Then we need to adjust our PanResponder to call an open function on grant. Grant being the first thing that gets called when the touch is allowed.
this._panResponder = PanResponder.create({ onStartShouldSetPanResponder: (evt, gestureState) => true, onStartShouldSetPanResponderCapture: (evt, gestureState) => true, onMoveShouldSetPanResponder: (evt, gestureState) => true, onMoveShouldSetPanResponderCapture: (evt, gestureState) => true, onPanResponderGrant: this.open, });
Lets adjust our Animated.View
container.
getLikeContainerStyle: function() { return { transform: [{scaleY: this._scaleAnimation}], overflow: this.state.open ? 'visible': 'hidden' }; }, //other render components <Animated.View style={[styles.likeContainer, this.getLikeContainerStyle()]} > <View style={styles.borderContainer} /> <View style={styles.imgContainer}> {this.getImages()} </View> </Animated.View>
Now our open function.
open: function() { Animated.parallel([ Animated.timing(this._scaleAnimation, { duration: 100, toValue: 1 }), Animated.stagger(50, this.getImageAnimationArray(0)) ]).start(() => this.setState({open: true})); }, getImageAnimationArray: function(toValue) { return images.map((img) => { return Animated.timing(this._imageAnimations[img.id].position, { duration: 200, toValue: toValue }) }); },
We'll queue up some parallel
animations so they run at the same time. The first is the initial scaling of the container. We'll do it over 100ms
, and go from 0 to 1.
The next thing we'll do is use Animated.stagger
, this will trigger an array of animations, but with a delay in between each one. That means we can easily slide up each image reaction separately with 50ms
inbetween. What a handy function.
So we'll do a little forward thinking and create a getImageAnimationArray
function because we'll have to do the exact opposite for the close
so lets make a reusable function.
Finally we'll setState
AFTER the animation is complete. This will not cause a jitter in our animation. This also allows the container to hide the images to start so they slide up. Then after the container is open we set overflow: visible
so we can scale and slide them up and they'll actually be visible. If we don't do this then the images won't be able visible when the user tries to select them.
Now modify the _panResponder
for the release to close it, we basically just do the exact opposite of open.
onPanResponderRelease: (evt, gestureState) => { this.close(); };
And our close function
close: function(cb) { this.setState({open: false}, () => { Animated.stagger(100,[ Animated.parallel(this.getImageAnimationArray(55, 0).reverse() ), Animated.timing(this._scaleAnimation, { duration: 100, toValue: 0 }) ]).start(cb); }) },
We need to do a setState to make the containers overflow hidden. This is tricky because it'll instantly cause anything selected to be hidden. So we'll need to account for this in our release function later. But at the moment, we set open
to false.
Rather than parallel we'll stagger our 2 animations. Our first will start and cause our reaction gifs to all slide down at the same time. Then 100ms
later we'll scale the container down to 0. This just adds a slight disappearing effect.
Before we can do image selections we need to know where our images coordinates are. Luckily we can do that with the onLayout
callback.
getImages: function() { return images.map((img) => { return ( <Animated.Image onLayout={this.handleLayoutPosition.bind(this, img.id)} key={img.id} source={{uri: img.img}} style={[ styles.img, { transform: [ {scale: this._imageAnimations[img.id].scale}, {translateY: this._imageAnimations[img.id].position} ] } ]} /> ); }) },
This is our image rendering function. It iterates over our images, passes in all necesary info and binds our onLayout
function with the image.id
which is just the reaction name. You can see here we also pass in our transform animations for scale
and translateY
handleLayoutPosition: function(img, position) { this._imgLayouts[img] = { left: position.nativeEvent.layout.x, right: position.nativeEvent.layout.x + position.nativeEvent.layout.width } },
Here we setup a map, we'll set the left
which is just the relative x
coordinate, then setup our right
which is x + width
.
onPanResponderMove: (evt, gestureState) => { var hoveredImg = this.getHoveredImg(Math.ceil(evt.nativeEvent.locationX)); if (hoveredImg && this._hoveredImg !== hoveredImg) { this.animateSelected(this._imageAnimations[hoveredImg]) } if (this._hoveredImg !== hoveredImg && this._hoveredImg) { this.animateFromSelect(this._imageAnimations[this._hoveredImg]); } this._hoveredImg = hoveredImg; },
We just do some basic logic here. If we have a hoveredImg
and it's different than before, then call the animate function to make the image slide and scale up.
Also if we have a current this._hoveredImg
and it's different then that means there is a new hovered image, so lets animate the previous image and scale it back down.
animateSelected: function(imgAnimations) { Animated.parallel([ Animated.timing(imgAnimations.position, { duration: 150, toValue: -30 }), Animated.timing(imgAnimations.scale, { duration: 150, toValue: 1.8 }) ]).start(); }, animateFromSelect: function(imgAnimations, cb) { Animated.parallel([ Animated.timing(imgAnimations.position, { duration: 50, toValue: 0 }), Animated.timing(imgAnimations.scale, { duration: 50, toValue: 1 }) ]).start(cb); }, getHoveredImg: function(x) { return Object.keys(this._imgLayouts).find((key) => { return x >= this._imgLayouts[key].left && x <= this._imgLayouts[key].right; }) },
Nothing too much here to call out. Our getHoveredImg
function runs through our layouts, and just checks if the x
coordinate of the finger press is between the left
and right
. If it finds one it returns the reaction id.
The animateSelected
and animatedFromSelect
both take one the image animation objects and applies different animations to each.
onPanResponderRelease: (evt, gestureState) => { if (this._hoveredImg) { this.animateFromSelect( this._imageAnimations[this._hoveredImg], this.close.bind(this, this.afterClose) ); } else { this.close(this.afterClose); } };
I had mentioned this before. On the release if we have a current selection, then we need to first animate the selected image back to it's original position, which we have animateFromSelect
already. Then when that is complete the callback is called which is our close function, which has a callback to trigger after close.
afterClose: function() { if (this._hoveredImg) { this.setState({ selected: this._hoveredImg }) } this._hoveredImg = ''; },
Our after close just sets some text of the hovered img and clears it. This may not be necessary for an actual app, but this is just so I can have some text to show a user they selected a reaction.
I'll just point out some "weird" things I'm doing to make things work correctly. Mainly rather than adding in the border on the likeContainer
we need to add in 2 separate containers.
Our element structure
<Animated.View style={[styles.likeContainer, this.getLikeContainerStyle()]}> <View style={styles.borderContainer} /> <View style={styles.imgContainer}>{this.getImages()}</View> </Animated.View>
Our weird styling.
likeContainer: { position: 'absolute', left: -10, top: -30, padding: 5, flex: 1, backgroundColor: '#FFF', borderColor: 'transparent', borderWidth: 0, borderRadius: 20, }, borderContainer: { backgroundColor: 'transparent', position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, borderWidth: 1, borderColor: '#444', borderRadius: 20 }, imgContainer: { backgroundColor: 'transparent', flexDirection: 'row', },
The borderContainer
is absolutely positioned inside of the likeContainer
. The borderContainer
is just that, a bunch of border data.
The likeContainer
also has border radius, transparent border styling so that it is shaped perfectly for overflow when the images slide down.
And there we have it. This is by no means an exact replica. It needs some serious polish and also needs to add a delay before opening when the users presses. It's good enough for me though. So go forth and add reactions to your app!
I didn't do anything crazy and assumed it would work on android. There a few issues with it so I'm not sure exactly what's going on.
As stated before there are minor tweaks from the code to make it work with react .11, not entirely certain why there are differences but there are.
Full code here
var React = require("react-native"); var { StyleSheet, Text, Image, View, PanResponder, TouchableOpacity, Animated, } = React; var images = [ { id: "like", img: "http://i.imgur.com/LwCYmcM.gif" }, { id: "love", img: "http://i.imgur.com/k5jMsaH.gif" }, { id: "haha", img: "http://i.imgur.com/f93vCxM.gif" }, { id: "yay", img: "http://i.imgur.com/a44ke8c.gif" }, { id: "wow", img: "http://i.imgur.com/9xTkN93.gif" }, { id: "sad", img: "http://i.imgur.com/tFOrN5d.gif" }, { id: "angry", img: "http://i.imgur.com/1MgcQg0.gif" }, ]; var App = React.createClass({ getInitialState: function () { return { selected: "", open: false, }; }, componentWillMount: function () { this._imgLayouts = {}; this._imageAnimations = {}; this._hoveredImg = ""; this._scaleAnimation = new Animated.Value(0); images.forEach((img) => { this._imageAnimations[img.id] = { position: new Animated.Value(55), scale: new Animated.Value(1), }; }); this._panResponder = PanResponder.create({ onStartShouldSetPanResponder: (evt, gestureState) => true, onStartShouldSetPanResponderCapture: (evt, gestureState) => true, onMoveShouldSetPanResponder: (evt, gestureState) => true, onMoveShouldSetPanResponderCapture: (evt, gestureState) => true, onPanResponderGrant: this.open, onPanResponderMove: (evt, gestureState) => { var hoveredImg = this.getHoveredImg( Math.ceil(evt.nativeEvent.locationX) ); if (hoveredImg && this._hoveredImg !== hoveredImg) { this.animateSelected(this._imageAnimations[hoveredImg]); } if (this._hoveredImg !== hoveredImg && this._hoveredImg) { this.animateFromSelect(this._imageAnimations[this._hoveredImg]); } this._hoveredImg = hoveredImg; }, onPanResponderRelease: (evt, gestureState) => { if (this._hoveredImg) { this.animateFromSelect( this._imageAnimations[this._hoveredImg], this.close.bind(this, this.afterClose) ); } else { this.close(this.afterClose); } }, }); }, afterClose: function () { if (this._hoveredImg) { this.setState({ selected: this._hoveredImg, }); } this._hoveredImg = ""; }, animateSelected: function (imgAnimations) { Animated.parallel([ Animated.timing(imgAnimations.position, { duration: 150, toValue: -30, }), Animated.timing(imgAnimations.scale, { duration: 150, toValue: 1.8, }), ]).start(); }, animateFromSelect: function (imgAnimations, cb) { Animated.parallel([ Animated.timing(imgAnimations.position, { duration: 50, toValue: 0, }), Animated.timing(imgAnimations.scale, { duration: 50, toValue: 1, }), ]).start(cb); }, getHoveredImg: function (x) { return Object.keys(this._imgLayouts).find((key) => { return ( x >= this._imgLayouts[key].left && x <= this._imgLayouts[key].right ); }); }, getImageAnimationArray: function (toValue) { return images.map((img) => { return Animated.timing(this._imageAnimations[img.id].position, { duration: 200, toValue: toValue, }); }); }, open: function () { Animated.parallel([ Animated.timing(this._scaleAnimation, { duration: 100, toValue: 1, }), Animated.stagger(50, this.getImageAnimationArray(0)), ]).start(() => this.setState({ open: true })); }, close: function (cb) { this.setState({ open: false }, () => { Animated.stagger(100, [ Animated.parallel(this.getImageAnimationArray(55, 0).reverse()), Animated.timing(this._scaleAnimation, { duration: 100, toValue: 0, }), ]).start(cb); }); }, handleLayoutPosition: function (img, position) { this._imgLayouts[img] = { left: position.nativeEvent.layout.x, right: position.nativeEvent.layout.x + position.nativeEvent.layout.width, }; }, getImages: function () { return images.map((img) => { return ( <Animated.Image onLayout={this.handleLayoutPosition.bind(this, img.id)} key={img.id} source={{ uri: img.img }} style={[ styles.img, { transform: [ { scale: this._imageAnimations[img.id].scale }, { translateY: this._imageAnimations[img.id].position }, ], }, ]} /> ); }); }, getLikeContainerStyle: function () { return { transform: [{ scaleY: this._scaleAnimation }], overflow: this.state.open ? "visible" : "hidden", }; }, render: function () { return ( <View style={styles.container}> <View style={styles.center} {...this._panResponder.panHandlers}> <Text>Like</Text> <Text>You selected: {this.state.selected}</Text> <Animated.View style={[styles.likeContainer, this.getLikeContainerStyle()]} > <View style={styles.borderContainer} /> <View style={styles.imgContainer}>{this.getImages()}</View> </Animated.View> </View> </View> ); }, }); var styles = StyleSheet.create({ container: { flex: 1, }, center: { position: "absolute", left: 50, top: 300, }, likeContainer: { position: "absolute", left: -10, top: -30, padding: 5, flex: 1, backgroundColor: "#FFF", borderColor: "transparent", borderWidth: 0, borderRadius: 20, }, borderContainer: { backgroundColor: "transparent", position: "absolute", left: 0, right: 0, top: 0, bottom: 0, borderWidth: 1, borderColor: "#444", borderRadius: 20, }, imgContainer: { backgroundColor: "transparent", flexDirection: "row", }, img: { marginLeft: 5, marginRight: 5, width: 30, height: 30, overflow: "visible", }, }); module.exports = App;