Facebook Reactions demystified

Cuong Le
10 min readMar 19, 2017

Disclaimer: I’m not Facebook engineer. The result may not be exactly like Facebook App. My purpose of writing this is to discover how Reaction Animations are made.

Take a look at Facebook App

As you can see, there is no bounds among Emotions . Therefore Facebook must do custom view and draws everything on the square boundary.

Animation analysis

  1. Pop up stage
The orange rounded rectangle is called RoundedBoard, the purple circles are named Emotions

The RoundedBoard goes up. Then the Emotion rises and scales up at MediumSize in sequence with a delay of Index*delay .

Given each Emotion will take 300 ms for its translation. Each is delayed Index*100 ms. There are 6 emotions, we count Index from 0. So the time the last Emotion is worked out by a formula totalDuration — 300 = 5*100 .

2. Selection stage

The selected Emotion grows at LargeSize. The others shrink at SmallSize. No matter their size is, they must stick to the Baseline.

In case of no selection, Emotions get back to their MediumSize.

Implementation

Step 1. Create RoundedBoard.java

This class defines constant values and draws itself. Some Animated fields are used for animation.

When its height decreases, its y must change accordingly.

public class RoundedBoard {

static final int BOARD_WIDTH = 6 * Emotion.MEDIUM_SIZE + 7 * Constants.HORIZONTAL_SPACING;

static final int BOARD_HEIGHT = DisplayUtil.dpToPx(48);

static final int BOARD_SCALED_DOWN_HEIGHT = DisplayUtil.dpToPx(38);

static final float BOARD_LEFT = DisplayUtil.dpToPx(16);

static final float BOARD_BOTTOM = Constants.HEIGHT_VIEW_REACTION - 200;

static final float BOARD_TOP = BOARD_BOTTOM - BOARD_HEIGHT;

static final float BASE_LINE = BOARD_TOP + Emotion.MEDIUM_SIZE + Constants.VERTICAL_SPACING;

float height = BOARD_HEIGHT;
float y;

float startAnimatedHeight;
float endAnimatedHeight;

float startAnimatedY;
float endAnimatedY;
private float radius = height / 2;
private Paint boardPaint;
private RectF rect;

RoundedBoard() {
initPaint();
rect = new RectF();
}

private void initPaint() {
boardPaint = new Paint();
boardPaint.setAntiAlias(true);
boardPaint.setStyle(Paint.Style.FILL);
boardPaint.setColor(Color.WHITE);
boardPaint.setShadowLayer(5.0f, 0.0f, 2.0f, 0xFF000000);
}

void setCurrentHeight(float newHeight) {
height = newHeight;
y = BOARD_BOTTOM - height;
}

void draw(Canvas canvas) {
rect.set(BOARD_LEFT, y, BOARD_LEFT + BOARD_WIDTH, y + height);
canvas.drawRoundRect(rect, radius, radius, boardPaint);
}
}

Step 2. Create Emotion.java

A lot of people are curious about Facebook Reactions under the hood. They unleashed the beast last year. It is https://facebookincubator.github.io/Keyframes. The library is not easy to use as it is documented. They instructed us to set a Drawable to an ImageView. And that’s it. In our case, we have no ImageView at all but a Canvas . I did spend much time to figure out how to get KeyframesDrawable worked with the Canvas . Look at the code:

public class Emotion {

static final int SMALL_SIZE = DisplayUtil.dpToPx(24);
static final int MEDIUM_SIZE = DisplayUtil.dpToPx(32);
static final int LARGE_SIZE = DisplayUtil.dpToPx(72);
int size = 0;

int startAnimatedSize;
int endAnimatedSize;

float x;
float y;

float startAnimatedX;

float startAnimatedY;
float endAnimatedY;

private KeyframesDrawable imageDrawable;

private Rect imageBound;
private Context context;

Emotion(Context context, String title, String imageResource) {
this.context = context;

imageDrawable = new KeyframesDrawableBuilder()
.withImage(getKFImage(imageResource))
.build();
imageDrawable.startAnimation();

imageBound = new Rect();
}

private KFImage getKFImage(String fileName) {
AssetManager assetManager = context.getAssets();

InputStream stream;
KFImage kfImage = null;

try {
stream = assetManager.open(fileName);
kfImage = KFImageDeserializer.deserialize(stream);
} catch (IOException e) {
e.printStackTrace();
}

return kfImage;
}

void draw(final Canvas canvas) {
imageBound.set((int)x,(int) y, (int)x + size, (int)y + size);
imageDrawable.setBounds(imageBound);
imageDrawable.draw(canvas);
}

void setCurrentSize(int currentSize) {
if(currentSize > this.size){
imageDrawable.setDirectionalScale(0.5F, 0.5F,
ScaleDirection.DOWN);
}else {
imageDrawable.setDirectionalScale(0.5F, 0.5F,
ScaleDirection.UP);
}
this.size = currentSize;
}

}

Is it just simple?

To get it animated we must call the startAnimation() and trigger the draw() method over and over.

Remember every time your View changes in size, call setDirectionalScale to update the Drawable along. Otherwise the Image is clipped when View contracted or is steady when View enlarged.

This constructor is doing a bit heavy, do you have any ideas for getting initialization off the main thread?

Step 3. Create ReactionView.java

Keyframe consumes *.json files then interpreter them into animation.

public class ReactionView extends View {

private static final int SCALE_DURATION = 200;
private static final int TRANSLATION_DURATION = 800;
private static final int CHILD_TRANSLATION_DURATION = 300;
private static final int CHILD_DELAY = 100;
private static final int DRAW_DELAY = 50;

private RoundedBoard board;
private List<Emotion> emotions;
private int selectedIndex = -1;
public ReactionView(Context context) {
super(context);
init();
}
...
//other constructors
private void init() {
board = new RoundedBoard();
setLayerType(LAYER_TYPE_SOFTWARE, null);

emotions = Arrays.asList(
new Emotion(getContext(), "Angry", "Anger.json"),
new Emotion(getContext(), "Haha", "Haha.json"),
new Emotion(getContext(), "Like", "Like.json"),
new Emotion(getContext(), "Love", "Love.json"),
new Emotion(getContext(), "Sorry", "Sorry.json"),
new Emotion(getContext(), "Wow", "Wow.json"));
startAnimation(new TranslationAnimation());
}
@Override
protected void onDraw(final Canvas canvas) {
board.draw(canvas);
for (Emotion emotion : emotions) {
emotion.draw(canvas);
}
postDelayed(runnable, DRAW_DELAY);
}

}

To make the View updated constantly, We create an infinite loop with postDelayed() method.

After all initialization parts, let’s move to our first animation. In ReactionView, create TranslationAnimation class. RoundedBoard is set initially at BOARD_BOTTOM + TRANSLATION_DISTANCE then soars to BOARD_TOP . y value is also assigned because its first render happens at 0. The same for Emotion but they just move up to BOARD_TOP + VERTICAL_SPACING .

private class TranslationAnimation extends Animation {

private static final int TRANSLATION_DISTANCE = 150;
private final int EMOTION_RADIUS = Emotion.MEDIUM_SIZE / 2;

TranslationAnimation() {
setDuration(TRANSLATION_DURATION);
prepareRoundedBoard();
prepareEmotions();
}
private void prepareRoundedBoard() {
board.startAnimatedY = board.y = RoundedBoard.BOARD_BOTTOM + TRANSLATION_DISTANCE;
board.endAnimatedY = RoundedBoard.BOARD_TOP;
}
private void prepareEmotions() {
for (int i = 0; i < emotions.size(); i++) {
emotions.get(i).endAnimatedY = RoundedBoard.BOARD_TOP + Constants.VERTICAL_SPACING;
emotions.get(i).startAnimatedY = emotions.get(i).y = RoundedBoard.BOARD_BOTTOM + TRANSLATION_DISTANCE;

emotions.get(i).startAnimatedX
= emotions.get(i).x = i == 0 ? RoundedBoard.BOARD_LEFT
+ Constants.HORIZONTAL_SPACING + EMOTION_RADIUS
: emotions.get(i - 1).x + Emotion.MEDIUM_SIZE + Constants.HORIZONTAL_SPACING;
}
}
}

In early stage of animation, Emotion is 0 in size. It is laid out in the middle of itself when fully grown. The next one is based on its precedent plus HORIZONTAL_SPACING plus 2*R .

Before scale animation, anEmotion is denoted as X₀. As it expands, its x moves towards X₁ . SoX₁ = X₀ — (interpolatedTime * R) .

Our timeline will increment from 0 to 800 . Each Emotion will wait Index*100 . To put it another way, when an Emotion starts to move, currentTime must be greater than Index*100 . At any moment on timeline, we can figure out a progress of animation of an item by progress = (currentTime — Index*100)/300 . Of course, the progress must have an upper bound at 1 . It means, currentTime — Index*100 <= 300 .

private class TranslationAnimation extends Animation {...@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
translateEmotions(interpolatedTime);
translateRoundedBoard(interpolatedTime);
}
private void translateEmotions(float interpolatedTime) {
float currentTime = interpolatedTime * TRANSLATION_DURATION;
for (int i = 0; i < emotions.size(); i++) {

int delayOfChild = CHILD_DELAY * i;

Emotion view = emotions.get(i);
if ((currentTime > delayOfChild)) {
if((currentTime - delayOfChild)<= CHILD_TRANSLATION_DURATION){

float progressOfChild = ((currentTime - delayOfChild) / CHILD_TRANSLATION_DURATION);
view.y = view.startAnimatedY +
progressOfChild * (view.endAnimatedY - view.startAnimatedY);

view.x = view.startAnimatedX - progressOfChild * EMOTION_RADIUS;

view.setCurrentSize((int) (progressOfChild * Emotion.MEDIUM_SIZE));
} else {
view.x = view.startAnimatedX - EMOTION_RADIUS;
view.y = view.endAnimatedY;
view.setCurrentSize(Emotion.MEDIUM_SIZE);
}
}
}
}

private void translateRoundedBoard() {
Emotion firstEmoticon = emotions.get(0);
float d =
(firstEmoticon.y - firstEmoticon.startAnimatedY) / (firstEmoticon.endAnimatedY
- firstEmoticon.startAnimatedY) * (board.endAnimatedY - board.startAnimatedY);

board.y = board.startAnimatedY + d;
}
}

There is an else for if (currentTime — delayOfChild)<= CHILD_TRANSLATION_DURATION because interpolatedTime increases irrationally. That makes our condition failed before Emotion makes it to its endAnimatedY . The progress of RoundedBoard is deduced by the progress of first Emotion .

Look at what we’ve got so far:

Handling selection

if MoveTouchEvent is in Emotion limit, onSelect(index) gets fired. If selectedIndex remains the same, we just ignore.

public class ReactionView extends View {private SelectingAnimation selectingAnimation;
private DeselectAnimation deselectAnimation;
private void init() {
//...
selectingAnimation = new SelectingAnimation();
deselectAnimation = new DeselectAnimation();
}
//...
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
return true;
case MotionEvent.ACTION_MOVE:
for (int i = 0; i < emotions.size(); i++) {
if (event.getX() > emotions.get(i).x &&
event.getX() < emotions.get(i).x + emotions.get(i).size) {
onSelect(i);
break;
}
}
return true;
case MotionEvent.ACTION_UP:
onDeselect();
return true;
}
return super.onTouchEvent(event);
}
//...private void onSelect(int index) {
if (selectedIndex == index) {
return;
}

selectedIndex = index;

selectingAnimation.prepare();
startAnimation(selectingAnimation);
}

private void onDeselect() {
deselectAnimation.prepare();
startAnimation(deselectAnimation);
}
}

Selecting Animation

Before animation, we set up value for it. LARGE_SIZE is assigned for the selected Emotion while the others have value of MEDIUM_SIZE . RoundedBoard reduces its height to SCALED_DOWN_HEIGHT

private class SelectingAnimation extends Animation {

SelectingAnimation() {
setDuration(SCALE_DURATION);
}

void prepare(){
prepareEmotions();
prepareRoundedBoard();
}

private void prepareEmotions() {
for (int i = 0; i < emotions.size(); i++) {
emotions.get(i).startAnimatedSize = emotions.get(i).size;

if (i == selectedIndex) {
emotions.get(i).endAnimatedSize = Emotion.LARGE_SIZE;
} else {
emotions.get(i).endAnimatedSize = Emotion.SMALL_SIZE;
}
}
}

private void prepareRoundedBoard() {
board.startAnimatedHeight = board.height;
board.endAnimatedHeight = RoundedBoard.BOARD_SCALED_DOWN_HEIGHT;
}

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
animateRoundedBoard(interpolatedTime);
animateEmotions(interpolatedTime);
}
}

Altering size or height is explicit by a simple formula interpolatedTime*(endValue — startValue) . When Emotion has its size changed, its y moves subsequently. What immutable in this case is the BASELINE , so y is anchored inBASELINE — size .

We can do a for loop to calculate x . However, from my observation, there are two anchor points; one is x of the first Emotion , the other is x + size of the last Emotion . We compute the other x s from 1 to selectedIndex and from 4 to selectedIndex in purpose of keeping animation smooth.

public class ReactionView extends View {//...
private void animateRoundedBoard(float interpolatedTime) {
board.setCurrentHeight(board.startAnimatedHeight + (interpolatedTime *
(board.endAnimatedHeight - board.startAnimatedHeight)));
}
private void animateEmotions(float interpolatedTime) {

for (Emotion emotion : emotions) {
animateEmotionSize(emotion, interpolatedTime);
animateEmotionPosition(emotion);
}
}
private void animateEmotionSize(Emotion emotion, float interpolatedTime) {
emotion.setCurrentSize(emotion.startAnimatedSize +
(int) (interpolatedTime * (emotion.endAnimatedSize - emotion.startAnimatedSize)));
}
private void animateEmotionPosition(Emotion emotion) {
emotion.y = RoundedBoard.BASE_LINE - emotion.size;

emotions.get(0).x = RoundedBoard.LEFT + Constants.HORIZONTAL_SPACING;
emotions.get(emotions.size() - 1).x =
RoundedBoard.LEFT + RoundedBoard.WIDTH - Constants.HORIZONTAL_SPACING
- emotions.get(emotions.size() - 1).size;

for (int i = 1; i < selectedIndex; i++) {
emotions.get(i).x = emotions.get(i - 1).x +
emotions.get(i - 1).size
+ Constants.HORIZONTAL_SPACING;
}

for (int i = emotions.size() - 2; i > selectedIndex; i--) {
emotions.get(i).x = emotions.get(i + 1).x - emotions.get(i).size
- Constants.HORIZONTAL_SPACING;
}

if (selectedIndex > 0) {
emotions.get(selectedIndex).x =
emotions.get(selectedIndex - 1).x +
emotions.get(selectedIndex - 1).size + Constants.HORIZONTAL_SPACING;
}
}
}

As for DeselectAnimation, it is pretty simple. We get them back to MEDIUM_SZIE .

private class DeselectAnimation extends Animation {

DeselectAnimation() {
setDuration(SCALE_DURATION);
}

void prepare(){
prepareRoundedBoard();
prepareEmotions();
}

private void prepareEmotions() {
for (Emotion emotion : emotions) {
emotion.startAnimatedSize = emotion.size;
emotion.endAnimatedSize = Emotion.MEDIUM_SIZE;
}
}

private void prepareRoundedBoard() {
board.startAnimatedHeight = board.height;
board.endAnimatedHeight = RoundedBoard.HEIGHT;
}

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
animateRoundedBoard(interpolatedTime);
animateEmotions(interpolatedTime);
}
}

Adding a Label

Drawing a black background and text inside takes time. We can take snapshot a TextView to Bitmap instead.

Firstly, create a rounded black rectangle named label_background.xml

<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#80000000"/>
<corners android:radius="12.5dp"/>
<padding
android:bottom="2dp"
android:top="2dp"/>
</shape>

Then create view_label.xml

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="@dimen/label_width"
android:layout_height="@dimen/label_height"
android:background="@drawable/label_background"
android:gravity="center"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:textColor="@android:color/white"
tools:text="Haha"/>

Back to Emotion.java

When size is greater than MEDIUM_SIZE , the label becomes more visible. The width of Label depends on Emotion size ; width = size — MEDIUM_SIZE . Since the Label is horizontally centered to Emotion , we infer its x by Emotion.x + (size — width)/2 .

public class Emotion {private float labelRatio;
private Bitmap imageTitle;
private RectF textBound;
private Paint textPaint;
private static final int SPACING_TO_LABEL = DisplayUtil.dpToPx(16);
private static final int MAX_WIDTH_TITLE = DisplayUtil.dpToPx(64);
Emotion(Context context, String title, String imageResource) {

//...
textPaint = new Paint(Paint.FILTER_BITMAP_FLAG);
textBound = new RectF();
snapShotLabelView(title);
}
private void snapShotLabelView(String title) {
LayoutInflater inflater = LayoutInflater.from(context);
TextView labelView = (TextView) inflater.inflate(R.layout.view_label, null, false);
labelView.setText(title);

int width = (int) context.getResources().getDimension(R.dimen.label_width);
int height = (int) context.getResources().getDimension(R.dimen.label_height);

labelRatio = width / height ;
imageTitle = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444);
//Snapshot a View to Bitmap
Canvas canvas = new Canvas(imageTitle);
labelView.layout(0, 0, width, height);
labelView.draw(canvas);
}
void draw(final Canvas canvas) {
//...
drawLabel(canvas);
}
private void drawLabel(Canvas canvas) {
int width = size - MEDIUM_SIZE;
int height = (int) (width / labelRatio);

if (width <= 0 || height <= 0) return;
setAlphaTitle(Constants.MAX_ALPHA * width / MAX_WIDTH_TITLE); float x = this.x + (size - width) / 2;
float y = this.y - SPACING_TO_LABEL - height;

textBound.set(x, y, x + width, y + height);
canvas.drawBitmap(imageTitle, null, textBound, textPaint);
}
}

We’re finished! Let’s enjoy what we’ve done

Conclusion

The result may not satisfy you. There’re still mistakes to be fixed. But at least, we do our best to mimic Facebook. As for performance, I myself cannot make it the most optimized. Hopefully I’ll get feedbacks from the community to improve it.

Full source code at https://github.com/dbof10/FBReaction

Special thanks to anh @DangNguyen, @GiangNguyen for supporting me.

--

--