Angular CDK Drag Drop 源码解析

什么是 drag drop?来自官方网站的描述 (opens in a new tab)

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 (opens in a new tab) 中找到该类的实现。

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

当你在一个 HTML 元素上声明了该指令时,它在初始化过程中做了如下几件事情,参考它的 constructor (opens in a new tab)afterViewInit (opens in a new tab) 钩子:

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 (opens in a new tab) 这个文件里。

它的 constructor (opens in a new tab) 做了如下几件事情:

DragDropRegistry

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

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

第二阶段:开始拖拽

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

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

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

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

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

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

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

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

第三阶段:拖动

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

  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 (opens in a new tab) 方法就会被调用。它会取消订阅来自 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 (opens in a new tab) 方法会被调用。

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 (opens in a new tab) 中被注入:

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 是如何生效的。

, CC BY-NC 4.0 © Wenzhao.RSS