本文翻译自:http://www.sitepen.com/blog/2011/12/05/dojo-drag-n-drop-redux/
原文作者:Colin Snover
译者:Ruan Qi
拖拽(dojo/dnd)作为Dojo的基础功能之一,可视化地支持页面元素或对象在多个容器之间拖放。Dojo/dnd还支持同时拖拽多个对象;另外还可以制定规则过滤拖放对象的目标容器,比如“桌子”应该被放在“家具”容器内,而不该放在“家电”容器中。下面通过一个有趣的故事,开始我们的Dojo拖拽功能实践。
1 单个容器内的拖拽
首先来介绍一下Dylan,Dylan这家伙最大爱好就是收集二手旧货。刚才他决定把一部分旧货处理掉,腾出地方来放新的破烂货。这不,他在当地租了个店铺开始经营旧货铺的小生意。和所有野心勃勃的实体店老板一样,Dylan也决定开个网店兜售他的旧货。Dylan有个正在攻读市场经济学位的弟弟,他建议为了让Dylan的网上旧货铺与众不同,得搞个自己的品牌。两兄弟思来想去,决定用Dylan’sOriginal做为他们铺子的商标。 Dylan决定给自己的网上店铺加上有着酷到一塌糊涂的用户体验的功能,顾客不由自主地就会买他家的二手旧货。所以Dylan就把我们请来了,我们给他做了个叫Dylan’s Original JunkOutlet的Demo来演示页面上的拖拽效果。
我们以最基础的拖拽功能进行演示,目标是一个可以由用户来动态排序的列表。首先得完成页面的总体UI框架,导入了Dojo工具包以及一点点CSS。请看最初的页面。
可以看到该页面包含一个简单的列表,列表里是Dylan最近想出售的二手货:手表、救生衣、玩具推土机、老式手机和一个玩具小飞机。
<div id="store"> <div class="wishlistContainer"> <h2>Wishlist</h2> <ol id="wishlistNode" class="container"> <li>Wrist watch</li> <li>Life jacket</li> <li>Toy bulldozer</li> <li>Vintage microphone</li> <li>TIE fighter</li> </ol> </div> </div>
拖拽基础类:dojo/dnd/Source
Dojo提供了dojo.dnd.Source类来实现拖拽效果,Source相当于一个容器,包含于其中的对象就有了可以被拖放的能力,下文中都以“内部对象”指代Source中可多拽的子项。下面的代码(本文中的相关代码的dojo版本均为1.7)实现了一个内部对象可拖放的列表:
require([ "dojo/dnd/Source", "dojo/domReady!" ], function(Source){ var wishlist = new Source("wishlistNode"); wishlist.insertNodes(false, [ "Wrist watch", "Life jacket", "Toy bulldozer", "Vintage microphone", "TIE fighter" ]); });
这就行了,看这个可排序的列表。如果你更喜欢通过声明的方式创建Dojo控件,那么请参见以下的代码:
<ol data-dojo-type="dojo.dnd.Source" id="wishlistNode" class="container"> <li class="dojoDndItem">Wrist watch</li> <li class="dojoDndItem">Life jacket</li> <li class="dojoDndItem">Toy bulldozer</li> <li class="dojoDndItem">Vintage microphone</li> <li class="dojoDndItem">TIE fighter</li> </ol>
两者的运行结果是一模一样的,请看通过声明方式创建的可排序列表。
通过声明式创建可拖放列表, dojo.dnd.Source会根据容器的HTML标签创建不同的子节点,主要有以下几种:
- 如果容器是<div>或者<p>,子节点是<div>.
- 如果容器是<ul>或者<ol>,子节点是<li>.
- 如果容器是<table>,那么首先创建<tbody>,然后在<tbody>下添加子节点<tr><td>.
- 其他情况下,子节点形势都是<span>.
下面介绍下dojo.dnd.Source一些常用的功能:
- 多选。同时拖拽多个对象是很基本的需求,dojo.dnd.Source当然是支持这项功能的。操作方式符合标准:ctrl+鼠标点击,或是shift+鼠标点击。
- 内部对象管理。除了上文出现过的inserNodes方法,dojo.dnd.Source还提供了不少方法来操作内部对象:
- getAllNodes()– 以dojo.NodeList形势返回所有内部对象。
- forInItems(fn,ctx) – 类似于dojo.forEach遍历所有内部对象。
- selectNone()、selectAll()、getSelectedNodes()、deleteSelectedNodes() – 功能与命名相同:全不选、全选、返回选中的对象、删除选中的对象。
- 另外更多方法请参见dojoreference guide。
- 复制内部对象。通常选中一个对象并移动鼠标时,选中对象就开始被拖拽,如果在选中前按下ctrl键不放,那么之前的操作就会复制选中的对象并进行拖拽。
取消拖拽。点击ESC键取消拖拽。
自定义拖放图标。可以自定义拖拽时自动生成的图标,下文会有详细介绍。
2 多个容器间的拖拽
当然,如果页面只提供在单个列表中拖拽对象的功能,那么还不足以打动用户。于是我们给Dylan的网店UI做了一点升级:请看新的网店页面。
我们添加了哪些内容呢?首先,页面上有了三个列表:Catalog(目录)、Cart(购物车)和Wishlist(就是收藏列表)。现在你可以在这三个列表间拖放商品了,有些被标记为“缺货”(out)的商品是不能被拖放到购物车里的。
拖放对象类型
新版本的页面引入了可拖放对象的类型。注意下面代码中新出现的accept和type属性:
require([ "dojo/dom-class", "dojo/dnd/Source", "dojo/domReady!" ], function(domClass, Source){ var catalog = new Source("catalogNode", { accept: [ "inStock", "outOfStock" ] }); catalog.insertNodes(false, [ { data: "Wrist watch", type: [ "inStock" ] }, { data: "Life jacket", type: [ "inStock" ] }, { data: "Toy bulldozer", type: [ "inStock" ] }, { data: "Vintage microphone", type: [ "outOfStock" ] }, { data: "TIE fighter", type: [ "outOfStock" ] }, { data: "Apples", type: [ "inStock" ] }, { data: "Bananas", type: [ "inStock" ] }, { data: "Tomatoes", type: [ "outOfStock" ] }, { data: "Bread", type: [ "inStock" ] } ]); catalog.forInItems(function(item, id, map){ domClass.add(id, item.type[0]); }); var cart = new Source("cartNode", { accept: [ "inStock" ] }); var wishlist = new Source("wishlistNode", { accept: [ "inStock", "outOfStock" ] }); });
通过声明方式创建三个列表的代码如下:
<div class="catalogContainer"> <h2>Catalog</h2> <ul data-dojo-type="dojo.dnd.Source" id="catalogNode" class="container" data-dojo-props="accept: [ 'inStock', 'outOfStock' ]" > <li class="dojoDndItem inStock" dndType="inStock">Wrist watch</li> <li class="dojoDndItem inStock" dndType="inStock">Life jacket</li> <li class="dojoDndItem inStock" dndType="inStock">Toy bulldozer</li> <li class="dojoDndItem outOfStock" dndType="outOfStock"> Vintage microphone</li> <li class="dojoDndItem outOfStock" dndType="outOfStock"> TIE fighter</li> <li class="dojoDndItem inStock" dndType="inStock">Apples</li> <li class="dojoDndItem inStock" dndType="inStock">Bananas</li> <li class="dojoDndItem outOfStock" dndType="outOfStock"> Tomatoes</li> <li class="dojoDndItem inStock" dndType="inStock">Bread</li> </ul> </div> <div class="cartContainer"> <h2>Cart</h2> <ol data-dojo-type="dojo.dnd.Source" id="cartNode" class="container" data-dojo-props="accept: [ 'inStock' ]" > </ol> </div> <div class="wishlistContainer"> <h2>Wishlist</h2> <ol data-dojo-type="dojo.dnd.Source" id="wishlistNode" data-dojo-props="accept: [ 'inStock', 'outOfStock' ]" class="container"> </ol> </div>
每个可拖放的对象都能被指定一个或多个type。type列表与容器的accept列表中只要有一对能匹配,那么对象能被放到对应的容器中,反之就不行。type和accept的默认值都是“text”。
这里我们用“inStock”和“outOfStock”来区分商品是否缺货,同时这也决定了商品能否拖放至“购物车”列表内。如果同时拖放“有货”和“缺货”的商品到购物车,会导致整个拖放不成功。
到目前为止Dylan的网上旧货铺看起来还不错。 不过还有几个问题亟待解决:
- “目录”中的商品被添加到“购物车”或者“收藏列表”里以后,就从“目录”里消失了。除非用户使用复制操作,不然同一件商品不能被同时被添加到“购物车”和“收藏列表”中。这大大影响了用户体验。
- 如果用户使用了复制操作,那么就有可能在同一个列表中出现多个重复商品,这可不妙。
- 页面上仅有三个列表,实在有点单调,用户体验还得进一步提升。
下面继续改进我们的页面。
3 列表项的自定义
我们之前已经提到过,可拖拽的列表内部对象可以被自定义。Dylan希望他的商品目录提供商品的图像、介绍和库存数量。根据他的需求,商品的数据结构看起来该是这个样子的:
{ name: "Wrist watch", image: "watch.jpg", description: "Tell time with Swiss precision", quantity: 3 }dojo.dnd提供了自定义内部对象的方法 – creator函数,下面是代码示例:
define(["dojo/string","dojo/dom-construct","dojo/dom-class", "dojo/dnd/Source","dojo/text!./itemTemplate.html", "dojo/text!./avatarTemplate.html"], function(stringUtil,domConstruct,domClass,Source, template,avatarTemplate){ //旧货商品数据 var junk = [ { name: "Wrist watch", image: "watch.jpg", description: "Tell time with Swiss precision", quantity: 3 }, { name: "Life jacket", image: "life-jacket.jpg", description: "Stay afloat during your frequent shipwrecks", quantity: 1 }, ... ]; //根据传入的item对象构建DOM节点 function catalogNodeCreator(item, hint){ var node = domConstruct.toDom(stringUtil.substitute( hint === "avatar" ? avatarTemplate : template, { name: item.name || "Product", imageUrl: "images/" + (item.image || "_blank.gif"), quantity: item.quantity || 0, description: item.description ? "<br><span" + item.description + "</span>" : "" } )), type = item.quantity ? ["inStock"] : ["outOfStock"]; return {node: node, data: item, type: type}; } //创建Source并导入旧货数据 var junkCatalog = new Source("junkCatalogContainer", { creator: catalogNodeCreator }); junkCatalog.insertNodes(false, junk); });
以下是列表内部对象的Template(即上面代码中的itemTemplate.html):
<tr> <td class="itemImg dojoDndHandle"><img src="${imageUrl}"></td> <td class="itemText">${name} ${description}</td> <td class="itemQty">${quantity}</td> </tr>这个是我们拖拽时的对象的Template(即avatarTemplate.html):
<table> <tr> <td class="itemImg"><img src="${imageUrl}"></td> <td class="itemText">${name}</td> </tr> </table>
下图展示了item与avatar间的区别:
现在来说明对商品目录做的一些修改:
- 为了方便布局,我们使用table作为商品列表的容器,可以看到上面的itemTemplate也相应的修改为<tr><td>.
- 商品以库存数量动态的标记自己的type值.
- dojo.dnd.Source构造函数中的creator函数还接受hint参数。当hint被设为“avatar”时,creator函数构造的是被拖拽的对象的DOM结构。
来看看更新后的Dylan’s Original Outlet Store吧。
新版本的页面看起来有很大的改进,除了商品列表分为旧货商品列表和食品列表以外,收藏列表和购物车列表被放到了dijit.TitlePane里,省出了很大的页面空间。
dojo.dnd.Target
var cart = new Target("cartPaneNode", { accept: [ "inStock" ] });这里我们引入了一个新的类:dojo.dnd.Target。其实Target就是一个只能放不能拖的Source,相当于把Source类里的isSource属性设为false。有趣的是,isSource属性也可以在运行时被改变,下文中会有示例。
拖放目标容器的更改
cart.parent =dom.byId("cartNode");原本拖放到cart里的商品会直接在cartPaneNode下创建子节点,不过cart.parent被赋值之后,所有拖放至cartPaneNode里的商品都会在
<table id="cartNode">
下创建子节点。
下面再基于我们的旧货店铺介绍一些Dojo拖放的额外功能。
4 监听拖放事件
Dojo的拖拽中应用了“订阅/发布”来处理事件响应。这里我们先借助aspect模式来处理onDrop事件。
// sets the count of items in a TitlePane function setListCount(){ query(".count", this.node)[0].innerHTML = this.getAllNodes().length; } // update the cart’s displayed item count when dropped on aspect.after(cart, "onDrop", setListCount); // update the wishlist’s displayed item count when dropped on aspect.after(wishlist, "onDrop", setListCount);在onDrop事件被触发时,更新列表上显示的商品数量。这里需要注意一点,onDrop事件仅在对象被拖放至接受它的容器中才触发,而对象被拖放到页面任何位置都会触发onDndDrop。
直接监听Topic
下面来借助“订阅/发布”模式来给我们的旧货铺添加一些动态效果:当拖拽开始时,高亮可以接受该拖拽对象的容器。
// 高亮可用的容器 function highlightTargets(show, source, nodes){ domClass.toggle("wishlistPaneNode", "highlight", show); domClass.toggle("cartPaneNode", "highlight", show && arrayUtil.every(nodes, function(node){ return domClass.contains(node, "inStock"); })); } // 目标容器闪烁一次 function glowTarget(source, nodes, copy, target){ domClass.add(target.node, "glow"); setTimeout(lang.hitch(domClass, "remove", target.node, "glow"), 1000); } // 拖拽动作开始 // (/dnd/start) topic.subscribe("/dnd/start", lang.partial(highlightTargets, true)); // 拖拽动作结束 // (/dnd/cancel or /dnd/drop) topic.subscribe("/dnd/cancel, /dnd/drop", lang.partial(highlightTargets, false)); topic.subscribe("/dnd/drop", glowTarget);避免对象多次复制
在前文中提到的对象多次复制的问题也很容易解决,只要在声明dojo.dnd.Source时设置copyOnly:true,那么在拖拽开始时Source不会移除内部对象,而只是将拷贝进行拖放。另外设置selfAccept:false可以防止被拖拽出去的copy放回源容器造成的重复问题。
下面是我们的旧货铺的最终版本:
总结
我们建立旧货铺的步骤如下:
- 搭建页面框架;
- 单列表拖拽;
- 多列表拖拽;
- 可拖拽列表项的自定义;
- 事件监听和处理;
我们还提供了旧货铺demo的源代码下载, Happy Dragging and Dropping!