Saturday, November 23, 2013

Forward and Backward Sliding Region in Marionette

I recently worked on a project where the client had a panel or region on the page and they had a series of views they'd like to show in it in a set order, with the ability to go back.  Kind of like a wizard.  When you did one action, you'd get the next view, but you could go back as well.

I was able to implement this easily using Marionette Regions, but it was missing something.  The continuity of the views was lost as Marionette would replace an old view with the new immediately.  It wasn't the worst thing in the world, and it was mitigated by another 'control' view that helped the user navigate and know where they were.  However, I thought it would be a better experience if it appeared as if the views were sliding forward and backward as well, much like the OSX finder application when navigating directories.  

A quick search revealed that this was possible by just extending Marionette.Region and writing your own custom one.  Code for this region is below:


/*global define*/
/*jshint newcap:false */
define(function (require) {
    "use strict";
    var Marionette = require('marionette'),
        _ = require('underscore'),
        Properties = require('properties'),
        Q = require('q');

    var forward = true,
        backward = false,
        flyIn = true,
        flyOut = false;
    var Region = Marionette.Region.extend({
        initialize : function(){

        },
        //  Overridden show function that uses jquery animations to slide items in and out based on a
        // 'direction', which defaults to 'forward' or true if not present
        // views in this region should have left properties that can move them off the screen.
        show: function (view, direction) {
            var region = this;
            direction = _.isUndefined(direction, forward) ? forward : direction;
            this.ensureEl();

            var isViewClosed = view.isClosed || _.isUndefined(view.$el);

            var isDifferentView = view !== this.currentView;
            var closePromise;
            if (isDifferentView) {
                closePromise = region.close(direction);
            } else {
                closePromise = Q();
            }
            return closePromise.then(function () {
                view.render();
                var openPromise;
                if (isDifferentView || isViewClosed) {
                    openPromise = region.open(view, direction);
                } else {
                    openPromise = Q();
                }
                return openPromise.then(function () {
                    region.currentView = view;

                    Marionette.triggerMethod.call(region, "show", view);
                    Marionette.triggerMethod.call(view, "show");
                });


            })
                .fail(function (error) {
                    console.error(error.stack ? error.stack : error);
                });

        },

        open: function (view, direction) {
            // src  example
//           this.$el.empty().append(view.el);
            var region = this;

            this.$el.html(view.el);

            var outerWidth =  view.$el.outerWidth();
            if(direction === backward){
                outerWidth = -outerWidth;
            }
            view.$el.css({
                left : outerWidth,
                opacity : 0
            });

            return this.slide(view,direction, flyIn)
                .then(function(){
                    region.$el.perfectScrollbar();
                });

        },

        // Close the current view, if there is one. If there is no
        // current view, it does nothing and returns immediately.
        close: function (direction) {
            var view = this.currentView;
            var region = this;
            if (!view || view.isClosed) {
                return Q();
            }
            this.$el.perfectScrollbar('destroy');
            return this.slide(view,direction,flyOut)
                .then(function(){
                    // call 'close' or 'remove', depending on which is found
                    if (view.close) {
                        view.close();
                    }
                    else if (view.remove) {
                        view.remove();
                    }

                    Marionette.triggerMethod.call(region, "close");

                    delete region.currentView;
                });

        },


        slide : function(view, forwardorBackward, flyInOrOut){
            var deferred = Q.defer();
            var animationProps = {
                opacity : flyInOrOut ? 1 : 0
            };
            if(flyInOrOut === flyIn && forwardorBackward === forward){
                animationProps.left = 0;
            }
            if(flyInOrOut === flyOut && forwardorBackward === forward){
                animationProps.left = parseInt(view.$el.css('left'), 10) === 0 ? -view.$el.outerWidth() : 0;
            }
            if(flyInOrOut === flyIn && forwardorBackward === backward){
                animationProps.left = 0;
            }
            if(flyInOrOut === flyOut && forwardorBackward === backward){
                animationProps.left = parseInt(view.$el.css('left'), 10) === 0 ? view.$el.outerWidth() : 0;
            }

            view.$el.animate(animationProps,
                {
                    duration : Properties.slidingAnimationDuration,
                    complete: function () {
                        deferred.resolve();
                    },
                    fail: function () {
                        deferred.reject(arguments);
                    }
                });

            return deferred.promise;
        }
    });

    return Region;
});


A couple of things to note:

  • I'm defining this as an AMD module, if that's not your cup of tea, it shouldn't be that hard for you to remove it.
  • I like promises, a lot. :)  I'm using the popular Q.js library here to help me control the flow.  Again, if not your cup of tea, you can substitute in promise library of your choice or convert to pure callback style.  
  • I was using the jquery plugin perfect scrollbar.  This could easily be taken out if it's not your thing as well.  Same goes for the "Properties", just a way to keep this configurable in my app, it's only controlling the duration of the slide.
  • There is a requirement that views being shown by this region implement a positioning style that allows them to be shifted left by css properties.  I defined a CSS class called 'slide-animate' with style:  "slide-animate { position relative; left: 0px;}" and placed it on all the views that will be managed by this region.
Let's dive through the code just a bit so we understand what's happening.  First standing on the shoulders of others, the flow of code is taken from the Marionette's Region so as to be compatible.  So a lot of the code for "ensureEL()" or checking to verify a view is closed is straight from that.  

The meat is really in the show method which is responsible for closing the old view and opening the new one.  The one tricky part is that the view needs to be completely rendered so that jQuery's outerWidth() call will work correctly to determine how far we need to slide in or out.  So we render the view, and place it in the DOM, then immediately shift it out of view with the "left" property (in the case we're moving 'foward' its moved positively, otherwise negatively).  Then it's just a matter of animating in the left (and opacity for style) attribute to bring it into or out of view.  Promises are used to help me control the flow better.

A couple of things could make this better, I'm not 100% happy with my use of booleans to move forward or backward, so that could be done better, and I could probably extract out the dependency on perfectscrollbar, but I wanted to get this out there quickly since I haven't blogged in awhile.  If this is useful, I could see it being a nice Marionette plugin for the community.  Let me know what you think in the comments below!