Creating An Animated Slack-Inspired "Reaction" Emoticon Button In Angular 2.4.4
CAUTION: This demo doesn't work properly in Safari. I am not sure if the problem resides in Angular 2, the Web Animations API polyfill, or perhaps in the use of "transform" CSS. Or, maybe it's just because Safari is the new IE6. But, for whatever reason, I could not get this to work in Safari 10.
The other day, I was using Slack on my iPhone when I noticed that the emoticon "Reaction" buttons have a fun little animation that is not present in the desktop version. While the desktop version shows static reaction "counts", the mobile version elegantly animates the incrementing and the decrementing of each reaction value. I thought this would be a fun interaction to try and reproduce with Angular 2's animation support.
Run this demo in my JavaScript Demos project on GitHub.
Adding this kind of animation to a component is a little funky because you need to have an element within the component who's sole purpose is that of being animated. Meaning, we need a transient element - the "previous value" being animated out-of-view - that doesn't exist until the animation is needed; then, once the animation is complete, we can destroy said element. I call this "funky" simply because it fundamentally changes the markup you need in order to implement the component.
When I first started working on this emoticon button component, I thought I would change the "static" position of the animating elements to be "absolute" during the animation; then, animate them using the CSS "top" property. But, unfortunately, I couldn't figure out a cross-browser way to change the "position" property for the duration of the animation (especially not without creating additional problems).
After struggling with absolute positioning for a while, I switched over to using the CSS "transform" property, animating the "current" and "previous" elements with translateY() values. This works (less the Safari problem); but, there's something about using "translate" that just rubs me the wrong way. Probably just a personal thing, but it feels like a cop-out. Like I couldn't figure out how to actually position some element, so I just punt on the problem, hoping the "translate" will get it done. But, like I said, probably just some odd personality issue I'm having.
That said, I was able to get it working with the CSS "transform" property. So, let's take a look. First, let's look at the app component, which sets up the demo. In the app component, we are rendering a number of emoticon-button element, passing in the assigned value for each reaction count:
// Import the core angular services.
import { Component } from "@angular/core";
interface Reaction {
type: string;
value: number;
includesUser: boolean;
}
@Component({
moduleId: module.id,
selector: "my-app",
styleUrls: [ "./app.component.css" ],
template:
`
<template ngFor let-reaction [ngForOf]="reactions">
<emoticon-button
[type]="reaction.type"
[value]="reaction.value"
(click)="handleClick( reaction )">
</emoticon-button>
</template>
<p>
<a (click)="setRandomValues()">Set random values</a>.
</p>
`
})
export class AppComponent {
public reactions: Reaction[];
// I initialize the app component.
constructor() {
this.reactions = [
{
type: "smile",
value: 3,
includesUser: true
},
{
type: "simple_smile",
value: 14,
includesUser: false
},
{
type: "disappointed",
value: 99,
includesUser: false
},
{
type: "slightly_smiling_face",
value: 100,
includesUser: true
},
{
type: "wink",
value: 999,
includesUser: false
},
{
type: "neutral_face",
value: 9,
includesUser: false
},
{
type: "stuck_out_tongue",
value: 11,
includesUser: false
},
{
type: "confused",
value: 21,
includesUser: true
},
{
type: "thumbsup",
value: 1,
includesUser: true
}
];
}
// ---
// PUBLIC METODS.
// ---
// I handle the emoticon button click associated with the given reaction.
public handleClick( reaction: Reaction ) : void {
// A user can only consume a given reaction once. If the user has not yet
// consumed a reaction, we can increment the value; but, if the user has
// already consumed a reaction, a subsequent click will be treated as an
// decrement and their association to the reaction will be removed.
if ( reaction.includesUser ) {
reaction.value--;
reaction.includesUser = false;
} else {
reaction.value++;
reaction.includesUser = true;
}
}
// I assign a random value to each reaction.
public setRandomValues() : void {
this.reactions.forEach(
( reaction: Reaction ) : void => {
reaction.value = Math.floor( Math.random() * 100 );
}
);
}
}
Notice that we are handling the (click) event in the app component, not in the emoticon-button itself. This is because the emoticon-button doesn't know how to adjust the [value] based on the user's relationship to the given reaction. Only the app component has this information - whether or not the current value includes the current user - and so, only the app component can handle the click-event and assign the new value.
The emoticon-button, on the other hand, just accepts the [value] input binding and monitors it for changes. If the new value is greater than the old value, the button is "incrementing"; and, if the new value is lesser than the old value, the button is "decrementing". It's this "incrementing" and "decrementing" value transition that we are going to be animating in the component User Interface (UI).
Configuring animations in Angular 2 is a rather complex task; and, in fact, you will see that half of the code in this component file is dedicated to animation meta-data. The component markup and business logic, however, is fairly simply. We have a "current value" element that is always present and "previous value" element that is injected into the DOM (Document Object Model) during the animation.
Because these two elements have different life-cycles, you will see that they also have different animation transition states. The "current value" trigger, which always exists, transitions from the default "none" state:
- none => moving-up
- none => moving-down
The "previous value" trigger, which only exists during the period of animation, transitions from the "void" state:
- void => moving-up
- void => moving-down
After each animation is completed, I'm using an animation "done" callback to transition from the animation state back to the default "none" state:
// Import the core angular services.
import { Component } from "@angular/core";
import { OnChanges } from "@angular/core";
import { SimpleChange } from "@angular/core";
import { SimpleChanges } from "@angular/core";
// Animation-oriented imports.
import { animate } from "@angular/core";
import { AnimationTransitionEvent } from "@angular/core";
import { state } from "@angular/core";
import { style } from "@angular/core";
import { transition } from "@angular/core";
import { trigger } from "@angular/core";
interface InputChanges extends SimpleChanges {
type?: SimpleChange;
value?: SimpleChange;
}
// We have two different elements (next value and previous value) being animated in
// unison. In order to keep both animations in sync, we'll define one timing value and
// then just reuse that in all of the animate() calls.
var valueAnimationTiming = "200ms ease-in-out";
@Component({
moduleId: module.id,
selector: "emoticon-button",
inputs: [ "type", "value" ],
animations: [
trigger(
"currentValue",
[
// Transition into view, from below.
transition(
"none => moving-up",
[
style({
opacity: "0",
transform: "translateY( 100% )"
}),
animate(
valueAnimationTiming,
style({
opacity: "1",
transform: "translateY( 0% )"
})
)
]
),
// Transition into view, from above.
transition(
"none => moving-down",
[
style({
opacity: "0",
transform: "translateY( -100% )"
}),
animate(
valueAnimationTiming,
style({
opacity: "1",
transform: "translateY( 0% )"
})
)
]
)
]
),
trigger(
"previousValue",
[
// The actual DOM element for the previous value is only present during
// the animation itself (see ngIf in template). As such, it won't be
// transitioning from the "none" default state - it will be transitioning
// into existence, from the "void" state.
// --
// Transition out of view, to above.
transition(
"void => moving-up",
[
style({
opacity: "1",
transform: "translateY( -100% )"
}),
animate(
valueAnimationTiming,
style({
opacity: "0",
transform: "translateY( -200% )"
})
)
]
),
// Transition out of view, to below.
transition(
"void => moving-down",
[
style({
opacity: "1",
transform: "translateY( -100% )"
}),
animate(
valueAnimationTiming,
style({
opacity: "0",
transform: "translateY( 0% )"
})
)
]
)
]
)
],
styleUrls: [ "./emoticon-button.component.css" ],
template:
`
<span class="emoticon emoticon--{{ type }}"></span>
<span class="counter">
<span
[@currentValue]="valueState"
(@currentValue.done)="handleAnimationDone( $event )"
class="current-value">
{{ value }}
</span>
<!--
We only need to include the previous-value when we are animating the
increment / decrement. That means that the previous-value is never in
the "none" state - it goes directly from "void" to an animation state.
-->
<span
*ngIf="( valueState !== 'none' )"
[@previousValue]="valueState"
class="previous-value">
{{ previousValue }}
</span>
</span>
`
})
export class EmoticonButtonComponent implements OnChanges {
public previousValue: number;
public type: string;
public value: number;
public valueState: string;
// I initialize the emoticon button component.
constructor() {
this.previousValue = 0;
this.type = "smile";
this.value = 0;
this.valueState = "none";
}
// ---
// PUBLIC METHODS.
// ---
// I handle the animation "done" callback event.
public handleAnimationDone( event: AnimationTransitionEvent ) : void {
// CAUTION: If an animation transition is interrupted by a state-change, the
// "done" callback will be fired for the interrupted transition. In that case,
// the "toState" of the event will not match the "viewState" of the component. We
// can use this fact to only "reset" the state when we have an expected outcome.
if ( this.valueState !== "none" && ( this.valueState === event.toState ) ) {
this.valueState = "none";
}
}
// I get called whenever the bound inputs change (including the first binding).
public ngOnChanges( changes: InputChanges ) : void {
// After the value is initialized, subsequent changes to the value will be
// classified as "moving-up" or "moving-down" actions, which will be given
// some animation goodness.
if ( changes.value && ! changes.value.isFirstChange() ) {
this.previousValue = changes.value.previousValue;
// Determine "direction" of value change for animation.
this.valueState = ( changes.value.currentValue > changes.value.previousValue )
? "moving-up" // Incrementing the value.
: "moving-down" // Decrementing the value.
;
}
}
}
As you can see, I'm using the CSS "transform" property to animate the current and previous value elements. While I don't love using translateY() - as described above - using it does have one nice benefit: the elements are inline before they are translated. That means the the parent element will right-size itself to the width of the largest value, even during the animation. This actually leads to a nice user experience (UX).
The actual CSS of this demo isn't all that interesting. But, since this is a demo about animation, I might as well include it:
:host {
background-color: #F4FAFF ;
border: 1px solid #BCE1FE ;
border-radius: 4px 4px 4px 4px ;
color: #69BCFC ;
cursor: pointer ;
display: inline-block ;
font-size: 14px ;
overflow: hidden ;
padding: 0px 6px 0px 32px ;
position: relative ;
user-select: none ;
-moz-user-select: none ;
-webkit-user-select: none ;
}
.emoticon {
background-image: url( "./emoticons.png" ) ; /* CAUTION: Relevant to root of app */
background-position: 0px 0px ;
background-repeat: no-repeat ;
display: inline-block ;
height: 22px ;
left: 5px ;
position: absolute ;
top: 4px ;
width: 22px ;
}
.emoticon--smile {
background-position: 0px 0px ;
}
.emoticon--simple_smile {
background-position: -36px 0px ;
}
.emoticon--disappointed {
background-position: -73px 0px ;
}
.emoticon--slightly_smiling_face {
background-position: -110px 0px ;
}
.emoticon--wink {
background-position: -147px 0px ;
}
.emoticon--neutral_face {
background-position: -184px 0px ;
}
.emoticon--stuck_out_tongue {
background-position: -221px 0px ;
}
.emoticon--confused {
background-position: -258px 0px ;
}
.emoticon--thumbsup {
background-position: -295px 0px ;
}
.counter {
display: block ;
font-family: monospace, sans-serif, helvetica, arial ;
height: 30px ;
line-height: 31px ;
overflow: hidden ;
}
.current-value {
display: block ;
}
.previous-value {
display: block ;
}
As you can see, the CSS isn't really relevant to the animation itself - the animation is driven by the component animation meta-data.
Now, if we run this application and click on one of the emoticon buttons, you we can see that the new value animates in and the previous value animates out:
While this is a "working" Angular 2 animation demo, I hit a number of road-blocks and had to make a number of compromises in the way that I wanted to build it. Working with animations in Angular 2 is rather complex and I don't yet have any confidence in my understanding of how it all fits together. Clearly, much more exploration is needed.
Want to use code from this post? Check out the license.
Reader Comments
Animations in Angular is the one area I've never gotten very deep into, even back in all my time in 1.x. I've done some simple things, and applied some through libraries I've used, but never dug into it much myself. Thanks for this post.
You may have some snags, but it's a really great view into how it works. I love Slack, so a pretty cool feature you've recreated. Nice job!
@John,
Thanks my man! Animations are pretty cool, but I have a love-hate relationship with them. On the one hand, I think they create a nice, organic experience; but, on the other hand, I think there are far too many slow and distracting animations. But, that's more of a "people" problem than a "technology" problem :D
Animations in NG2 are radically different than they were in NG1. But, I've been told there is going to be some additional work to port NG1-style, class-based animations into NG2. We'll see.
@Ben,
Yeah. I do love them because done right, they add a lot. Though when they're done poorly, they take away from it. Unfortunately, they're often overdone. It's like the PowerPoint problem of old, there every slide changes a different direction and every piece of text flips, zoom, spins, or slides. Some people of have just moved that "design pattern" over to the web.