AngularJS: Scroll Animations
Introduction
Everyone has seen scroll based animations right? You know, the ones where you start scrolling down the webpage and animations start triggering around depending on how much you have scrolled. One of my favorite examples is Let's free Congress.
Now, sometimes we want to trigger an animation, but we don't want to make the entire page to rely on the scroll... maybe, just a little part of it. However, we can't trigger the animation until the user is viewing the part we want to animate, or else the animation will do all its magic without no audience. How do we do it?
The scrollPosition directive
Let me introduce you the scrollPosition directive.
.directive('scrollPosition', ['$window', '$timeout', '$parse', function($window, $timeout, $parse) {
return function(scope, element, attrs) {
var windowEl = angular.element($window)[0];
var directionMap = {
"up": 1,
"down": -1,
"left": 1,
"right": -1
};
// We retrieve the element with the scroll
scope.element = angular.element(element)[0];
// We store all the elements that listen to this event
windowEl._elementsList = $window._elementsList || [];
windowEl._elementsList.push({element: scope.element, scope: scope, attrs: attrs});
var element, direction, index, model, scrollAnimationFunction, tmpYOffset = 0, tmpXOffset = 0;
var userViewportOffset = 200;
function triggerScrollFunctions() {
for (var i = windowEl._elementsList.length - 1; i >= 0; i--) {
element = windowEl._elementsList[i].element;
if(!element.firedAnimation) {
directionY = tmpYOffset - windowEl.pageYOffset > 0 ? "up" : "down";
directionX = tmpXOffset - windowEl.pageXOffset > 0 ? "left" : "right";
tmpXOffset = windowEl.pageXOffset;
tmpYOffset = windowEl.pageYOffset;
if(element.offsetTop - userViewportOffset < windowEl.pageYOffset && element.offsetHeight > (windowEl.pageYOffset - element.offsetTop)) {
model = $parse(windowEl._elementsList[i].attrs.scrollAnimation)
scrollAnimationFunction = model(windowEl._elementsList[i].scope)
windowEl._elementsList[i].scope.$apply(function() {
element.firedAnimation = scrollAnimationFunction(directionMap[directionX]);
})
if(element.firedAnimation) {
windowEl._elementsList.splice(i, 1);
}
}
} else {
index = windowEl._elementsList.indexOf(element); //TODO: Add indexOf polyfill for IE9
if(index > 0) windowEl._elementsList.splice(index, 1);
}
};
};
windowEl.onscroll = triggerScrollFunctions;
};
}]);
This directive was used to craft the following animation.
Pretty much, when you scroll down through the page, the slider starts going up until we reach the limit, and the animation only triggers when the user is currently viewing the element to animate.
Usage
First, we are going to see how we can use this directive in your project and then we are going to break it in little pieces to understand how it works.
In the element you want to perform an animation, add the directive. In our case it was this (Jade HTML):
.row.show-for-large-up
.teaser(scroll-position, scroll-animation='fireupApplicationDesignAnimation')
.row
.large-6.columns.left-align.margin-top
h1(data-i18n="_CareerDesign_APPLICATIONDESIGNTITLE")
This tells AngularJS that when the user starts to scrolls within the area of this DOM element, it will trigger the scroll-animation function. In your controller, you should have something like this
$scope.fireupApplicationDesignAnimation = function(scrollDirection) {
scrollDirection > 0 ? reduceAmount() : aumentAmount(); // We want to increase on scrollDown
setOffsetForImage();
};
The directive sends to your controller function the direction of the scroll, so you can even perform animations based on this. Sometimes, you need to trigger the animation only once, so you need to return true
in order to do so. An example from the same page:
$scope.fireupMarketingDesignAnimation = function() {
if(!firedMarketingAnimation) {
window.animations.marketingAnimation.init();
firedMarketingAnimation = true;
return firedMarketingAnimation;
}
}
In this one, we have an animation that only needs to be triggered once. Since we don't know at which point of the scroll the user will match the DOM viewport (remember, he/she can scroll really fast!), we ought to wrap our function in a flag that ensures this only happens once.
How it works?
This directive performs the following:
- Creates an array of elements that require an onScroll eventListener (you can have multiples, they will be stored and removed in case you return a
true
value from your triggering function in order to ensure memory usage) - Adds an eventLister onScroll that checks the current user position on the webage
- Travels through all your binded elements (although I haven't had performance problems, this could be an issue if you have too many binded elements with the directive) and checks if they have triggered their animation.
- If they haven't and the user is within the viewport of the scroll, they do. This makes heavy usage of
$parse
, so make sure to read it's docs in order to understand completely. - Pops the element that was binded with the directive if the animation called within the scope's function returned a true value.
Conclusion
This directive is good when you need to perform many animations that are triggered through a specific amount of time. It's also effective when you want a specific behavior that relies on the user position, or even changing CSS3 values as you scroll. I made a Codepen to show off this, which also works in a horizontal scroll.
Basically I'm moving and rotating the ball on each scroll, depending on which direction the user goes.
Be warned though, that if the user has a kick as screen that allows him to see all your content without scroll, he might not be able to see the show. Fill up a timeout to make sure he/she does. As a final note, I'm using indexOf which it's not IE friendly, but can be easily replaced with a simple for..loop.
Written by Jose Jesus Perez Aguinaga
Related protips
3 Responses
If I scrolled fast the ball gets off the screen.
@vohof Aha! True. The directive performs the function every time the user scrolls. Since the user can scroll all the way down (in the case of the ball, all the way to the left) in a single scroll, then the directive performs this only once. The same way, if the user scrolls it reeeallly slow, the function is performed many, many times.
How do we solve this? There are two possible ways. The first one is to create a relationship between the "distance" the user scrolls and the amount of "scrolls" it performs in that distance. Let's call it "scroll speed". In the directive function, calculate that through a function and then pass it to your function the same way I'm passing the direction.
The second way is through step based scrolls. Your element has a finite height (or width, in the case of the ball). You can divide this dimension in the amount of steps you want the animation to be performed. Then, when the user scrolls, calculate at which percentage of this dimension the user is going to finish its scroll. E.g. if I scroll to the 50% of my element, then I want to perform 50 times the function given that I assigned a total of 100 times. This is useful for timeline functions, where you only need maybe 3 or 4 animations to be performed: if the user performs scrolls within the range of 0-25% your element's dimension, perform one step animation; 25-50% second step and so on.
IMHO, unless you really need something quite specific, use this directive as a trigger and THEN control your animation with a different mechanic (e.g. timeouts, intervals). I usually always return true
after the first function call to dismiss the triggering for the rest of the scrolling.
Is the scroll-animation function apart of angular (i.e. a directive), or is that a normal html attribute?
Also, how can I add a css class dynamically if the element is on screen? My first plan was to use the 'fireupMarketingDesignAnimation' callback, and add the css in that function, but I'm not sure how I could add css to the specific element.
I essentially want to animate only if the item is on screen. I have css classes that I'm already using for animations (https://github.com/daneden/animate.css), but I just want to add them dynamically based on scroll position. Any advice?