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
- Pop up stage
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 constructorsprivate 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.