Closed wzhudev closed 3 years ago
What is drag drop? May I quote the description from the official website here:
The @angular/cdk/drag-drop module provides you with a way to easily and declaratively create drag-and-drop interfaces, with support for free dragging, sorting within a list, transferring items between lists, animations, touch devices, custom drag handles, previews, and placeholders, in addition to horizontal lists and locking along an axis.
For short, drag-drop helps you to build draggale components in a convenient and declarative way.
This article takes a deep look into its implementation by exploring four demos on the official website:
The simplest usage is to attach a directive called cdkDrag
to an HTML element so it becomes draggable. Let take a look into the mechanism by exploring this directive’s life cycle and user interactions.
cdkDrag
The directive is implemented in file directives/drag.ts. When you attach it to an HTML element, it does the following things:
DragRef
object. This object is the actual executioner of dragging logics. We would have a detailed discussion about it later.DragRef
. In its constructor:if (dragDrop) {
this._dragRef = dragDrop.createDrag(element, config)
} else {
this._dragRef = new DragRef(
element,
config,
_document,
_ngZone,
viewportRuler,
dragDropRegistry
)
}
this._dragRef.data = this
this._syncInputs(this._dragRef)
this._proxyEvents(this._dragRef)
_syncInputs
, cdkDrag
subscribes DragRef
’s event beforeStarted
, and sync the directive’s inputs to DragRef
with some methods start with with
._proxyEvents
, the directive subscribes DragRef
’s events and emits them, like a proxy.You may notice that there is a injected DragDrop. It’s a simple service to create DragRef. If you are interested you can find more about it here. In the directive’s AfterViewInit hook, the directive deals with drag handles:
class CdkDrag {
ngAfterViewInit() {
// We need to wait for the zone to stabilize, in order for the reference
// element to be in the proper place in the DOM. This is mostly relevant
// for draggable elements inside portals since they get stamped out in
// their original DOM position and then they get transferred to the portal.
this._ngZone.onStable
.asObservable()
.pipe(
take(1),
takeUntil(this._destroyed)
)
.subscribe(() => {
this._updateRootElement()
// Listen for any newly-added handles.
this._handles.changes
.pipe(
startWith(this._handles),
// Sync the new handles with the DragRef.
tap((handles: QueryList<CdkDragHandle>) => {
const childHandleElements = handles
.filter(handle => handle._parentDrag === this)
.map(handle => handle.element)
this._dragRef.withHandles(childHandleElements)
}),
// Listen if the state of any of the handles changes.
switchMap((handles: QueryList<CdkDragHandle>) => {
return merge(...handles.map(item => item._stateChanges))
}),
takeUntil(this._destroyed)
)
.subscribe(handleInstance => {
// Enabled/disable the handle that changed in the DragRef.
const dragRef = this._dragRef
const handle = handleInstance.element.nativeElement
handleInstance.disabled
? dragRef.disableHandle(handle)
: dragRef.enableHandle(handle)
})
})
}
}
When the DOM renderes the first time (startWith
operator will ensure that) or handles change, subscribes the state change events of all handles who are targeting the current draggable element, and use DragRef
’s methods to disable or enable them. We will talk about handle after we have a basic understanding of the main process. We jumped over the construction of DragRef
earlier, now let’s take a good look at it.
DragRef
DragRef
is implemented in drag-ref.ts
. Its constructor is
class DragRef {
constructor(
element: ElementRef<HTMLElement> | HTMLElement,
private _config: DragRefConfig,
private _document: Document,
private _ngZone: NgZone,
private _viewportRuler: ViewportRuler,
private _dragDropRegistry: DragDropRegistry<DragRef, DropListRef>
) {
this.withRootElement(element)
_dragDropRegistry.registerDragItem(this)
}
}
withRootElement
method binds drag start event listener _pointerDown
on the root element, in another word, the elements that cdkDrag
attaches to. When user press mouse button or tap finger on the root element, _pointerDown
is invoked to deal with this event. And after that, it register itself on DragDropRegistry
.
DragDropRegistry
DragDropRegistry
is a service provided in root and injected to cdkDrag
. It’s responsible for listening to mousemove
or touchmove
events and make sure only one DragRef
could respond to these events at one time. As for the registerDragItem
, when it’s invoked, it registers a DragRef
as a drag instance and make sure that touchmove
events are ‘defaultPrevented’ when there’s item actually getting dragged. So far, cdk has been prepared to interact with users’ dragging gestures. So let’s go to the next stage.
When user press the mouse on the root element, a mousedown
or touchdown
event is emitted and _pointerDown
method of DragRef
as event handler is triggered. Let’s consider the simplest situation and assume there’s no handle. By dispatching a beforeStart
event, as we mentioned earlier, all inputs are synced. After that, a drag sequence is initialized by _initializeDragSequance
. As its name suggests, _initializeDragSequence
make everything ready for dragging and receiving move events. Let’s ignore the codes that dealing with dragging delay on mobile devices and focus on the main process.
// Cache the previous transform amount only after the first drag sequence, because
// we don't want our own transforms to stack on top of each other.
if (this._initialTransform == null) {
this._initialTransform = this._rootElement.style.transform || ''
}
First, it caches the initial transform. After dragging stops, we would set transform to the root element and also put the initial transform back.
this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(
this._pointerMove
)
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(
this._pointerUp
)
Then it subscribes move and up events from the registry. Other things are setting properties related to dragging status, like
_hasStartedDragging
, _hasMoved
. They explain for themselves._pickupPositionInElement
. The pointer’s position against the root container’s top-left corner as origin._pointerDirectionDelta
. A vector telling how the pointer moves. At last, it calls startDragging
of the registry. Like registerDragItem
, startDragging
registers move and up handles and selectstart
preventer when it’s necessary and registers them outside of zone.js out of performance consideration.The drag&drop system is now ready for dragging. Assume that user drags the root component, pointerMove
emits an event and _pointerMove
of DragRef
gets invoked. First it would check if the dragging distance has exceeded the threshold. If so, mark _hasStartedDragging
as true. After that, it would calcualte the correct transform and assign it to the root element.
const constrainedPointerPosition = this._getConstrainedPointerPosition(event)
We would come back to this line later when we talk about boundary. Let’s have a look at how the trasnform is calculated first. For now, just take constrainedPointerPosition
as the pointer’s position.
const activeTransform = this._activeTransform
activeTransform.x =
constrainedPointerPosition.x -
this._pickupPositionOnPage.x +
this._passiveTransform.x
activeTransform.y =
constrainedPointerPosition.y -
this._pickupPositionOnPage.y +
this._passiveTransform.y
const transform = getTransform(activeTransform.x, activeTransform.y)
// Preserve the previous `transform` value, if there was one. Note that we apply our own
// transform before the user's, because things like rotation can affect which direction
// the element will be translated towards.
this._rootElement.style.transform = this._initialTransform
? transform + ' ' + this._initialTransform
: transform
Let’s clarify some variables here. activeTransform
means during the current dragging, how many pixels has the root element moved along the x and y axises from its original position. passiveTransform
means how many pixels has the root element moved from its original position to the position where we started the current dragging. And obviously, pickUpPositionOnPage
means the position of the pointer when we started the current dragging. And it’s obvious too that after the current dragging completes, activatedTransform
would be assigned to passiveTransform
. Knowing what the variables means, we now can understand how activatedTransform
is calculated now. After the calculation, we set the transform to the root element’s style. Remember that initialTransform
was cached? It would be appended to activatedTransform
. > It’s a funny fact the you can add two values to a CSS property!
Now the root element should be draggable. Let’s talk about how to make it stoppable. When user releases the mouse button or picks up his finger, _pointerUp
method will be invoked. Of course, it unsubscribes move and up events handles, and clear global listeners in DragDropService
. And it accumulate active transform to passive transform. So the next the root element get transformed, it would have a correct start point.
I don’t know why
this._dragDropRegistry.stopDragging(this);
appears at least twice… Bingo! That’s the whole process of dragging. Let’s talk about some advanced features.
Here is something we haven’t talked about in the stages earlier but still important.
When we were talking about position in stage 3, we ignore _getConstrainedPointerPosition
method. Now let’s talk about boundary mechanism. When dragging starts, a beforeStarted
event is emitted and withBoundaryElement
is invoked. getClosestMatchingAncestor
use an CSS selector to query the closest ancestor node as the boundary element. When drag sequence is initialized, the element’s rect is set to this._boundaryRect
.
if (this._boundaryElement) {
this._boundaryRect = this._boundaryElement.getBoundingClientRect()
}
And in _getConstrainedPointerPosition
, the pointer’s position is mutate to ensure that the root element is always inside of the boundary element.
const point = this._getPointerPositionOnPage(event)
// ...
if (this._boundaryRect) {
const { x: pickupX, y: pickupY } = this._pickupPositionInElement
const boundaryRect = this._boundaryRect
const previewRect = this._previewRect!
const minY = boundaryRect.top + pickupY
const maxY = boundaryRect.bottom - (previewRect.height - pickupY)
const minX = boundaryRect.left + pickupX
const maxX = boundaryRect.right - (previewRect.width - pickupX)
point.x = clamp(point.x, minX, maxX)
point.y = clamp(point.y, minY, maxY)
}
Why we calculates the boundary rect outside of _getConstrainedPointerPosition? Because .getBoundingClientRect causes pages to reflow thus is performance expensive.
Earlier we mentioned that cdkDrag
deals with handle. When handles change, it invokes withHandles
.
tap((handles: QueryList<CdkDragHandle>) => {
const childHandleElements = handles
.filter(handle => handle._parentDrag === this)
.map(handle => handle.element);
this._dragRef.withHandles(childHandleElements);
}),
How does the filter work? Take a look into DragHandle
’s constructor:
constructor(
public element: ElementRef<HTMLElement>,
@Inject(CDK_DRAG_PARENT) @Optional() parentDrag?: any) {
this._parentDrag = parentDrag;
toggleNativeDragInteractions(element.nativeElement, false);
}
And in cdkDrag
’s meta:
@Directive({
selector: '[cdkDrag]',
exportAs: 'cdkDrag',
host: {
'class': 'cdk-drag',
'[class.cdk-drag-dragging]': '_dragRef.isDragging()',
},
providers: [{provide: CDK_DRAG_PARENT, useExisting: CdkDrag}]
})
You can see that cdkDrag is provided to its children as CDK_DRAG_PARENT
. As for withHandles
, it just register these handles’ HTML element. In _pointerDown
, instead of the root element, handles’ element would be investigated to seen if a dragging sequence should be initialized.
if (this._handles.length) {
const targetHandle = this._handles.find(handle => {
const target = event.target;
return !!target && (target === handle || handle.contains(target as HTMLElement));
});
if (targetHandle && !this._disabledHandles.has(targetHandle) && !this.disabled) {
this._initializeDragSequence(targetHandle, event);
}
} else if (!this.disabled) {
this._initializeDragSequence(this._rootElement, event);
}
contains is an API that could determine whether a param is the callee’s child element. How do we know if a handle is disabled or not? Remember at the beginning of _pointerDown we emit a event? In its subscriber , this line handleInstance.disabled ? dragRef.disableHandle(handle) : dragRef.enableHandle(handle); would call DragRef’s methods to enable or disable them.
Since we know how to ensure the root element to be within the boundary element, it’s simple to understand how to lock axis, just reassign x
or y
.
if (this.lockAxis === 'x' || dropContainerLock === 'x') {
point.y = this._pickupPositionOnPage.y
} else if (this.lockAxis === 'y' || dropContainerLock === 'y') {
point.x = this._pickupPositionOnPage.x
}
This article gives a detailed explanation of how drag&drop works without a container, and how handles, axis locking and boundary work. cdkDrag
is attached to an HTML element which you want it to be draggable but DragRef
is the main executor of logics. DragDropRegister
is responsible for dispatching move events and ensuring only one element is draggable at a time.
You can see the English version of this blog by unfolding the comment above. 👆
什么是 drag drop?来自官方网站的描述:
简单来说,drag drop 能够帮助你声明式地,方便地创建可拖拽元素。
这篇会探究以下四个 demo 所体现的 drag drop 一些 feature 是如何实现的:
drag drop 最简单的用法是在组件或 HTML 元素上声明一个
cdkDrag
指令,我们将通过追踪该指令的生命周期与其所创建的一些 listener 来探究 drag drop 的运行机制。具体而言,这篇文章的内容会包含以下几个主题:cdkDrag
的初始化,以及相关类的初始化有关于 container 的内容将会在下一篇文章中叙述。
第一阶段:初始化
cdkDrag
你可以在这个 directives/drag.ts 中找到该类的实现。
当你在一个 HTML 元素上声明了该指令时,它在初始化过程中做了如下几件事情,参考它的
constructor
和afterViewInit
钩子:DragRef
对象,拖拽逻辑主要由该对象负责完成。这个对象十分重要,我们待会儿再详细讨论它,先看下其他代码做了什么。_syncInputs
方法会将指令的一些参数同步到DragRef
对象上。在该方法中,cdkDrag
订阅了DragRef
的beforeStarted
事件,在收到该事件时调用DragRef
的一系列以 with 的开头的方法来同步 inputs。记住这一点,后面我们会反复提及。_proxyEvents
会将自己作为DragRef
的代理。在_proxyEvents
方法中,订阅了DragRef
的一系列事件并转发出去,类似于DragRef
的代理。在该指令的
AfterViewInit
钩子 中,它对 handle 做了处理:当页面第一次渲染(通过startWith
操作符来实现)或者 handle 发生改变时,订阅所有 handle 的状态改变事件,并且调用DragRef
的方法来禁用或启用它们。我们会在讲解完主要逻辑之后再介绍 handle 是如何工作的。DragRef
这个类执行了拖拽最主要的逻辑,它的代码在 drag-ref.ts 这个文件里。
它的
constructor
做了如下几件事情:withRootElement
方法在rootElement
(即绑定了cdkDrag
指令的元素)上绑定了 drag start 事件的 listener_pointerDown
。当用户在该元素上按下鼠标左键或者是按下该元素时,_pointerDown
就会被调用。DragDropRegistry
上注册了自己。DragDropRegistry
DragDropRegistry
服务注册在根注入器上,并被DragRef
所依赖。它负责监听mousemove
和touchmove
事件,并确保同一时刻只有一个DragRef
能够响应这些事件。registerDragItem
被调用时,它会注册调用它的DragRef
对象,然后在某个DragRef
拖拽时,把光标移动的事件发送给它。到这里,整个 drag drop 机制就做好了准备来响应用户的操作。
第二阶段:开始拖拽
当用户在
rootElement
上按下光标时,会派发一个mousedown
或touchdown
事件,此时DragRef
的_pointerDown
方法就会被调用。我们先考虑没有 handle 的最简单的情形。
beforeStart
事件被派发出去,此时,就像我们之前提到的那样,所有的 inputs 会从cdkDrag
同步到DragRef
上。_initializeDragSequance
就会实例化一个 drag sequence,会确保整个机制已经做好准备接收 move 事件的准备。具体而言,实例化 drag sequence 的过程会做如下的事情:
其他的代码和设置状态相关,例如:
_hasStartedDragging
_hasMoved
,字面意思。_pickupPositionInElement
。以rootElement
的左上角为原点,鼠标相对于rootElement
的位置。_pointerDirectionDelta
,一个记录光标移动的矢量。最后它调用了 registry 的
startDragging
方法,这个方法会绑定 move 和 up 事件的 listener,这样当用户移动光标时,就会触发这两个 listener,然后把事件发送给dragRef
。第三阶段:拖动
现在用户就可以移动元素了,
_pointerMove
方法会在用户移动光标时被调用。这个方法做了如下几件事情:_hasStartedDragging
标记为 true。rootElement
。当我们讨论拖动边界的时候我们会回来讨论_getConstrainedPointerPosition
,现在先假设constrainedPointerPosition
就是光标此时所在的位置,看看rootElement
的新位置是如何计算的。在我们开始讨论之前,让我们先来弄清楚这段代码中几个变量的含义。
activeTransform
表示在这一次的拖拽过程中,相对于rootElement
最初的位置,rootElement
沿着 x 和 y 轴移动了多少像素。passiveTransform
表示在这一次拖拽过程开始之前,相对于rootElement
最初的位置,rootElement
沿着 x 和 y 轴移动了多少像素。很显然,在当前这次拖拽结束过后,activatedTransform
会被赋值到passiveTransform
上。pickUpPositionOnPage
表示指的是在这一次拖拽过程开始鼠标相对于页面原点的位置。 知道了这些变量的含义,我们现在就能很轻易地明白activatedTransform
是如何计算的了:就是一个简单的向量加法,上次移动的向量,加上这次移动的向量 (当前鼠标位置减去开始拖拽时鼠标的位置)。计算完成之后,我们会通过一个辅助方法生成 transform 字符串,然后将它设置到
rootElement
的 style 上。记得之前提到过的缓存的initialTransform
吗?它会被添加到 transform 属性后面。第四阶段:停止拖拽
现在元素已经能够随着光标移动了,现在我们来看看如何停止这个拖拽过程。
当用户释放鼠标左键或者抬起手指的时候,
pointerUp
方法就会被调用。它会取消订阅来自 registry 的 move 和 up 事件,DragDropService
所绑定的全局事件 listener 也会被移除。然后它将passiveTransform
赋值给activatedTransform
,这样下一次拖拽开始时就会有一个正确的起始点。Bingo!现在整个拖拽过程就完成了。
其他
这里我们会谈论一些之前跳过的高级内容。
拖拽边界 boundary
当我们在第三阶段中讨论定位问题时我们略过了
_getConstrainedPointerPosition
方法,现在我们来谈一谈边界机制。当拖拽开始时
beforeStarted
事件派发,withBoundaryElement
方法会被调用。此时,getClosestMatchingAncestor
方法使用一个 CSS 选择器,来选择rootElement
最近的一个满足选择器的祖先元素作为边界元素。当 drag sequence 被初始化的时候,该元素的位置信息被赋值给
this._boundaryRect
。handle
早前我们就提到
cdkDrag
会负责 handle 的处理。当 handle 改变时,withHandles
方法会被调用。这个 filter 是如何工作的,它怎么知道哪些 handler 属于当前的
cdkDrag
呢?原来cdkDrag
会在DragHandle
的 constructor 中被注入:cdkDrag
的 metadata 中也体现了cdkDrag
会以CDK_DRAG_PARENT
这个令牌注入到它的 handle 子元素中:至于
withHandles
,它仅仅是注册这些 handle 的 HTML 元素。在
_pointerDown
方法中,如果有 handle 的话,handle 就会代替rootElement
作为是否应该开始一个 drag sequence 的依据。我们如何知道一个 handle 是启用还是禁用呢?还记得在
cdkDrag
中_pointerDown
事件的 subscriber 吗?这行代码会启用或禁用 handle。
在某个方向上锁定 axis locking
我们知道了如何将
rootElement
限定在边界元素之内,锁定拖动的方向就更简单了,只需要重新赋 x 或 y 的值就可以了。这样
constrainedPointerPosition.x - this._pickupPositionOnPage.x 或者 constrainedPointerPosition.y - this._pickupPositionOnPage.y
就会等于 0,在某个方向上的 transform 也就不会变化了。结论
这篇文章详细的解释了在没有 container 的情况下,drag drop 是如何工作的,以及 handle,axis locking 和 boundary 是如何生效的。
cdkDrag
指令的元素会变成可拖拽的,但DragRef
是拖拽逻辑的主要执行者