← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/expander-anim into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/expander-anim into lp:launchpad with lp:~danilo/launchpad/expand-diffs as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~danilo/launchpad/expander-anim/+merge/67161

= Expander animations =

This branch provides nice, reversible expander animations.  With lazr-js moved inside the tree, I add a few notes about the problems I've seen with existing slide-out, slide-in animations, and instead provide a new reversible slide out animation that is smart about pausing and restarting where appropriate.

Since I am actually refactoring the expanders, I've decided to not fix slide-out/slide-in because that would expand the already big scope.  Ideally, they would be replaced with the reversible_slide_out animation altogether.  Tests are also missing for it: hopefully not a big deal (I do want to provide them for the reversible stuff).

To actually play with it, try lp:~danilo/launchpad/replace-expanders-1.

== Tests ==

lib/lp/app/javascript/tests/test_expander.html

== Demo and Q/A ==

We can only demo and/or QA this when the follow-up branch lands.  Not used anywhere yet.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/javascript/expander.js
  lib/lp/app/javascript/lazr/effects/effects.js
  lib/lp/app/javascript/tests/test_expander.js
-- 
https://code.launchpad.net/~danilo/launchpad/expander-anim/+merge/67161
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/expander-anim into lp:launchpad.
=== modified file 'lib/lp/app/javascript/expander.js'
--- lib/lp/app/javascript/expander.js	2011-07-01 11:37:12 +0000
+++ lib/lp/app/javascript/expander.js	2011-07-07 10:44:25 +0000
@@ -66,6 +66,8 @@
         this.config = {};
     }
     this.loaded = !Y.Lang.isValue(this.config.loader);
+    this._animation = Y.lazr.effects.reversible_slide_out(
+        this.content_node);
 
     // Is setup complete?  Skip any animations until it is.
     this.fully_set_up = false;
@@ -126,9 +128,36 @@
      * Hide or reveal the content node (by adding the "unseen" class to it).
      *
      * @param expand Are we expanding?  If not, we must be collapsing.
+     * @param no_animation {Boolean} Whether to short-circuit the animation?
      */
-    foldContentNode: function(expand) {
-        this.setContentClassIf(!expand, this.css_classes.unseen);
+    foldContentNode: function(expand, no_animation) {
+        var expander = this;
+        var has_paused = false;
+        if (no_animation === true) {
+            expander.setContentClassIf(
+                !expand, expander.css_classes.unseen);
+        } else {
+            this._animation.set('reverse', !expand);
+
+            if (expand) {
+                // Show when expanding.
+                expander.setContentClassIf(
+                    false, expander.css_classes.unseen);
+            } else {
+                // Hide when collapsing but only after
+                // animation is complete.
+                this._animation.once('end', function() {
+                    // Only hide if the direction has not been
+                    // changed in the meantime.
+                    if (this.get('reverse')) {
+                        expander.setContentClassIf(
+                            true, expander.css_classes.unseen);
+                    }
+                });
+            }
+
+            expander._animation.run();
+        }
     },
 
     revealIcon: function() {
@@ -154,10 +183,21 @@
      *
      * @param output A Node or NodeList to replace the contents of the content
      *     tag with.
+     * @param failed Whether loading has failed and should be retried.
      */
-    receive: function(output) {
-        // We'll animate this later (if this.fully_set_up is false).
+    receive: function(output, failed) {
+        if (failed === true) {
+            this.loaded = false;
+        }
+        var from_height = this.content_node.getStyle('height');
         this.content_node.setContent(output);
+        var to_height = this.content_node.get('scrollHeight');
+        if (this._animation.get('running')) {
+            this._animation.stop();
+        }
+        this._animation.set('to', { height: to_height });
+        this._animation.set('from', { height: from_height });
+        this._animation.run();
     },
 
     /*
@@ -175,8 +215,8 @@
      * @param expanded Whether the expander is to be rendered in its expanded
      *     state.  If not, it must be in the collapsed state.
      */
-    render: function(expanded) {
-        this.foldContentNode(expanded);
+    render: function(expanded, no_animation) {
+        this.foldContentNode(expanded, no_animation);
         this.setIcon(expanded);
         if (expanded && !this.loaded) {
             this.load();
@@ -191,11 +231,11 @@
     setUp: function() {
         var expander = this;
         function click_handler(e) {
-            e.preventDefault();
+            e.halt();
             expander.render(!expander.isExpanded());
         }
 
-        this.render(this.isExpanded());
+        this.render(this.isExpanded(), true);
         this.icon_node.on('click', click_handler);
         this.revealIcon();
         this.fully_set_up = true;
@@ -230,4 +270,4 @@
 }
 namespace.createByCSS = createByCSS;
 
-}, "0.1", {"requires": ["node"]});
+}, "0.1", {"requires": ["node", "lazr.effects"]});

=== modified file 'lib/lp/app/javascript/lazr/effects/effects.js'
--- lib/lp/app/javascript/lazr/effects/effects.js	2011-06-29 14:56:15 +0000
+++ lib/lp/app/javascript/lazr/effects/effects.js	2011-07-07 10:44:25 +0000
@@ -37,6 +37,121 @@
 
 
 /**
+ * Gets the desired total height for a node.
+ */
+function default_to_height(node) {
+    return node.get('scrollHeight');
+}
+
+/**
+ * Produces a reversible slide-out animation as an Y.Anim object.
+ *
+ * Simply changing the 'reverse' attribute will pause the animation
+ * and prepare it for restarting with run() from the point it was on.
+ *
+ * The target node obtains the 'lazr-opened' CSS class when open,
+ * 'lazr-closed' when closed.
+ *
+ * @method reversible_slide_out
+ * @public
+ * @param node {Node|HTMLElement|Selector}  The node to apply the effect to.
+ * @param user_cfg {Y.Anim config} Additional Y.Anim config parameters.
+ *     These will override the default parameters of the same name.
+ * @return {Y.Anim} A new animation instance.
+ */
+namespace.reversible_slide_out = function(node, user_cfg) {
+    var cfg = Y.merge(namespace.slide_effect_defaults, user_cfg);
+    cfg.node = node;
+
+    var node = Y.one(node);
+    if (node === null) {
+        Y.fail("A valid node, HTMLElement, or CSS3 selector must be given " +
+               "for the slide_out animation.");
+        return null;
+    }
+
+    // We don't want to stomp on what the user may have given as the
+    // from.height and to.height;
+    cfg.from        = cfg.from ? cfg.from : {};
+    cfg.from.height = cfg.from.height ? cfg.from.height : 0;
+
+    cfg.to          = cfg.to ? cfg.to : {};
+    cfg.to.height   = cfg.to.height ? cfg.to.height : default_to_height;
+
+    var anim = new Y.Anim(cfg);
+    node.addClass(OPENED);
+
+    // Set what we need to calculate the new content's scrollHeight.
+    // The call-site must ensure we can find the height (iow, display
+    // CSS class should be set properly; setting 'display: block' will
+    // mess up rendering of table rows when used on them).
+    node.setStyles({
+        height:   cfg.from.height,
+        overflow: 'hidden'
+    });
+
+    anim.after('reverseChange', function() {
+        if (this.get('running')) {
+            // Reversing an animation direction always stops it if running.
+            this.stop();
+
+            // Store the current height as appropriate for the direction.
+            var current_height = node.getStyle('height');
+            var full_height = node.get('scrollHeight');
+
+            if (this.get('reverse')) {
+                // Animate from current point to closing.
+                this.set('from', { height: '0px' });
+                this.set('to', { height: current_height });
+            } else {
+                // Animate from current point to the fully open node.
+                this.set('from', { height: current_height });
+                this.set('to', { height: full_height });
+            }
+        } else {
+            // Restore the default from/to if we are not in the middle
+            // of the animation.
+            this.set('from', { height: '0px' });
+            this.set('to', { height: default_to_height });
+        }
+
+    });
+
+    anim.on('end', function() {
+        if (this.get('reverse')) {
+            node.addClass(OPENED).removeClass(CLOSED);
+        } else {
+            node.addClass(CLOSED).removeClass(OPENED);
+        }
+    });
+    return anim;
+
+};
+
+
+/**
+ * Produces a reversible slide-in animation as an Y.Anim object.
+ *
+ * Returned anim object is a reversed slide-out animation.
+ *
+ * The target node obtains the 'lazr-opened' CSS class when open,
+ * 'lazr-closed' when closed.
+ *
+ * @method reversible_slide_in
+ * @public
+ * @param node {Node|HTMLElement|Selector}  The node to apply the effect to.
+ * @param user_cfg {Y.Anim config} Additional Y.Anim config parameters.
+ *     These will override the default parameters of the same name.
+ * @return {Y.Anim} A new animation instance.
+ */
+namespace.reversible_slide_in = function(node, user_cfg) {
+    var anim = namespace.reversible_slide_out(node, user_cfg);
+    anim.set('reverse', true);
+    return anim;
+}
+
+
+/**
  * Produces a simple slide-out drawer effect as a Y.Anim object.
  *
  * Starts by setting the container's overflow to 'hidden', display to 'block',
@@ -48,6 +163,9 @@
  * 'lazr-closed' when closed.
  *
  * This animation is reversible.
+ * XXX 20110704 Danilo:
+ * Reversing doesn't actually make the animation restart from
+ * where it was stopped, so "jerking" effect can still be seen.
  *
  * @method slide_out
  * @public
@@ -70,10 +188,6 @@
         return null;
     }
 
-    var default_to_height = function(node) {
-        return node.get('scrollHeight');
-    };
-
     // We don't want to stomp on what the user may have given as the
     // from.height and to.height;
     cfg.from        = cfg.from ? cfg.from : {};
@@ -163,6 +277,8 @@
     anim.on('start', function() {
         if (!this.drawer_closed) {
             // We're closing the draw, so hide the overflow.
+            // XXX 20110704 Danilo: this logic seems broken for reversed
+            // animations.
             node.setStyle('overflow', 'hidden');
         }
     });
@@ -171,6 +287,8 @@
         if (this.drawer_closed) {
             // We've finished opening the drawer, so show the overflow, just
             // to be safe.
+            // XXX 20110704 Danilo: this logic seems broken for reversed
+            // animations.
             this.drawer_closed = false;
             node.setStyle('overflow', 'visible')
                 .addClass(OPENED)

=== modified file 'lib/lp/app/javascript/tests/test_expander.js'
--- lib/lp/app/javascript/tests/test_expander.js	2011-07-01 11:37:12 +0000
+++ lib/lp/app/javascript/tests/test_expander.js	2011-07-07 10:44:25 +0000
@@ -6,8 +6,32 @@
     base: '../../../../canonical/launchpad/icing/yui/',
     filter: 'raw', combine: false,
     fetchCSS: false
-    }).use('test', 'console', 'node', 'node-event-simulate',
-           'lp.app.widgets.expander', function(Y) {
+}).use('base', 'test', 'console', 'node', 'node-event-simulate',
+       'lp.app.widgets.expander', function(Y) {
+
+    function FakeAnim(config) {
+        FakeAnim.superclass.constructor.apply(this, arguments);
+        this.call_stack = [];
+   }
+
+    FakeAnim.ATTRS = {
+        running: { value: false },
+        reverse: { value: false },
+        to: { value: {} },
+        from: { value: {} },
+    };
+
+    Y.extend(FakeAnim, Y.Base, {
+        stop: function() {
+            this.call_stack.push('stop');
+            this.set('running', false);
+        },
+
+        run: function() {
+            this.call_stack.push('run');
+            this.set('running', true);
+        }
+    });
 
     var suite = new Y.Test.Suite("lp.app.widgets.expander Tests");
     var module = Y.lp.app.widgets.expander;
@@ -144,6 +168,19 @@
             Y.Assert.isTrue(render_has_run);
         },
 
+        test_setUp_calls_foldContentNode_no_anim: function() {
+            var foldContentNode_animate_arg = false;
+            var fake_foldContentNode = function(expanded, no_animate) {
+                foldContentNode_animate_arg = no_animate;
+            };
+            var old_method = module.Expander.prototype.foldContentNode;
+            module.Expander.prototype.foldContentNode = fake_foldContentNode;
+            var expander = this.makeExpander();
+            expander.foldContentNode = fake_foldContentNode;
+            Y.Assert.isTrue(foldContentNode_animate_arg);
+            module.Expander.prototype.foldContentNode = old_method;
+        },
+
         test_createByCSS_creates_expander: function() {
             var root = this.makeExpanderHooks();
             this.findTestHookTag().appendChild(root);
@@ -182,7 +219,159 @@
             var children = expander.content_node.get('children');
             Y.Assert.areEqual(1, children.size());
             Y.Assert.areEqual(ajax_result, children.item(0));
-        }
+        },
+
+        test_receive_success_leaves_loaded: function() {
+            var expander = this.makeExpander();
+            Y.Assert.isTrue(expander.loaded);
+            expander.receive('');
+            Y.Assert.isTrue(expander.loaded);
+        },
+
+        test_receive_failure_resets_loaded: function() {
+            var expander = this.makeExpander();
+            Y.Assert.isTrue(expander.loaded);
+            expander.receive('', true);
+            Y.Assert.isFalse(expander.loaded);
+        },
+
+        test_receive_stops_and_restarts_animation: function() {
+            var expander = this.makeExpander();
+            var anim = new FakeAnim();
+            anim.set('running', true);
+            expander._animation = anim;
+            expander.receive('');
+            // Animation is first stopped, then restarted with run().
+            Y.ArrayAssert.itemsAreSame(
+                ['stop', 'run'], anim.call_stack);
+        },
+
+        test_receive_restarts_at_current_height: function() {
+            var expander = this.makeExpander();
+
+            var anim = new FakeAnim();
+            expander._animation = anim;
+
+            // We've got a half (well, 40%) open container node
+            // with current height at 2px.
+            var content_node = Y.Node.create('<div />')
+                .setStyle('height', '2px');
+            this.findTestHookTag().appendChild(content_node);
+            expander.content_node = content_node;
+
+            // Full desired content height of 5px.
+            var content = Y.Node.create('<div />')
+                .setStyle('height', '5px');
+
+            expander.receive(content);
+            // We get an integer from scrollHeight, and pixels from height.
+            Y.Assert.areEqual(5, anim.get('to').height);
+            Y.Assert.areEqual('2px', anim.get('from').height);
+        },
+
+        test_foldContentNode_expand_no_animation: function() {
+            var expander = this.makeExpander();
+
+            var anim = new FakeAnim();
+            expander._animation = anim;
+
+            // First parameter is true for expand, false for folding.
+            // Second parameter indicates if no animation should be used
+            // (true for no animation, anything else otherwise).
+            expander.foldContentNode(true, true);
+
+            // No anim.run() calls have been executed.
+            Y.ArrayAssert.itemsAreEqual([], anim.call_stack);
+            // And unseen CSS class has been removed.
+            Y.Assert.isFalse(
+                expander.content_node.hasClass("unseen"));
+        },
+
+        test_foldContentNode_fold_no_animation: function() {
+            var expander = this.makeExpander();
+
+            var anim = new FakeAnim();
+            expander._animation = anim;
+
+            // First parameter is true for expand, false for folding.
+            // Second parameter indicates if no animation should be used
+            // (true for no animation, anything else otherwise).
+            expander.foldContentNode(false, true);
+
+            // No anim.run() calls have been executed.
+            Y.ArrayAssert.itemsAreEqual([], anim.call_stack);
+            // And unseen CSS class has been added.
+            Y.Assert.isTrue(
+                expander.content_node.hasClass("unseen"));
+        },
+
+        test_foldContentNode_expand: function() {
+            // Expanding a content node sets the animation direction
+            // as appropriate ('reverse' to false) and removes the
+            // 'unseen' CSS class.
+            var expander = this.makeExpander();
+
+            var anim = new FakeAnim();
+            anim.set('reverse', true);
+            expander._animation = anim;
+
+            expander.foldContentNode(true);
+
+            // Reverse flag has been toggled.
+            Y.Assert.isFalse(anim.get('reverse'));
+            // 'unseen' CSS class has been removed.
+            Y.Assert.isFalse(expander.content_node.hasClass("unseen"));
+            // Animation is shown.
+            Y.ArrayAssert.itemsAreEqual(['run'], anim.call_stack);
+        },
+
+        test_foldContentNode_fold: function() {
+            // Folding a content node sets the animation direction
+            // as appropriate ('reverse' to false) and removes the
+            // 'unseen' CSS class.
+            var expander = this.makeExpander();
+
+            var anim = new FakeAnim();
+            anim.set('reverse', true);
+            expander._animation = anim;
+            // Initially expanded (with no animation).
+            expander.foldContentNode(true, true);
+
+            // Now fold it back.
+            expander.foldContentNode(false);
+
+            // Reverse flag has been toggled.
+            Y.Assert.isTrue(anim.get('reverse'));
+            // Animation is shown.
+            Y.ArrayAssert.itemsAreEqual(['run'], anim.call_stack);
+            // 'unseen' CSS class is added back, but only when
+            // the animation completes.
+            Y.Assert.isFalse(expander.content_node.hasClass("unseen"));
+            anim.fire('end');
+            Y.Assert.isTrue(expander.content_node.hasClass("unseen"));
+        },
+
+        test_foldContentNode_fold_expand: function() {
+            // Quickly folding then re-expanding a node doesn't
+            // set the 'unseen' flag.
+            var expander = this.makeExpander();
+            var anim = new FakeAnim();
+            anim.set('reverse', true);
+            expander._animation = anim;
+            // Initially expanded (with no animation).
+            expander.foldContentNode(true, true);
+
+            // Now fold it.
+            expander.foldContentNode(false);
+            Y.Assert.isFalse(expander.content_node.hasClass("unseen"));
+            // And expand it before animation was completed.
+            expander.foldContentNode(true);
+            // When animation for folding completes, it does not
+            // set the 'unseen' CSS class because expanding is now
+            // in progress instead.
+            anim.fire('end');
+            Y.Assert.isFalse(expander.content_node.hasClass("unseen"));
+        },
     }));
 
     var handle_complete = function(data) {


Follow ups