亚巴逊首页分类导航菜单触发区域控制原理窥视

对于大型电子商务网站,不论是平台型电商还是垂直型电商,由于商品品类丰富,入口繁多,为方便用户快速定位及查询,在首页一般会挂出一个分类导航的菜单。例如国内的天猫,京东,当当,凡客,苏宁易购...国外的Amazon,Newegg等。
Like this:

对于上图呈现的菜单,常规的实现无外乎以下两种:
1,子菜单属于主菜单项的子级。Dom结构如下:

<ul>
        <li>
            <span>主菜单项1</span>
            <div style="display:none;">
                子菜单1
            </div>
        </li>
        <li>
            <span>主菜单项2</span>
            <div style="display:none;">
                子菜单2
            </div>
        </li>
</ul>

类似上述菜单的DOM结构,可以很方便的利用每个li的hover事件进行控制,实现父-子级的导航。不再赘述。


2,子菜单与主菜单项分离。Dom结构如下:

<ul>
        <li>
            <span>主菜单项1</span>
            
        </li>
        <li>
            <span>主菜单项2</span>
        </li>
</ul>
<div style="display:none;">
    子菜单1
</div>
<div style="display:none;">
    子菜单2
</div>

依然在每个li的hover事件中对子菜单的显隐进行控制,但由于子菜单不属于主菜单的子级,在li的 mouseout事件触发后,子菜单也会随之消失。这个时候常规的做法是利用延时。也就是在mouseout事件触发后,不立即隐藏子菜单。

上述两种情况实现的分类导航菜单,能满足绝大多数情况的使用需求。但就用户体验来说,依然有完善的空间。

具体说明请参考:
http://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown

主要涉及两点,一是用户鼠标移动意图的判断,二是利用延时方式时,鼠标垂直移动菜单延时触发。

 

国内的天猫,国外的亚马逊和Newegg是已知的仅有几家对以上情况有所考虑的电商公司。同时天猫和亚马逊也分属国内和国外的行业老大。这也从一个侧面说明了技术实力和市场占有率是相辅相成的。

接下来我们对类似亚马逊分类导航菜单中,对用户鼠标移动意图判断进行一些代码层面的挖掘。主要参考了上文作者编写的插件进行一些说明。开源地址如下:
https://github.com/kamens/jQuery-menu-aim

如下图所示,主要运用斜率的比较判断4点处于三角区域123之内,从而识别用户的鼠标移动意图。

在代码方面:

1, 在document级别注册mousemove事件:

$(document).mousemove(mousemoveDocument);

2, 在mousemoveDocument事件处理函数中,将用户当前鼠标的移动坐标存储于mouseLocs数组中(数组仅存储三个值,动态更新)。

mouseLocs.push({ x: e.pageX, y: e.pageY });

if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
    mouseLocs.shift();
}

3, 注册主菜单每项的mouseenter事件:

$menu
            .mouseleave(mouseleaveMenu)
            .find(options.rowSelector)
                .mouseenter(mouseenterRow)
                .mouseleave(mouseleaveRow)
                .click(clickRow);

4, 在mouseenterRow事件处理函数中,会调用activationDelay 方法,利用斜率判断用户鼠标的移动意图:

  l  弹出数组的第一个和最后一个存储位。最后一位(loc)即当前鼠标点,第一位(prevLoc)即前一个鼠标点。

loc = mouseLocs[mouseLocs.length - 1],
         prevLoc = mouseLocs[0];

  

  l  求斜率。其中,decreasingCorner为upperRight点(示意图中的1点),increasingCorner为lowerRight点(示意图中的2点)。由此可见,所参考的三角区域是随着用户的鼠标移动动态更新的。

var decreasingSlope = slope(loc, decreasingCorner),
                    increasingSlope = slope(loc, increasingCorner),
                    prevDecreasingSlope = slope(prevLoc, decreasingCorner),
                    prevIncreasingSlope = slope(prevLoc, increasingCorner);

  l  斜率公式为。(题外话:斜率好像是初中数学)

function slope(a, b) {
                return (b.y - a.y) / (b.x - a.x);
            };

  l  斜率比较,实现移动区域判断。如果当前点位于所参考的三角区域之类,则返回延迟值(默认为300ms),判定用户鼠标移动的目标为子菜单。

 if (decreasingSlope < prevDecreasingSlope &&
                        increasingSlope > prevIncreasingSlope) {
                lastDelayLoc = loc;
                return DELAY;
            }

  

上述代码概述了亚马逊风格菜单鼠标触发区域判断实现的基本思路和流程。另外,我们会注意到代码中存储用户鼠标移动坐标的数组仅存储三个值(MOUSE_LOCS_TRACKED预设值为3)。大于三个值,会shift掉第一个值。

mouseLocs.push({ x: e.pageX, y: e.pageY });
if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
       mouseLocs.shift();
}

在利用数组中存储的值进行三角区域判断的时候:

loc = mouseLocs[mouseLocs.length - 1],
         prevLoc = mouseLocs[0];

由上述代码可以看到,我们仅利用了首值和尾值。那么为什么会设定数组存储三个值?(或者存储更多值,修改MOUSE_LOCS_TRACKED即可)。

其实这里涉及到一个采样求趋势的概念。如图所示:

 

我们丢弃中间的3点,直接取1,2两点,会得到一个用户鼠标移动轨迹的一个更加平缓的过渡(用户鼠标移动的趋势)。即采样越宽泛,受个体元素干扰的可能越低。

如果没有这种类似缓冲的机制,直接取3点和2点进行判断,则会得出完全迥异的结论。(用户鼠标的移动还是比较容易产生“越界”的个体的。)

Okay,代码方面就分析到这里。下面插件贴出源码:

/**
 * menu-aim is a jQuery plugin for dropdown menus that can differentiate
 * between a user trying hover over a dropdown item vs trying to navigate into
 * a submenu's contents.
 *
 * menu-aim assumes that you have are using a menu with submenus that expand
 * to the menu's right. It will fire events when the user's mouse enters a new
 * dropdown item *and* when that item is being intentionally hovered over.
 *
 * __________________________
 * | Monkeys  >|   Gorilla  |
 * | Gorillas >|   Content  |
 * | Chimps   >|   Here     |
 * |___________|____________|
 *
 * In the above example, "Gorillas" is selected and its submenu content is
 * being shown on the right. Imagine that the user's cursor is hovering over
 * "Gorillas." When they move their mouse into the "Gorilla Content" area, they
 * may briefly hover over "Chimps." This shouldn't close the "Gorilla Content"
 * area.
 *
 * This problem is normally solved using timeouts and delays. menu-aim tries to
 * solve this by detecting the direction of the user's mouse movement. This can
 * make for quicker transitions when navigating up and down the menu. The
 * experience is hopefully similar to amazon.com/'s "Shop by Department"
 * dropdown.
 *
 * Use like so:
 *
 *      $("#menu").menuAim({
 *          activate: $.noop,  // fired on row activation
 *          deactivate: $.noop  // fired on row deactivation
 *      });
 *
 *  ...to receive events when a menu's row has been purposefully (de)activated.
 *
 * The following options can be passed to menuAim. All functions execute with
 * the relevant row's HTML element as the execution context ('this'):
 *
 *      .menuAim({
 *          // Function to call when a row is purposefully activated. Use this
 *          // to show a submenu's content for the activated row.
 *          activate: function() {},
 *
 *          // Function to call when a row is deactivated.
 *          deactivate: function() {},
 *
 *          // Function to call when mouse enters a menu row. Entering a row
 *          // does not mean the row has been activated, as the user may be
 *          // mousing over to a submenu.
 *          enter: function() {},
 *
 *          // Function to call when mouse exits a menu row.
 *          exit: function() {},
 *
 *          // Selector for identifying which elements in the menu are rows
 *          // that can trigger the above events. Defaults to "> li".
 *          rowSelector: "> li",
 *
 *          // You may have some menu rows that aren't submenus and therefore
 *          // shouldn't ever need to "activate." If so, filter submenu rows w/
 *          // this selector. Defaults to "*" (all elements).
 *          submenuSelector: "*",
 *
 *          // Direction the submenu opens relative to the main menu. Can be
 *          // left, right, above, or below. Defaults to "right".
 *          submenuDirection: "right"
 *      });
 *
 * https://github.com/kamens/jQuery-menu-aim
 * @mcmurphy fixed some bug in this plugin.
*/
(function ($) {

    $.fn.menuAim = function (opts) {
        //cache current menuSelector @mcmurphy
        opts.menuSelector = $(this).selector; 

        // Initialize menu-aim for all elements in jQuery collection
        this.each(function () {
            init.call(this, opts);
        });

        return this;
    };

    function init(opts) {
        var $menu = $(this),
            activeRow = null,
            mouseLocs = [],
            lastDelayLoc = null,
            timeoutId = null,
            activeMenuSelector = null,   //current active submenu @mcmurphy
            menuSelector = opts.menuSelector, //cache row menu selector @mcmurphy
            leaveMenu = false,              //cache if the mouse leave the menu bound @mcmurphy
            options = $.extend({
                rowSelector: "> li",
                submenuSelector: "*",
                submenuDirection: "right",
                tolerance: 75,  // bigger = more forgivey when entering submenu
                enter: $.noop,
                exit: $.noop,
                activate: $.noop,
                deactivate: $.noop,
                exitMenu: $.noop
            }, opts);

        var MOUSE_LOCS_TRACKED = 3,  // number of past mouse locations to track
            DELAY = 300;  // ms delay when user appears to be entering submenu

        /**
        * Keep track of the last few locations of the mouse.
        */
        var mousemoveDocument = function (e) {
            mouseLocs.push({ x: e.pageX, y: e.pageY });

            if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
                mouseLocs.shift();
            }

            /*
                fix bug:
                when mouse move out the main-menu and the sub-menu area,hide the sub-menu
                @mcmurphy
            */
            
            var rootMenuObj = $(e.target).closest(menuSelector);
            var rootSubmenuObj = $(e.target).closest(activeMenuSelector);

            if ((rootMenuObj.length === 0) && (rootSubmenuObj.length === 0)) {
                if (activeRow) {
                    options.deactivate(activeRow); 
                    activeRow = null;
                    leaveMenu = false;
                }
            }
            
        };

        /**
        * Cancel possible row activations when leaving the menu entirely
        */
        var mouseleaveMenu = function () {
            if (timeoutId) {
                clearTimeout(timeoutId);
            }

            // If exitMenu is supplied and returns true, deactivate the
            // currently active row on menu exit.
            if (options.exitMenu(this)) {
                if (activeRow) {
                    options.deactivate(activeRow); 
                }

                activeRow = null;
            }
        };

        /**
        * Trigger a possible row activation whenever entering a new row.
        */
        var mouseenterRow = function () {
            if (timeoutId) {
                // Cancel any previous activation delays
                clearTimeout(timeoutId);
            }
            leaveMenu = false;  
            options.enter(this);
            possiblyActivate(this);
        },
            mouseleaveRow = function () {
                options.exit(this);
            };

        /*
        * Immediately activate a row if the user clicks on it.
        */
        var clickRow = function () {
            activate(this);
        };

        /**
        * Activate a menu row.
        */
        var activate = function (row) {
            if (row == activeRow || leaveMenu) {
                return;
            }
            if (activeRow) {
                options.deactivate(activeRow); 
            }
            //cache the active submenu @mcmurphy
            activeMenuSelector = options.activate(row); 
            activeRow = row;
        };

        /**
        * Possibly activate a menu row. If mouse movement indicates that we
        * shouldn't activate yet because user may be trying to enter
        * a submenu's content, then delay and check again later.
        */
        var possiblyActivate = function (row) {
            var delay = activationDelay();

            if (delay) {
                timeoutId = setTimeout(function () {
                    possiblyActivate(row);
                }, delay);
            } else {
                activate(row); //catch the active submenu -- Km
            }
        };

        /**
        * Return the amount of time that should be used as a delay before the
        * currently hovered row is activated.
        *
        * Returns 0 if the activation should happen immediately. Otherwise,
        * returns the number of milliseconds that should be delayed before
        * checking again to see if the row should be activated.
        */
        var activationDelay = function () {
            if (!activeRow || !$(activeRow).is(options.submenuSelector)) {
                // If there is no other submenu row already active, then
                // go ahead and activate immediately.
                return 0;
            }
            var offset = $menu.offset(),
                    upperLeft = {
                        x: offset.left,
                        y: offset.top - options.tolerance
                    },
                    upperRight = {
                        x: offset.left + $menu.outerWidth(),
                        y: upperLeft.y
                    },
                    lowerLeft = {
                        x: offset.left,
                        y: offset.top + $menu.outerHeight() + options.tolerance
                    },
                    lowerRight = {
                        x: offset.left + $menu.outerWidth(),
                        y: lowerLeft.y
                    },
                    loc = mouseLocs[mouseLocs.length - 1],
                    prevLoc = mouseLocs[0];

            if (!loc) {
                return 0;
            }

            if (!prevLoc) {
                prevLoc = loc;
            }

            if (prevLoc.x < offset.left || prevLoc.x > lowerRight.x ||
                    prevLoc.y < offset.top || prevLoc.y > lowerRight.y) {
                // If the previous mouse location was outside of the entire
                // menu's bounds, immediately activate.
                leaveMenu = true; //@mcmurphy
                return 0;
            }

            if (lastDelayLoc &&
                        loc.x == lastDelayLoc.x && loc.y == lastDelayLoc.y) {
                // If the mouse hasn't moved since the last time we checked
                // for activation status, immediately activate.
                return 0;
            }

            // Detect if the user is moving towards the currently activated
            // submenu.
            //
            // If the mouse is heading relatively clearly towards
            // the submenu's content, we should wait and give the user more
            // time before activating a new row. If the mouse is heading
            // elsewhere, we can immediately activate a new row.
            //
            // We detect this by calculating the slope formed between the
            // current mouse location and the upper/lower right points of
            // the menu. We do the same for the previous mouse location.
            // If the current mouse location's slopes are
            // increasing/decreasing appropriately compared to the
            // previous's, we know the user is moving toward the submenu.
            //
            // Note that since the y-axis increases as the cursor moves
            // down the screen, we are looking for the slope between the
            // cursor and the upper right corner to decrease over time, not
            // increase (somewhat counterintuitively).
            function slope(a, b) {
                return (b.y - a.y) / (b.x - a.x);
            };

            var decreasingCorner = upperRight,
                    increasingCorner = lowerRight;

            // Our expectations for decreasing or increasing slope values
            // depends on which direction the submenu opens relative to the
            // main menu. By default, if the menu opens on the right, we
            // expect the slope between the cursor and the upper right
            // corner to decrease over time, as explained above. If the
            // submenu opens in a different direction, we change our slope
            // expectations.
            if (options.submenuDirection == "left") {
                decreasingCorner = lowerLeft;
                increasingCorner = upperLeft;
            } else if (options.submenuDirection == "below") {
                decreasingCorner = lowerRight;
                increasingCorner = lowerLeft;
            } else if (options.submenuDirection == "above") {
                decreasingCorner = upperLeft;
                increasingCorner = upperRight;
            }

            var decreasingSlope = slope(loc, decreasingCorner),
                    increasingSlope = slope(loc, increasingCorner),
                    prevDecreasingSlope = slope(prevLoc, decreasingCorner),
                    prevIncreasingSlope = slope(prevLoc, increasingCorner);

            if (decreasingSlope < prevDecreasingSlope &&
                        increasingSlope > prevIncreasingSlope) {
                // Mouse is moving from previous location towards the
                // currently activated submenu. Delay before activating a
                // new menu row, because user may be moving into submenu.
                lastDelayLoc = loc;
                return DELAY;
            }

            lastDelayLoc = null;
            return 0;
        };

        /**
        * Hook up initial menu events
        */
        $menu
            .mouseleave(mouseleaveMenu)
            .find(options.rowSelector)
                .mouseenter(mouseenterRow)
                .mouseleave(mouseleaveRow)
                .click(clickRow);

        $(document).mousemove(mousemoveDocument);

    };
})(jQuery);
View Code

页面DOM结构:

<div class="mainNav">
    <ul>
        <li>
        </li>
        <li>
        </li>
    </ul>
</div>
<div class="subNav" style="display: none;">
    
</div>
<div class="subNav" style="display: none;">
    
</div>
View Code

调用方式:

            $("div.mainNav").menuAim({
                activate: activateSubmenu,
                deactivate: deactivateSubmenu,
                rowSelector:">ul li"
            });
            
            //激活子菜单(示例)
            function activateSubmenu(row) {
                var $row = $(row),
                    rowIndex = $("div.mainNav").find("li").index($row),
                    submenuId = $("div.subNav").eq(rowIndex),
                    $submenu = $(submenuId);

                if ($submenu.length>0) {
                    $submenu.css({
                        display: "block"
                    });

                    $row.addClass("hover");
                    return submenuId;
                }
            }
            //隐藏子菜单(示例)
            function deactivateSubmenu(row) {
                var $row = $(row),
                    rowIndex = $("div.mainNav").find("li").index($row),
                    submenuId = $("div.subNav").eq(rowIndex);
                $(submenuId).css("display", "none");
                $row.removeClass("hover");
                return submenuId;
            }
View Code

 

感觉很久没有更新博客了。工作算不上忙,生活平缓的前进,真正的借口其实就是懒。一天一天,离自己最在乎的东西越远,越是失去了动力。小波说,人活着就是一个缓慢的被锤骟的过程。以前觉得没有谁锤得了我,这个家伙会一直生猛下去。现在看来,我就是一条慢慢萎缩下去的脉搏,终有一天会停止跳动。更可怕的你只能眼睁睁的看着这一过程的发生,什么都做不了,真他妈的可怕。

但说到底,这些都是一个读书人无病呻吟自怨自艾的状态。生活的本质是有时雄心万丈,有时忧愁惨淡。有时洒落阳光,有时沐浴阴凉。没事,该做什么做什么,站在路口别犹豫太久,选择一条路勇敢的走下去,前方什么都会有的~

posted @ 2013-08-25 14:53  麦克默菲  阅读(650)  评论(1编辑  收藏  举报