wzhudev / blog

:book:
220 stars 14 forks source link

Angular CDK Drag Drop 源码解析 #6

Closed wzhudev closed 3 years ago

wzhudev commented 5 years ago

什么是 drag drop?来自官方网站的描述

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。

简单来说,drag drop 能够帮助你声明式地,方便地创建可拖拽元素。

这篇会探究以下四个 demo 所体现的 drag drop 一些 feature 是如何实现的:

drag drop 最简单的用法是在组件或 HTML 元素上声明一个 cdkDrag 指令,我们将通过追踪该指令的生命周期与其所创建的一些 listener 来探究 drag drop 的运行机制。具体而言,这篇文章的内容会包含以下几个主题:

有关于 container 的内容将会在下一篇文章中叙述。

第一阶段:初始化

cdkDrag

你可以在这个 directives/drag.ts 中找到该类的实现。

为了方便阅读源码来更好地理解文章中介绍的内容,我给一些文件,类和方法添加了链接。

当你在一个 HTML 元素上声明了该指令时,它在初始化过程中做了如下几件事情,参考它的 constructorafterViewInit 钩子:

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)
  1. 实例化 DragRef 对象,拖拽逻辑主要由该对象负责完成。这个对象十分重要,我们待会儿再详细讨论它,先看下其他代码做了什么。
  2. _syncInputs 方法会将指令的一些参数同步到 DragRef 对象上。在该方法中,cdkDrag 订阅了 DragRefbeforeStarted 事件,在收到该事件时调用 DragRef 的一系列以 with 的开头的方法来同步 inputs。记住这一点,后面我们会反复提及。
  3. _proxyEvents 会将自己作为 DragRef 的代理。在 _proxyEvents 方法中,订阅了 DragRef 的一系列事件并转发出去,类似于 DragRef 的代理。

你或许注意到注入了一个 DragDrop 对象,这是一个用于创建 DragRef 的简单服务,如果对它感兴趣你可以自行查看它的代码。

在该指令的 AfterViewInit 钩子 中,它对 handle 做了处理:当页面第一次渲染(通过 startWith 操作符来实现)或者 handle 发生改变时,订阅所有 handle 的状态改变事件,并且调用 DragRef 的方法来禁用或启用它们。我们会在讲解完主要逻辑之后再介绍 handle 是如何工作的。

DragRef

这个类执行了拖拽最主要的逻辑,它的代码在 drag-ref.ts 这个文件里。

它的 constructor 做了如下几件事情:

DragDropRegistry

DragDropRegistry 服务注册在根注入器上,并被 DragRef 所依赖。它负责监听 mousemovetouchmove 事件,并确保同一时刻只有一个 DragRef 能够响应这些事件registerDragItem 被调用时,它会注册调用它的 DragRef 对象,然后在某个 DragRef 拖拽时,把光标移动的事件发送给它。

到这里,整个 drag drop 机制就做好了准备来响应用户的操作。

第二阶段:开始拖拽

当用户在 rootElement 上按下光标时,会派发一个 mousedowntouchdown 事件,此时 DragRef_pointerDown 方法就会被调用。

我们先考虑没有 handle 的最简单的情形。

  1. beforeStart 事件被派发出去,此时,就像我们之前提到的那样,所有的 inputs 会从 cdkDrag 同步到 DragRef 上。
  2. 之后,_initializeDragSequance 就会实例化一个 drag sequence,会确保整个机制已经做好准备接收 move 事件的准备。

一个 drag sequance 就是一次拖拽过程。

具体而言,实例化 drag sequence 的过程会做如下的事情:

  1. 首先,它会缓存元素已有的 (往往是用户设置的) transform。在拖拽结束之后,缓存的 transform 会被设置回去。
  2. 然后它订阅了来自 registry 的 move 和 up 事件。

其他的代码和设置状态相关,例如:

最后它调用了 registry 的 startDragging 方法,这个方法会绑定 move 和 up 事件的 listener,这样当用户移动光标时,就会触发这两个 listener,然后把事件发送给 dragRef

第三阶段:拖动

现在用户就可以移动元素了,_pointerMove 方法会在用户移动光标时被调用。这个方法做了如下几件事情:

  1. 首先,它会检查光标移动的距离是否超过了设定的阈值。如果超过了,它会将 _hasStartedDragging 标记为 true。
  2. 然后,它会计算 transform,然后将它赋给 rootElement。当我们讨论拖动边界的时候我们会回来讨论 _getConstrainedPointerPosition,现在先假设 constrainedPointerPosition 就是光标此时所在的位置,看看 rootElement 的新位置是如何计算的。
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)

this._rootElement.style.transform = this._initialTransform
  ? transform + ' ' + this._initialTransform
  : transform

在我们开始讨论之前,让我们先来弄清楚这段代码中几个变量的含义。

计算完成之后,我们会通过一个辅助方法生成 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

if (this._boundaryElement) {
  this._boundaryRect = this._boundaryElement.getBoundingClientRect()
}

getBoundingClientRect 会触发 reflow,所以必须要在每次拖动开始的时候进行计算并缓存。 在 _getConstrainedPointerPosition 中,光标的位置会被修改已确保 rootElement 不会超出边界元素。

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)
}

handle

对 handle 比较准确的翻译是“把手”。

早前我们就提到 cdkDrag 会负责 handle 的处理。当 handle 改变时,withHandles 方法会被调用。

tap((handles: QueryList<CdkDragHandle>) => {
  const childHandleElements = handles
    .filter(handle => handle._parentDrag === this)
    .map(handle => handle.element);
  this._dragRef.withHandles(childHandleElements);
}),

这个 filter 是如何工作的,它怎么知道哪些 handler 属于当前的 cdkDrag 呢?原来 cdkDrag 会在 DragHandleconstructor 中被注入:

constructor(
  public element: ElementRef<HTMLElement>,
  @Inject(CDK_DRAG_PARENT) @Optional() parentDrag?: any) {
  this._parentDrag = parentDrag;
  toggleNativeDragInteractions(element.nativeElement, false);
}

cdkDrag 的 metadata 中也体现了 cdkDrag 会以 CDK_DRAG_PARENT 这个令牌注入到它的 handle 子元素中:

@Directive({
  selector: '[cdkDrag]',
  exportAs: 'cdkDrag',
  host: {
    'class': 'cdk-drag',
    '[class.cdk-drag-dragging]': '_dragRef.isDragging()',
  },
  providers: [{provide: CDK_DRAG_PARENT, useExisting: CdkDrag}]
})

至于 withHandles,它仅仅是注册这些 handle 的 HTML 元素。

_pointerDown 方法中,如果有 handle 的话,handle 就会代替 rootElement 作为是否应该开始一个 drag sequence 的依据。

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 这个 API 可以判断某个元素是否是另一个元素的子孙元素。

我们如何知道一个 handle 是启用还是禁用呢?还记得在 cdkDrag_pointerDown 事件的 subscriber 吗?这行代码

handleInstance.disabled ?dragRef.disableHandle(handle) : dragRef.enableHandle(handle);

会启用或禁用 handle。

在某个方向上锁定 axis locking

我们知道了如何将 rootElement 限定在边界元素之内,锁定拖动的方向就更简单了,只需要重新赋 x 或 y 的值就可以了。

if (this.lockAxis === 'x' || dropContainerLock === 'x') {
  point.y = this._pickupPositionOnPage.y
} else if (this.lockAxis === 'y' || dropContainerLock === 'y') {
  point.x = this._pickupPositionOnPage.x
}

这样 constrainedPointerPosition.x - this._pickupPositionOnPage.x 或者 constrainedPointerPosition.y - this._pickupPositionOnPage.y 就会等于 0,在某个方向上的 transform 也就不会变化了。

结论

这篇文章详细的解释了在没有 container 的情况下,drag drop 是如何工作的,以及 handle,axis locking 和 boundary 是如何生效的。

wzhudev commented 5 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.

Stage 1: Setup

cdkDrag

The directive is implemented in file directives/drag.ts. When you attach it to an HTML element, it does the following things:

  1. Initialize a DragRef object. This object is the actual executioner of dragging logics. We would have a detailed discussion about it later.
  2. Deal with drag handles.
  3. Make itself a proxy of 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)

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.

Stage 2: Start Dragging

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

Stage 3: Dragging

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!

Stage 4: Stop Dragging

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.

Other

Here is something we haven’t talked about in the stages earlier but still important.

Boundary

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.

Handle

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 &amp;&amp; (target === handle || handle.contains(target as HTMLElement));
  });
  if (targetHandle &amp;&amp; !this._disabledHandles.has(targetHandle) &amp;&amp; !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.

Axis lock

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
}

Conclusion

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.

wzhudev commented 5 years ago

You can see the English version of this blog by unfolding the comment above. 👆