JavaScript 拖放效果

拖放效果,也叫拖拽、拖动,学名Drag-and-drop ,是最常见的js特效之一。
如果忽略很多细节,实现起来很简单,但往往细节才是难点所在。
这个程序的原型是在做图片切割效果的时候做出来的,那时参考了好几个同类的效果,跟muxrwcBlueDestiny学习了不少东西。
虽然每次整理都觉得很好了,不过每隔一段时间又会发现得某个地方可以改善,某个地方有错误,某些需求需要实现,就像自己学习的知识那样。

这里考虑到有的人可能只需要简单的拖放,所以有一个简化版的拖放SimpleDrag,方便学习。

效果预览

ps:在maxthon下如果开启广告过滤的话很可能会被过滤掉(不知有什么方法可以避免)。


拖放状态:未开始


程序说明

【程序原理】

这里以SimpleDrag为例说一下基本原理。

首先初始化程序中要一个拖放对象:

this.Drag = $(drag);


还要两个参数在开始时记录鼠标相对拖放对象的坐标:

this._x = this._y = 0;


还有两个事件对象函数用于添加移除事件:

this._fM = BindAsEventListener(thisthis.Move);
this._fS = Bind(thisthis.Stop);


分别是拖动程序和停止拖动程序。
拖放对象的position必须是absolute绝对定位:

this.Drag.style.position = "absolute";


最后把Start开始拖放程序绑定到拖放对象mousedown事件:

addEventHandler(this.Drag, "mousedown", BindAsEventListener(thisthis.Start));


鼠标在拖放对象按住,就会触发Start程序,主要是用来准备拖动,在这里记录鼠标相对拖放对象的坐标:

this._x = oEvent.clientX - this.Drag.offsetLeft;
this._y = oEvent.clientY - this.Drag.offsetTop;


并把_fM拖动程序和_fS停止拖动程序分别绑定到document的mousemove和mouseup事件:

addEventHandler(document, "mousemove"this._fM);
addEventHandler(document, 
"mouseup"this._fS);


注意要绑定到document才可以保证事件在整个窗口文档中都有效,如果只绑定到拖放对象就很容易出现拖太快就脱节的现象。

当鼠标在文档上移动时,就会触发Move程序了,这里就是实现拖动的程序。
通过现在鼠标的坐标值跟开始拖动时鼠标相对的坐标值的差就可以得到拖放对象应该设置的left和top了:

this.Drag.style.left = oEvent.clientX - this._x + "px";
this.Drag.style.top = oEvent.clientY - this._y + "px";


最后放开鼠标后就触发Stop程序结束拖放。
这里的主要作用是把Start程序中给document添加的事件移除:

removeEventHandler(document, "mousemove"this._fM);
removeEventHandler(document, 
"mouseup"this._fS);


这样一个简单的拖放程序就做好了,下面说说其他扩展和细节部分。

【拖放锁定】

锁定分三种,分别是:水平方向锁定(LockX)、垂直方向锁定(LockY)、完全锁定(Lock)。
这个比较简单,水平和垂直方向的锁定只要在Move判断是否锁定再设置left和top就行,如果是完全锁定就直接返回。

if(!this.LockX){ this.Drag.style.left = ; }
if(!this.LockY){ this.Drag.style.top = ; }


【触发对象】

触发对象是用来触发拖放程序的,程序中通过Handle属性设置。有的时候不需要整个拖放对象都用来触发,这时就需要触发对象了。
使用了触发对象后,进行移动的还是拖放对象,只是用触发对象来触发拖放(一般的使用是把触发对象放到拖放对象里面)。
ps:触发对象的另一个用法是通过设置相同的Handle,实现一个触发对象同时拖放多个拖放对象。

【范围限制】

要设置范围限制必须先把Limit设为true。范围限制分两种,分别是固定范围和容器范围限制,主要在Move程序中设置。
原理是当比较的值超过范围时,修正left和top要设置的值使拖放对象能保持在设置的范围内。

【固定范围限制】

容器范围限制就是指定上下左右的拖放范围。
各个属性的意思是:
上(mxTop):top限制;
下(mxBottom):top+offsetHeight限制;
左(mxLeft):left限制;
右(mxRight):left+offsetWidth限制。

如果范围设置不正确,可能导致上下或左右同时超过范围的情况,程序中有一个Repair程序用来修正范围参数的。
Repair程序会在程序初始化和Start程序中执行,在Repair程序中修正mxRight和mxBottom:

this.mxRight = Math.max(this.mxRight, this.mxLeft + this.Drag.offsetWidth);
this.mxBottom = Math.max(this.mxBottom, this.mxTop + this.Drag.offsetHeight);


其中mxLeft+offsetWidth和mxTop+offsetHeight分别是mxRight和mxBottom的最小范围值。

根据范围参数修正移动参数:

iLeft = Math.max(Math.min(iLeft, mxRight - this.Drag.offsetWidth), mxLeft);
iTop 
= Math.max(Math.min(iTop, mxBottom - this.Drag.offsetHeight), mxTop);


对于左边上边要取更大的值,对于右边下面就要取更小的值。

【容器范围限制】

容器范围限制的意思就是把范围限制在一个容器_mxContainer内。
要注意的是拖放对象必须包含在_mxContainer中,因为程序中是使用相对定位来设置容器范围限制的(如果是在容器外就要用绝对定位,这样处理就比较麻烦了),还有就是容器空间要比拖放对象大,这个就不用说明了吧。
原理跟固定范围限制差不多,只是范围参数是根据容器的属性的设置的。

当设置了容器,在Repair程序如果容器的position不是relative或absolute,会自动把position设为relative来相对定位:

!this._mxContainer || CurrentStyle(this._mxContainer).position == "relative" || CurrentStyle(this._mxContainer).position == "absolute" || (this._mxContainer.style.position = "relative");


ps:其中CurrentStyle是用来获取最终样式(详细看这里的最终样式部分)。

注意如果在程序执行之前设置过拖放对象的left和top而容器没有设置relative,在自动设置relative时会发生移位现象,所以程序在初始化时就执行一次Repair程序防止这种情况。因为offsetLeft和offsetTop要在设置relative之前获取才能正确获取值,所以在Start程序中Repair要在设置_x和_y之前执行。

由于是相对定位,对于容器范围来说范围参数上下左右的值分别是0、clientHeight、0、clientWidth。
clientWidth和clientHeight是容器可视部分的宽度和高度(详细参考这里)。
为了容器范围能兼容固定范围的参数,程序中会获取容器范围和固定范围中范围更小的值:

Code


因为设置相对定位的关系,容器_mxContainer设置过后一般不要取消或修改,否则很容易造成移位异常。

【鼠标捕获】

我在一个拖放实例中看到,即使鼠标移动到浏览器外面,拖放程序依然能够执行,仔细查看后发现是用了setCapture。
鼠标捕获(setCapture)是这个程序的重点,作用是将鼠标事件捕获到当前文档的指定的对象。这个对象会为当前应用程序或整个系统接收所有鼠标事件。
使用很简单:

this._Handle.setCapture();


setCapture捕获以下鼠标事件:onmousedown、onmouseup、onmousemove、onclick、ondblclick、onmouseover和onmouseout。
程序中主要是要捕获onmousemove和onmouseup事件。
msdn的介绍中还说到setCapture有一个bool参数,用来设置在容器内的鼠标事件是否都被容器捕获。
容器就是指调用setCapture的对象,大概意思就是:
参数为true时(默认)容器会捕获容器内所有对象的鼠标事件,即容器内的对象不会触发鼠标事件(跟容器外的对象一样);
参数为false时容器不会捕获容器内对象的鼠标事件,即容器内的对象可以正常地触发事件和取消冒泡。
而对于容器外的鼠标事件无论参数是什么都会被捕获,可以用下面这个简单的例子测试一下(ie):

Code


这里的参数是true,一开始body会捕获所有鼠标事件,即使鼠标经过div也不会触发onmousemove事件。
换成false的话,div就可以捕获鼠标事件,就能触发div的onmousemove事件了。

拖放结束后还要使用releaseCapture释放鼠标,这个可以放在Stop程序中:

this._Handle.releaseCapture();


setCapture是ie的方法,对于dom也有对应的captureEvents和releaseEvents方法,但这两个方法现在的ff已经不支持了(如果还想了解一下的话可以看w3c中dom2的Event Capture部分)。

那是不是没有鼠标捕获就不能在浏览器外执行拖动呢?并不是这样,大家测试SimpleDrag的话可以发现没有设置鼠标捕获也能在浏览器外拖动(ie和ff都可以)。
那时因为程序把mousemove事件绑定到document了,如果换成this.Drag就没有了这个效果了。
不过细心的话也能发现这个跟用setCapture设置的鼠标捕获还是有很大不同的。
看来这个效果是专门为document而设的,我想这是为了能在浏览器外也能对文档进行一些特殊操作,例如拖动鼠标选择一段文本,即使拖到浏览器外也能继续选择。

这里说说ie中有个奇怪的现象,如果在SimpleDrag的Move程序中加入document.selection.empty()来清除选择(下面会介绍),那么捕获也会失效。
这个我也想不明白,希望高人能指点。

那是不是就不需要用setCapture鼠标捕获了呢?也不是,setCapture在下面说明的焦点丢失、取消默认动作上都很有用处的,而对于ff还是需要把事件绑定到document来捕获浏览器外的拖动事件。

注意ff2下的鼠标捕获有一个bug,当拖放对象内部没有文本内容并拖放到浏览器外时捕获就会失效。
给拖放对象插入一个空文本,例如<font size='1px'>&nbsp;</font>就可以解决,不过这个bug在ff3已经修正了。

【焦点丢失】

一般情况下,鼠标捕获都能正常捕获事件,但如果浏览器窗口的焦点丢失就会导致捕获失效。
我暂时测试到会导致焦点丢失的操作包括切换窗口(包括alt+tab),alert和popup等弹出窗体。
当焦点丢失时应该同时执行Stop程序结束拖放,但当焦点丢失就不能捕获mouseup事件也就是不能触发_fS。
还好ie有onlosecapture事件会在捕获失效时触发,针对这个情况可以这样设置:

addEventHandler(this._Handle, "losecapture"this._fS);


并在Stop程序中移除:

removeEventHandler(this._Handle, "losecapture"this._fS);

但ff没有类似的方法,不过muxrwc找到一个替代losecapture的window.onblur事件,那么可以在Start程序中设置:

addEventHandler(window, "blur"this._fS);


在Stop程序中移除:

removeEventHandler(window, "blur"this._fS);

那ie也有window.onblur事件,用window.onblur代替losecapture不就可以省一段代码了吗。
接着我做了一些测试,发现基本上触发losecapture的情况都会同时触发window.onblur,看来真的可以。
于是我修改程序用window.onblur代替losecapture,但测试后就出问题了,我发现如果我用alt+tab切换到另一个窗口,拖动还可以继续,但这个时候应该是已经丢失焦点了。

于是我逐一排除测试和程序代码,结果发现如果使用了DTD,那么window.onblur会在再次获得焦点时才会触发。
大家可以用下面这段代码测试:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<script>window.onblur=function(){alert(1)}</script>


在切换到其他程序后,再切换回来才会触发window.onblur,还有几个比较怪异的状况就不说了,反正ie用window.onblur是不理想的了。

【取消默认动作】

对选择状态的文本内容、连接和图片等进行拖放操作会触发系统的默认动作,例如ie中拖动图片鼠标会变成禁止操作状态,这样会导致这个拖放程序执行失败。

不过ie在设置了setCapture之后,通过用户界面用鼠标进行拖放操作和内容选择都会被禁止。
意思就是setCapture之后就不能对文档内容进行拖放和选择,注意这里的拖放是指系统的默认动作,例如ondragstart就不会被触发。
不过如果setCapture的参数是false的话,容器内的对象还是可以触发事件的(具体看鼠标捕获部分),所以setCapture的参数要设成true或保留默认值。

而ff的鼠标捕获没有这个功能,但可以用preventDefault来取消事件的默认动作来解决:

oEvent.preventDefault();


ps:据说使用preventDefault会出现mouseup丢失的情况,但我在ff3中测试没有发现,如果各位发现任何mouseup丢失的情况,务必告诉我啊。

【清除选择】

ie在设置setCapture之后内容选择都会被禁止,但也因此不会清除在设置之前就已经选择的内容,而且设置之后也能通过其他方式选择内容,
例如用ctrl+a来选择内容。
ps:onkeydown、onkeyup和onkeypress事件不会受到鼠标捕获影响。
而ff在mousedown时就能清除原来选择的内容,但拖动鼠标,ctrl+a时还是会继续选择内容。
不过在取消了系统默认动作之后,这样的选择并不会对拖放操作造成影响,这里设置主要还是为了更好的体验。

以前我用禁止拖放对象被选择的方法来达到目的,即ie中设置拖放对象的onselectstart返回false,在ff中设置样式MozUserSelect(css:-moz-user-select)为none。
但这种方法只能禁止拖放对象本身被选择,后来找到个更好的方法清除选择,不但不影响拖放对象的选择效果,还能对整个文档进行清除:
ie:document.selection.empty()
ff:window.getSelection().removeAllRanges()
为了防止在拖放过程中选择内容,所以把它放到Move程序中,下面是兼容的写法:

window.getSelection ? window.getSelection().removeAllRanges() : document.selection.empty();


这里说说ff下有一个比较奇怪的现象,在SimpleDrag中第一次拖放没有问题,但第二次拖放时鼠标就会显示禁止操作的样式(拖动失败)。
我发现如果在Start程序中阻止默认动作(preventDefault)或清除选择(window.getSelection().removeAllRanges())就能正常了。
所以我估计是在点击事件后,拖放对象已经默认设置成选择状态了(虽然看起来不是),再拖动时就触发了系统的默认拖动事件。
为了支持推断,我在html插入一段文本,然后在每一次拖放之后选择一下那段文本(间接取消拖放对象的选择),那就能正常了。
不过暂时还没找到官方说明,所以还不能下结论。

【margin】

还有一个情况,当拖放对象设置了margin,那么拖放的时候就会错位(给SimpleDrag的拖放对象设置margin就可以测试)。
原因是在Start程序设置_x和_y时是使用offset获取的,而这个值是包括margin的,所以在设置left和top之前要减去这个margin。
但如果在Start程序中就去掉margin那么在Move程序中设置范围限制时就会计算错误,
所以最好是在Start程序中获取值:

this._marginLeft = parseInt(CurrentStyle(this.Drag).marginLeft) || 0;
this._marginTop = parseInt(CurrentStyle(this.Drag).marginTop) || 0;


在Move程序中设置值:

this.Drag.style.left = iLeft - this._marginLeft + "px";
this.Drag.style.top = iTop - this._marginTop + "px";


要注意margin要在范围修正之后再设置,否则会错位。

【透明背景bug】

在ie有一个透明背景bug(不知算不算bug),可以用下面的代码测试:

Code


点击div的背景会触发不了事件(点击边框或div里面的对象是可以触发的)。
到底什么时候会出现这个bug呢,再用下面的代码测试:

Code


测试代码中我把背景颜色(包括body)设成灰色,首先可以看出在蓝色div(测试对象)内只要触发点是在灰色上面,就能触发事件;相反,在其他不是背景的地方,即使是边框、图片,也不能触发事件。
就像是把灰色的背景的补到蓝色div上来,而且仅仅是背景能这样,多奇怪的设定啊。
这里要说明的是body比较特别,不管背景是不是透明,只要触发点是直接在body上就能触发事件。
我的结论是如果触发事件的对象背景是透明的,而事件的触发点不在对象内的元素上,也不是直接在body上,而且透明背景外没有非透明背景的话,那么事件触发就会失败。
这个结论写得不太好,因为我都不知改怎么表达这奇怪的设定,希望各位能明白。
ps:这里设置图片背景跟颜色背景效果是一样的。

那最好解决的方法就是给对象设一个非透明背景,但有时需求正好是要透明的,那怎么办呢?
首先想到的是加上背景色后设置完全透明,但这样连边框,容器内的对象等都完全透明了,不够好。
如果能保证触发点直接在body或非背景上也可以,如果这个也不能保证呢?
我想到的一个解决方法是在容器里面加一个层,覆盖整个容器,并设置背景色和完全透明:

Code


ps:不要忘了设置fontSize,否则就有一个默认最小高度。
当发现程序有这个bug出现,把程序可选参数Transparent设为true就会自动插入这样一个层了。

到这里插入一个小知识吧,细心的话会发现上面的测试代码中我给html设了一个背景色。
大家可以去掉这个背景色,会发现背景色会设置到整个页面,虽然一直都是用body设置页面背景色,但现在会不会有一个疑惑,body是红色框的部分,为什么设置它的背景色就能应用到整个页面,而给html设置了背景色就又“正常”显示了呢。
这个可以从CSS21的w3c标准中关于background的部分看到原因:
For HTML documents, however, we recommend that authors specify the background for the BODY element rather than the HTML element. For HTML documents whose root HTML element has computed values of 'transparent' for 'background-color' and 'none' for 'background-image', user agents must instead use the computed value of those properties from that HTML element's first BODY element child when painting backgrounds for the canvas, and must not paint a background for that BODY element. Such backgrounds must also be anchored at the same point as they would be if they were painted only for the root element. This does not apply to XHTML documents.

我英文很烂,就勉强翻译一下吧:
对于HTML文档,我们建议作者(这是对浏览器的制作者说的)使用BODY元素的background,而不是HTML元素的。如果HTML文档的根元素(HTML元素)的'background-color'是'transparent',同时'background-image'是'none'(这两个刚好就是默认值),那么在设置背景时就取HTML元素的第一个BODY子元素的属性值,并且不再渲染那个元素的background。
后面两个句就看不太懂了,不过已经足够解析原因了。

【iframe】

如果页面上有嵌入iframe,那就要注意了,因为鼠标捕获在iframe上会有问题。

例如在拖放容器内的一个iframe上快速移动拖放,或者鼠标拖动到容器外的一个iframe上,反正就是鼠标在iframe上(注意没有其他元素隔开),就会出问题:
首先是捕获的失效,鼠标在iframe上,就拖不动了,但并不会触发losecapture,更不用说window的blur了,这个在ie和ff都是一样;
其次ie里在iframe多摩擦几次,还可能导致ie死掉(原因不明)。

下面是我想到的几种方法:
隐藏页面的iframe,比较简单,但可能有些在iframe中重要的信息也被隐藏,或者造成页面布局的错位,用户体验不好;
鼠标移动到iframe后取消拖放,难度不大,但同样用户体验不好;
每个iframe用一个透明的层遮住,很麻烦,要计算好每个iframe的位置和大小;
用一个透明的层把整个页面遮住,比较推荐,也比较简单,下面介绍这种做法。

我在仿LightBox内容显示效果做的那个覆盖层正好能应用在这里,首先实例化一个透明的覆盖层:

var ol = new OverLay({ Opacity: 0 });


然后在onStart和onStop事件中添加ol.Show()和ol.Close()来显示和隐藏覆盖层就可以了,这样只要不是在iframe触发拖放就没有问题了。

有其他更好的方法也请各位指教。

暂时就研究到这里,想不到小小的拖放就有这么多的学问。
还有滚屏等这些都还没考虑到呢,等以后有需要了再来研究拉。


使用说明

实例化时只需要一个参数,就是拖放对象:

var drag = new Drag("idDrag")


有以下这些可选参数和属性:
属性:默认值//说明
Handle:"",//设置触发对象(不设置则使用拖放对象)
Limit:false,//是否设置范围限制(为true时下面参数有用,可以是负数)
mxLeft:0,//左边限制
mxRight:9999,//右边限制
mxTop:0,//上边限制
mxBottom:9999,//下边限制
mxContainer:"",//指定限制在容器内
LockX:false,//是否锁定水平方向拖放
LockY:false,//是否锁定垂直方向拖放
Lock:false,//是否锁定
Transparent: false,//是否透明
onStart:function(){},//开始移动时执行
onMove:function(){},//移动时执行
onStop:function(){}//结束移动时执行

还有属性Drag是拖放对象,Transparent、Handle和mxContainer初始化后就不能再设置。


程序代码 

Code



完整实例下载

posted @ 2008-11-17 08:48 cloudgamer 阅读(...) 评论(...) 编辑 收藏