Monday, January 19, 2015

angular ng-show with slide

if you just want the final solution jump to the end, or this final jsBin: http://jsbin.com/jukogewobo/5/.

NOW IN GITHUB https://github.com/bresleveloper/bNgSlide

a common case in web dev is the "Mega Menu", while you have a menu bar that should open under it another big menu.

for this tutorial all my examples are shown in JSBINs, each part has its own link.
the basic html I am using for part 1 looks like this:

    <li ng-repeat="level1 in data"
        ng-mouseenter="toggle($event, true)"
        ng-mouseleave="toggle($event, false)">   
         <div> //html </div>
    </li>
the "li" is the repeater and event fire, and in the "div" the content to show/hide.

part 1: simple logic (JSBIN)

in Example 1 we see the "simple" solution with show/hide, nothing much to tell, it works great.
   
$scope.toggle = function (e, toggle) {
    $(e.currentTarget).find("div")[toggle ? 'show' : 'hide']();
};

in Example 2, where we want to start using animation, runing with the mouse on top of the menu items start making an animation queue.

$(e.currentTarget).find("div")[toggle ? 'slideDown' : 'slideUp']();

so, you should say, use jQuery stop! and you're right!

Examples 3 and 4 shows the wrong and right way to use it, and the right way is to stop everything before we start the new slideDown, while sending (true, true), to clean queue and finish the last "slideUp"

$(e.currentTarget).closest('ul').find("div").stop(true, true);
$(e.currentTarget).find("div")[toggle ? 'slideDown' : 'slideUp']();

but truly all that is not really enough, we want the slide to happen when the user actually stops the mouse on our item.

there is a pretty famous jQuery plugin named hoverIntent, that actually does some setTimeout, so we'll implement something pretty close, only in the angular way using $timeout.

Examples 5 and 6 shows the wrong and right way to use it, and the right way is remember that only slideDown needs to be delayed, but the slideUp needs to be instant.

$timeout.cancel($scope.lastToggleDownTimeout);
if (toggle) {
    $scope.lastToggleDownTimeout = $timeout(function () {
        $(e.currentTarget).find("div").slideDown();
    }, 300);
}
else {
    $(e.currentTarget).find("div").slideUp();
}





part 2: using directives (JSBIN)

so all that is cool, but I guess anyone would like to wrap all that into a nice directive that will simulate ng-show, after all in a more common case we would use ng-show.

for part 2 our case study will be a little more realistic mega menu, so my new html is:

   <div class="outer" ng-mouseleave="data2 = {}">
      <ul>
         <li ng-repeat="level1 in data"
             ng-mouseenter="$root.data2 = level1">
            <a href="#">{{ level1.name }}</a>
         </li>
      </ul>
      <div class="inner" ng-show="data2.data">
         <ul>
            <li ng-repeat="level2 in data2.data">
               <a href="#">{{ level2 }}</a>
            </li>
         </ul>
      </div>
   </div>
lets take a look at Example 1, i hope you like the green BG :)

so lets start by making our own ngShow, i'll name it bresleveloper-show1, and similar to ng-show i'll just toggle a slide instead of a class, Example 2:

* for sake oh honesty, I evolved this http://jsfiddle.net/g/Bs66R/1/.
(http://stackoverflow.com/questions/14775751/angular-js-how-can-i-animate-on-model-change)

directive('bresleveloperShow1',function(){
    return {
        link: function (scope, element, attrs) {
            element.hide();
                scope.$watch(
                    attrs.bresleveloperShow1,
                    function (val, oldVal) {
                        element[val ? 'slideDown' : 'slideUp'](300);
                    });
        }
    };
})
 


looks good!

but sometime the client wants it to close and open for each one, lets try just moving the "mouseout" event from the container to the "li" element itself, as shown in Example 3, which causes us the same original problem of queue animations, and with now with the new html even the data doesn't sync.

<li ng-repeat="level1 in data"
    ng-mouseenter="$root.data3 = level1.data"
    ng-mouseleave="$root.data3 = []">

so the technique I learned from our friend is change the data in the scope in a $timeout with the same time of the animation, as shown in Example 4, with directive bresleveloper-show2 where I changed the $watch

clearTimeout(scope.timeout);
if (!val) {
    element.slideUp(300);
}
else {
    scope.timeout = setTimeout(function () {
        element.slideDown(300);
    }, 200);
}


yet there is still a problem, in the example I made if you could see it, the data is still not synced well with the slide, you can create glitches while the slideUp is running.

the simple solution in Example 5 is to change the data with the same timeout:

<li ng-repeat="level1 in data"
    ng-mouseenter="changeData5(level1.data)" 
    ng-mouseleave="changeData5([])">

$scope.changeData5 = function (data5) {
   $scope.data5ChangeTimeout = $timeout(function () {
      $scope.data5 = data5;
   }, 300);
};



part 3: final directive

so all that is good... but we all want 1 directive to rule them all!

my thinking is to move the changing data function to the directive and let is manage all the timings

this is my final result:

http://jsbin.com/jukogewobo/5/

there are now 3 attribures
bresleveloper-ng-show="scope function name"
b-time="300"
b-delay="100"
b-test="children"

bresleveloper-ng-show takes a scope function name, override it, and toggleSlide before and after execution

b-time is the time for the animation, if not stated is 300

b-delay is the time for the delay before firing the animation (hover intent), if not stated is same as b-time

b-test is for a case where you don't want the animation to occur, in case of a null array or reference, so it loops all the argument of the scope function above and if it does not have this member (i.e. if (!args[b-test])) the slideDown will not occur





No comments:

Post a Comment