本文翻译自: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一些常用的功能:
- 内部对象管理。除了上文出现过的inserNodes方法,dojo.dnd.Source还提供了不少方法来操作内部对象:
- getAllNodes()– 以dojo.NodeList形势返回所有内部对象。
- forInItems(fn,ctx) – 类似于dojo.forEach遍历所有内部对象。
- selectNone()、selectAll()、getSelectedNodes()、deleteSelectedNodes() – 功能与命名相同:全不选、全选、返回选中的对象、删除选中的对象。
- 另外更多方法请参见dojoreference guide。
- 复制内部对象。通常选中一个对象并移动鼠标时,选中对象就开始被拖拽,如果在选中前按下ctrl键不放,那么之前的操作就会复制选中的对象并进行拖拽。
2 多个容器间的拖拽
当然,如果页面只提供在单个列表中拖拽对象的功能,那么还不足以打动用户。于是我们给Dylan的网店UI做了一点升级:请看新的网店页面。
我们添加了哪些内容呢?首先,页面上有了三个列表:Catalog(目录)、Cart(购物车)和Wishlist(就是收藏列表)。现在你可以在这三个列表间拖放商品了,有些被标记为“缺货”(out)的商品是不能被拖放到购物车里的。
拖放对象类型
新版本的页面引入了可拖放对象的类型。注意下面代码中新出现的accept和type属性:
require([ "dojo/dom-class","dojo/dnd/Source",function(domClass,Source){ var catalog = new Source("catalogNode",{ accept: [ "inStock","outOfStock" ] }); catalog.insertNodes(false,[ { data: "Wrist watch",type: [ "inStock" ] },{ data: "Life jacket",{ data: "Toy bulldozer",{ data: "Vintage microphone",type: [ "outOfStock" ] },{ data: "TIE fighter",{ data: "Apples",{ data: "Bananas",{ data: "Tomatoes",{ 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","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/text!./itemTemplate.html","dojo/text!./avatarTemplate.html"],function(stringUtil,domConstruct,domClass,Source,template,avatarTemplate){ //旧货商品数据 var junk = [ { name: "Wrist watch",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,setListCount);在onDrop事件被触发时,更新列表上显示的商品数量。这里需要注意一点,onDrop事件仅在对象被拖放至接受它的容器中才触发,而对象被拖放到页面任何位置都会触发onDndDrop。
直接监听Topic
下面来借助“订阅/发布”模式来给我们的旧货铺添加一些动态效果:当拖拽开始时,高亮可以接受该拖拽对象的容器。
// 高亮可用的容器 function highlightTargets(show,source,nodes){ domClass.toggle("wishlistPaneNode","highlight",show); domClass.toggle("cartPaneNode",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",false)); topic.subscribe("/dnd/drop",glowTarget);避免对象多次复制
在前文中提到的对象多次复制的问题也很容易解决,只要在声明dojo.dnd.Source时设置copyOnly:true,那么在拖拽开始时Source不会移除内部对象,而只是将拷贝进行拖放。另外设置selfAccept:false可以防止被拖拽出去的copy放回源容器造成的重复问题。
下面是我们的旧货铺的最终版本:
总结
我们建立旧货铺的步骤如下:
我们还提供了旧货铺demo的源代码下载, Happy Dragging and Dropping!