Build a Full Page AngularJS Slideshow Plus Magic Tricks!

slide

The Setup

Occasionally a soon-to-be married couple finds out that I do “web stuff” and somehow they conclude that they just have to have a super sentimental slideshow highlighting their journey from newborns into each other’s arms. I generally agree and just chalk up my work as a “wedding gift” Mwahahaha!

gift

And as a gift to all my web developer friends, I am going to share just how I do this in AngularJS so you can start to rake in the brownie points as well! The post is going to be broken into three parts where I show you how to build the basic slideshow using $timeout, use PreloadJS to load your assets, and then some magic tricks using ngAnimate and Greensock.

Before I get started, I have to give a huge thanks to my BFF Shane Mielke at shanemielke.com for the beautiful pictures I am using in my slideshow. Check out the demo and grab the code and let’s roll!

Demo Code

Creating the Basic Slideshow

The slideshow is actually a fairly straightforward variation of the photo slider and so I am not going to break down every line of code in this section. Please check out the post Build a Sweet Photo Slider with AngularJS Animate for an in-depth explanation of the techniques I am going to use here.

Quick Review

With that said, a few reminder points. When you are doing any animation in AngularJS be certain to include the ngAnimate submodule into your module definition.

var app = angular.module('website', ['ngAnimate', 'ui.bootstrap']);

We are using ng-repeat to iterate over a slides collection to display our slides on the page and then using ng-if to show or hide the slide if isCurrentSlideIndex returns true or not.

<img bg-image class="fullBg" ng-repeat="slide in slides"
     ng-if="isCurrentSlideIndex($index)"
     ng-src="{{slide.src}}">

Using $timeout

And now we are into new territory as we dig into how to cause the slides to move from slide to slide on a timer. Quite a few individuals asked how to accomplish this when I wrote the slider posts and so here we are! The trick to getting a slide to automatically go to the next slide is to use the $timeout service and use it to call nextSlide at a set interval.

We are going to define an interval called INTERVAL and set it to 3 seconds or 3000 milliseconds.

app.controller('MainCtrl', function ($scope, $timeout) {
    var INTERVAL = 3000,
        slides = [{id:"image00", src:"./images/image00.jpg"},
        {id:"image01", src:"./images/image01.jpg"},
        {id:"image02", src:"./images/image02.jpg"},
        {id:"image03", src:"./images/image03.jpg"},
        {id:"image04", src:"./images/image04.jpg"}];

<pre><code>function setCurrentSlideIndex(index) {
    $scope.currentIndex = index;
}

function isCurrentSlideIndex(index) {
    return $scope.currentIndex === index;
}

function nextSlide() {
    $scope.currentIndex = ($scope.currentIndex < $scope.slides.length - 1) ? ++$scope.currentIndex : 0;
    $timeout(nextSlide, INTERVAL);
}

function loadSlides() {
    $timeout(nextSlide, INTERVAL);
}

$scope.slides = slides;
$scope.currentIndex = 0;
$scope.setCurrentSlideIndex = setCurrentSlideIndex;
$scope.isCurrentSlideIndex = isCurrentSlideIndex;

loadSlides();
</code></pre>
});

We can call a function with $timeout using this pattern $timeout(FUNCTION_TO_CALL, WHEN_TO_CALL_IT) which is what we are doing in loadSlides with $timeout(nextSlide, INTERVAL). And once we call nextSlide once, we want to keep calling it; so we set it on a loop by adding $timeout(nextSlide, INTERVAL) to the nextSlide method.

The bgImage Directive

To get the images to actually be fullscreen, some JavaScript is required. This is a directive that was inspired by a jQuery plugin that I ran across some time ago and though I forgot where I saw it… Thank you! At the core of this directive we have a resizeBG function that gets element width and height and $window innerWidth and innerHeight. For those new to AngularJS, $window is just a wrapper around the browser’s native window object. And from there, we calculate dimension ratios and differences and set the height and width accordingly. It’s math folks!

app.directive('bgImage', function ($window, $timeout) {
    return function (scope, element, attrs) {
        var resizeBG = function () {
            var bgwidth = element.width();
            var bgheight = element.height();

<pre><code>        var winwidth = $window.innerWidth;
        var winheight = $window.innerHeight;

        var widthratio = winwidth / bgwidth;
        var heightratio = winheight / bgheight;

        var widthdiff = heightratio * bgwidth;
        var heightdiff = widthratio * bgheight;

        if (heightdiff > winheight) {
            element.css({
                width: winwidth + 'px',
                height: heightdiff + 'px'
            });
        } else {
            element.css({
                width: widthdiff + 'px',
                height: winheight + 'px'
            });
        }
    };

    var windowElement = angular.element($window);
    windowElement.resize(resizeBG);

    element.bind('load', function () {
        resizeBG();
    });
}
</code></pre>
});

From here we bind the resize and load events to call resizeBG so that the image gets resized right from the beginning and every time the browser is resized thereafter.

And this is the sum of the basic slideshow. It is pretty awesome what we can accomplish in less than 100 lines of JavaScript and about 10 lines of HTML. Whatever you do, do NOT tell anyone I know who happens to be getting married just how easy their slideshow was! I really want them to think I worked long and hard making their perfect day even more perfect.

Preload

createjs

The problem that I immediately ran into when I ran this on a remote server is that the images would do really funny things the first time they loaded. More often than not, one image would finally load and before it could get resized it was time for the next image. To fix this issue, we need to preload all our images up front and then start the slideshow when all the images are downloaded. We are going to use PreloadJS to preload our images in a service and notify our controller when everything is loaded.

QueueService

We are going to encapsulate the PreloadJS functionality into a service called QueueService. We are going to instantiate a new LoadQueue object and assign the result to a queue variable for use in the service. LoadQueue is the main API class for loading content and the true parameter indicates that we want our requests to use XHR. From here, we expose the queue object via a loadManifest method which takes a manifest parameter which in our case is going to be an “array of images” object.

app.factory('QueueService', function($rootScope){
    var queue = new createjs.LoadQueue(true);

<pre><code>function loadManifest(manifest) {
    queue.loadManifest(manifest);

    queue.on('progress', function(event) {
        $rootScope.$broadcast('queueProgress', event);
    });

    queue.on('complete', function() {
        $rootScope.$broadcast('queueComplete', manifest);
    });
}

return {
    loadManifest: loadManifest
}
</code></pre>
});

Calling loadManifest passes the manifest value to the loadManifest method on the queue to kick off the preloading. We are then going to listen for the progress and complete events on queue and pass the values along to the MainCtrl by calling $rootScope.$broadcast. Sending a payload with an event in AngularJS is really easy as you just add it as a second parameter to the $broadcast call. In the case of the progress event, we are broadcasting the event object which we will use to drive a progressbar in just a moment. In the case of the complete event, we are going to send back the manifest so that we can assign it to the slides property in the MainCtrl.

And now we need to inject the QueueService into our MainCtrl and instead of calling $timeout from loadSlides, we are going to call QueueService.loadManifest(slides).

app.controller('MainCtrl', function ($scope, $timeout, QueueService) {
    var INTERVAL = 3000,
        slides = [{id:"image00", src:"./images/image00.jpg"},
        {id:"image01", src:"./images/image01.jpg"},
        {id:"image02", src:"./images/image02.jpg"},
        {id:"image03", src:"./images/image03.jpg"},
        {id:"image04", src:"./images/image04.jpg"}];

<pre><code>//...

function loadSlides() {
    QueueService.loadManifest(slides);
}

$scope.$on('queueProgress', function(event, queueProgress) {
    $scope.$apply(function(){
        $scope.progress = queueProgress.progress * 100;
    });
});

$scope.$on('queueComplete', function(event, slides) {
    $scope.$apply(function(){
        $scope.slides = slides;
        $scope.loaded = true;

        $timeout(nextSlide, INTERVAL);
    });
});

$scope.progress = 0;
$scope.loaded = false;

//... 

loadSlides();
</code></pre>
});

We need to set up MainCtrl to listen for the queueProgress and queueComplete events. To listen for an event in AngularJS, we use the $scope.$on method which takes a string parameter for the event name and a callback function. Because we have payloads in our event broadcasts, we add those as our second parameter in the callback function. The event parameter contains information about the AngularJS event itself. On the queueProgress event, we set $scope.progress to the queueProgress.progress value multiplied by a hundred so we can use it on our progressbar. On the queueComplete event, we are setting $scope.slides to the value of slides and $scope.loaded to true. From here, we have everything we need to get started and so we can call $timeout(nextSlide, INTERVAL) and have a reasonable expectation of performance since the images are now loaded into memory. One more note before we move on, because PreloadJS is outside of the AngularJS domain I found that I had to call $scope.$apply to get the changes on $scope to show.

We will track our loading progress in our view with the progressbar directive from ui-bootstrap. We are toggling visibility using ng-if and showing the progressbar if progress is less than 100.


<div class="col-xs-12" ng-if="progress !== 100">
    <progressbar class="progress-striped" value="progress">{{progress | number:0}}%</progressbar>
</div>

We are also using the progress property to drive the value of the progressbar and binding the label to progress as well.

At this point, we have the images loading and cycling from one to the next automatically on an interval, but now it is time to go the extra mile and make the slideshow pretty. Because lovebirds are worth it!

Animations

greensock

In this section, we are going to create not one but three! animations and show how easy it is to jump from one to the next. By separating state from the DOM, it is really amazing what you can accomplish with relatively little code. For instance, we are going to create three animations and set the animation we want to use with some buttons. Click! Done! But… some enterprising individual (or the lovesick) could just as easily set the current animation randomly for a truly special something.

On with the fireworks! We are going to attach our animation to our images by dynamically adding a class via {{currentAnimation}}.

The HTML

<img ng-show="loaded" bg-image class="fullBg {{currentAnimation}}" ng-repeat="slide in slides"
     ng-if="isCurrentSlideIndex($index)"
     ng-src="{{slide.src}}">

The Controller

We are going to set the currentAnimation property to slide-left-animation which we will define in just a moment. We have created a method called setCurrentAnimation to update the currentAnimation property and we have created a method called isCurrentAnimation which compares the animation parameter to currentAnimation and returns true or false depending on if there is a match.

app.controller('MainCtrl', function ($scope, $timeout, QueueService) {
    //...

<pre><code>function setCurrentAnimation(animation) {
    $scope.currentAnimation = animation;
}

function isCurrentAnimation(animation) {
    return $scope.currentAnimation === animation;
}

//... 

$scope.currentAnimation = 'slide-left-animation';

//... 

$scope.setCurrentAnimation = setCurrentAnimation;
$scope.isCurrentAnimation = isCurrentAnimation;

//...
</code></pre>
});

The Animations

And so now we are dynamically setting a class on our images which is not going to do a whole lot yet but this is why I love ngAnimate! Because ngAnimate follows a CSS naming convention, we are essentially creating CSS based directives and attaching our functionality via a CSS classname. We are going to define three animations called slide-left-animation, slide-down-animation and fade-in-animation.

AngularJS is really good about following consistent conventions and so defining an animation is very much like defining anything else in AngularJS. We are going to call the animation method on our app module with the name of the animation we want and a factory method defining how the animation is supposed to work. Notice we are defined them as if they are CSS classes because ngAnimate follows a CSS naming convention. This allows AngularJS to treat CSS or JavaScript animations as if they were the same.

We are going to define functions for the enter and leave methods which are triggered when a class is added and removed. These two methods take an element and done parameter which is the element the animation is defined on and the done callback which we need to call when our animation is complete.

When the slide-left-animation is added, we are calling TweenMax.fromTo to animate the left CSS property on element from right to left over the timespan of 1 second. We are using the onComplete method to call done to let AngularJS know the animation is finished. When the slide-left-animation is removed, because the image is already in the starting position, we are going to call TweenMax.to and slide it off the left side of the screen.

app.animation('.slide-left-animation', function ($window) {
    return {
        enter: function (element, done) {
            TweenMax.fromTo(element, 1, { left: $window.innerWidth}, {left: 0, onComplete: done});
        },

<pre><code>    leave: function (element, done) {
        TweenMax.to(element, 1, {left: -$window.innerWidth, onComplete: done});
    }
};
</code></pre>
});

Once you understand that pattern of how we did the slide-left-animation it is fairly trivial to create the slide-down-animation. Instead of animating on the left property, we are going to animate on the top property using $window.innerHeight to determine our starting and stopping points.

app.animation('.slide-down-animation', function ($window) {
    return {
        enter: function (element, done) {
            TweenMax.fromTo(element, 1, { top: -$window.innerHeight}, {top: 0, onComplete: done});
        },

<pre><code>    leave: function (element, done) {
        TweenMax.to(element, 1, {top: $window.innerHeight, onComplete: done});
    }
};
</code></pre>
});

And to create fade-in-animation we are using the exact same pattern except we are animating on the opacity property and setting it from 0 to 1 and vice versa.

app.animation('.fade-in-animation', function ($window) {
    return {
        enter: function (element, done) {
            TweenMax.fromTo(element, 1, { opacity: 0}, {opacity: 1, onComplete: done});
        },

<pre><code>    leave: function (element, done) {
        TweenMax.to(element, 1, {opacity: 0, onComplete: done});
    }
};
</code></pre>
});

At this point we have the ability to set the current animation in MainCtrl and the animations defined on the application module and now we just need to wire it up in the view. We are going to create a navigation element with a button for each of the animations. When one of the buttons is clicked, we are calling setCurrentAnimation and sending in the corresponding name for the selected animation. We are also attaching an active class to the list element if the currentAnimation property matches the animation that element represents using ng-class.


<ul ng-show="loaded" class="nav nav-pills">
<li ng-class="{'active':isCurrentAnimation('slide-left-animation')}"><a ng-click="setCurrentAnimation('slide-left-animation')">LEFT</a></li>
<li ng-class="{'active':isCurrentAnimation('slide-down-animation')}"><a ng-click="setCurrentAnimation('slide-down-animation')">DOWN</a></li>
<li ng-class="{'active':isCurrentAnimation('fade-in-animation')}"><a ng-click="setCurrentAnimation('fade-in-animation')">FADE</a></li>
</ul>

And that completes this tutorial! At this point, I wish people would never get married!

Review

Just to recap the topics we have covered:

  • Use $timeout to call a method at a specific interval. Use $timeout within that method to keep calling that method at a specific interval.
  • Setting images to full width and height requires some imperative logic but can be streamlined when you encapsulate that behavior into a directive.
  • PreloadJS is a great way to preload your assets and we learned how to encapsulate its behavior into a service for reuse.
  • Because animations can be attached via CSS classes, we can dynamically attach animations using ng-class

And here is the entire MainCtrl so we can see all of these concepts together.

app.controller('MainCtrl', function ($scope, $timeout, QueueService) {
    var INTERVAL = 3000,
        slides = [{id:"image00", src:"./images/image00.jpg"},
        {id:"image01", src:"./images/image01.jpg"},
        {id:"image02", src:"./images/image02.jpg"},
        {id:"image03", src:"./images/image03.jpg"},
        {id:"image04", src:"./images/image04.jpg"}];

<pre><code>function setCurrentSlideIndex(index) {
    $scope.currentIndex = index;
}

function isCurrentSlideIndex(index) {
    return $scope.currentIndex === index;
}

function nextSlide() {
    $scope.currentIndex = ($scope.currentIndex < $scope.slides.length - 1) ? ++$scope.currentIndex : 0;
    $timeout(nextSlide, INTERVAL);
}

function setCurrentAnimation(animation) {
    $scope.currentAnimation = animation;
}

function isCurrentAnimation(animation) {
    return $scope.currentAnimation === animation;
}

function loadSlides() {
    QueueService.loadManifest(slides);
}

$scope.$on('queueProgress', function(event, queueProgress) {
    $scope.$apply(function(){
        $scope.progress = queueProgress.progress * 100;
    });
});

$scope.$on('queueComplete', function(event, slides) {
    $scope.$apply(function(){
        $scope.slides = slides;
        $scope.loaded = true;

        $timeout(nextSlide, INTERVAL);
    });
});

$scope.progress = 0;
$scope.loaded = false;
$scope.currentIndex = 0;
$scope.currentAnimation = 'slide-left-animation';

$scope.setCurrentSlideIndex = setCurrentSlideIndex;
$scope.isCurrentSlideIndex = isCurrentSlideIndex;
$scope.setCurrentAnimation = setCurrentAnimation;
$scope.isCurrentAnimation = isCurrentAnimation;

loadSlides();
</code></pre>
});

Thanks for letting me share this project with you and if you have anything to say please drop me a line in the comments below.

Resources

Remastered Animation in AngularJS 1.2

Getting Started with the JavaScript Version of the GreenSock Animation Platform (GSAP)

Demo Code

Leave a Comment