小剧起始页,拖拽篇

时间:2022-03-19 作者:剧中人

如果你还没有用过小剧起始页,建议你先体验一番之后,再回来阅读这篇文章。

https://e.bh-lay.com/

小剧起始页上线几个月来,受到很多小伙伴的喜欢。在达成自己学习目标的前提下,还能得到你们的认可,并且激励小剧持续迭代下去,这是最开始完全没有想到的。

收到部分小伙伴的反馈后了解到,小剧起始页让他们眼前一亮的地方集中在两个地方。

今天来聊一聊小剧起始页中,拖拽相关的设计及开发思路。

一、拖拽交互的特点

在 PC 的操作上,主要借助于鼠标、键盘实现行为输入。键盘一般可以设计快捷键来辅助操作,而鼠标可以设计出更为丰富复杂的交互。

1.1、鼠标的基本操作

将按下、释放组合起来,是我们交互中使用最广泛的点击、右键菜单行为。多事件延迟触发,还可以模拟出不太常用的双击、长按事件。

将鼠标的基本操作全部结合在一起,拖拽行为就可以被设计出来,鼠标按下是拖拽触发的时机,移动是执行拖拽的预操作,释放则是拖拽行为的最终确定。

拖拽可以用在移动位置、调整大小等常见的交互中,也可用于实现模拟手势,结合业务特定,还能设计出更多好玩的花样。

1.2、拖拽交互有哪些优势呢?

1.2.1、界面简洁

点击操作多数情况下需要占用 UI 界面,体现在 UI 组件上可以是各种链接、按钮、下拉框、右键菜单之类的基础组件。

而拖拽因为其交互特性,可以省略掉额外的 UI 设计,常见的 Slider 滑动、textarea 的缩放大小、侧边栏宽度调节、弹窗位置移动等交互,都不需要或者仅需极小的尺寸就可以辅助完成操作的执行。

1.2.2、反馈更加直观

排序、缩放等操作若是使用点击交互,你得预先知道排序的规则、调整后的目标值。当你了解这一切之后,你才能顺利完成一次排序操作。

拖拽交互则不一样,配合实时反馈的过程展示,操作更加容易,所见即所得的表现可以屏蔽很多内部逻辑设计。

1.2.3、操作效率高

常见的操作,在点击的交互下需要分解为 1、2、3 或者更多步才能完成,例如将 A 分类下的文章移动到 B 分类,常规操作路径如下:

而在拖拽交互中,只需要拖住文章应对的 UI 组件,移动到 B 分类所在的位置,释放鼠标即可完成拖拽,非常简单高效。

1.3、拖拽交互的弊端是什么?

拖拽这么好用,但事实是在 WEB 设计中的使用率非常低,你可以打开淘宝、微博等网站,很少能够看到拖拽交互的影子。 这就不得不提一下拖拽交互的弊端了。

1.3.1、交互较为隐晦

前面提到拖拽交互不过分占用 UI 界面,这是其优势,也是弊端。相比点击交互,初次使用产品时可能不会使用拖拽交互,甚至不太容易发现还有拖拽可用。

1.3.2、对部分用户不友好

拖拽比较强依赖鼠标操作,在一些不太灵敏的触摸板上表现很差。对于一些手指活动不是很灵活的老年人、上肢行动不便的残障人士也是使用上的一大障碍。

大型的 WEB 应用的用户群体非常广泛,有对电子设备操作非常流畅的青少年,也有轻度使用电脑的耄耋老人,还有遭遇不幸的残障人士。

为了降低对用户的教育成本,减轻界面学习负担,提高用户覆盖面。均衡考量下,大型 WEB 应用更愿意在交互设计中,使用最为稳妥的点击操作方案。

二、小剧起始页有哪些拖拽交互

前面提到了拖拽交互的特点,其中的弊端导致市面上很少看到大面积使用拖拽完成的交互。

那为什么小剧起始页要使用拖拽完成大部分功能交互呢?

首先小剧起始页是非常个人化的网站,绝大多数需求的出发点源于小剧自己,个人的喜好在这其中起到了非常决定性的作用。

其次愿意使用小剧起始页的小伙伴大多和我一样,是深谙 WEB 各类交互,对简洁的视觉有一定追求年轻人。

基于以上两点原因,小剧起始页将很多操作都设计为拖拽交互,比如下面几例:

2.1、桌面图标操作

桌面图标的排序调整顺序,两个图标合并成组,图标移入图标组,图标删除,调整图标尺寸等操作,均可以借助拖拽完成。

desktop

2.2、打开后的图标组

这里的交互和桌面非常像,差异在于缺少了图标合并成组操作,多了放回桌面。

bookmark-group

2.3、小书房

小书房是一个类似于浏览器书签管理器的地方,这里的排序调整、合并成组、移动目录、删除等操作都是借助于拖拽交互完成。

private-boommark

2.4、各类 Slider

Slider 是数值调整中非常常见的交互组件。小剧起始页中,桌面布局的各个尺寸调节,以及三角形生成器工具中,三角形边长微调的工具都是借助于 Slider 组件完成。

小剧在实现 Slider 组件的交互逻辑时,核心交互也是基于拖拽来实现。

private-boommark

三、如何实现拖拽交互?

鼠标按下是拖拽触发的时机,移动是执行拖拽的预操作,释放则是拖拽行为的最终确定。

这句话在【鼠标的基本操作】部分有提到过,我们将鼠标的按下、移动、释放结合在一起,拖拽行为就可以被设计出来。

听起来是不是很简单,然而实际开发中仍然有几点需要考虑:

3.1、拖拽的卡顿如何处理?

很多小伙伴在处理拖拽交互的时候,经常性地发现整个体验非常拉垮。要么是拖拽过快容易导致拖拽行为失效,要么是鼠标移动过程非常生涩不流畅。

这是拖拽交互中最常见的两类问题,具体的原因及解决方法如下:

3.1.1、拖拽过快导致拖拽行为失效

我们以拖拽弹窗移动位置举例,分析一下问题出在哪儿?

通常情况下,我们会把 mousedownmousemove 事件都绑定在弹窗组件上,并且在 mousemove 过程中实时修改弹窗坐标位置,这样即完成了弹窗拖拽移动位置的交互。

是不是听起来很合理?

问题其实就发生在 mousemove 事件的绑定上。因为 mousemove 事件的触发精度并不高,并且事件监听本身是异步的,所以从鼠标发生移动,到弹窗实际发生位移是有一定时间差的。如果弹窗再定义了 transition 缓动,问题会更加明显。

正是因为这个时间差,导致鼠标移动过快时很可能离开弹窗所在区域,自然也不会触发后续的 mousemove 事件,因此拖拽行为到此便被中断。

解决办法很简单,在 mousedown 触发拖拽开始行为后,mousemove 事件绑定在 body 上,而非弹窗本身。这样即使 UI 反馈延迟也不影响整个拖拽逻辑的响应。

当然你需要注意处理 body 的 mousemove 事件的解除,避免交互异常及内存泄漏。

3.1.2、鼠标移动不流畅

同样再拿拖拽弹窗移动位置举例,分析一下问题出在哪儿 ?

我们在鼠标移动过程中,计算当前鼠标与上一次触发 mousemove 的坐标差,再获取弹窗的位置,拿坐标差与当前弹窗位置做计算,即可算出弹窗新的位置。

是不是同样听起来很合理?

这里的问题在于弹窗位置的获取时机。因为这个行为涉及到 DOM 属性的读取,在 mousemove 相对高频触发的条件下会引起不必要的性能开销。

如果切换到拖拽排序的场景下,mousemove 过程中频繁获取列表尺寸、位置数据,性能损耗会更加明显。

想要优化这里的体验,提升操作流畅度,就要想办法减少不必要的 DOM 属性读取。

我们可以充分利用 mousedown 这个时机,将 dom 的尺寸、位置在这个时机获取,并缓存下来,同时缓存下来当前鼠标所在位置。在 mousemove 过程中仅仅根据新的坐标点和缓存的数据相计算,最后再应用到 dom 上,即可大幅提升拖拽的流畅度。

这一优化的效果在列表排序中分尤为明显。

3.2、代码如何组织?

拖拽因为有通用的交互共性,在具体的业务中又有着不同的交互特性,因此这里的代码组织分为两部分,分别是通用代码逻辑和业务代码逻辑。

3.2.1、通用代码逻辑

这是小剧使用了很多年的一段拖拽基础代码,绝大多数拖拽场景下小剧都是基于这段代码来实现交互。

drag-handle.ts

const removeSelecteion = window.getSelection
  ? function () {
    const selections = window.getSelection()
    selections && selections.removeAllRanges()
  }
  : function () {
    // nothing
  }

type dragOptions = {
  stableStart: (startX: number, startY: number) => void;
  move: (a: dragParams) => void;
  end: (a: dragParams) => void;
  cancel?: () => void;
  stableDistance: number;
};
type dragParams = {
  clientX: number;
  clientY: number;
  xOffset: number;
  yOffset: number;
};
function getParam(e: MouseEvent, startX: number, startY: number) {
  const clientX = e.clientX
  const clientY = e.clientY
  const xOffset = clientX - startX
  const yOffset = clientY - startY
  const returns: dragParams = {
    clientX,
    clientY,
    xOffset,
    yOffset,
  }
  return returns
}
export default function dragHandler(event: MouseEvent, options?: dragOptions) {
  const { stableStart, move, end, cancel, stableDistance } = options || {}
  const startX = event.clientX
  const startY = event.clientY
  let isTriggeredEvent = false
  const _stableDistance = stableDistance || 0
  if (!_stableDistance) {
    event.preventDefault && event.preventDefault()
    event.stopPropagation && event.stopPropagation()
  }
  const listenerConfig: AddEventListenerOptions = {
    passive: true,
    capture: true,
  }
  function mousemove(e: MouseEvent) {
    e.stopPropagation && e.stopPropagation()
    removeSelecteion()
    const param = getParam(e, startX, startY)
    if (isTriggeredEvent) {
      move && move(param)
    } else if (
      Math.sqrt(param.xOffset * param.xOffset + param.yOffset * param.yOffset) >
      _stableDistance
    ) {
      isTriggeredEvent = true
      stableStart && stableStart(startX, startY)
      move && move(param)
    }
  }
  function up(event: MouseEvent) {
    event.stopPropagation()

    document.removeEventListener('mousemove', mousemove, listenerConfig)
    document.removeEventListener('mouseup', up, listenerConfig)
    if (isTriggeredEvent) {
      end && end(getParam(event, startX, startY))
    } else {
      cancel && cancel()
    }
  }
  document.addEventListener('mousemove', mousemove, listenerConfig)
  document.addEventListener('mouseup', up, listenerConfig)
}

代码看起来有七十多行,但除去 type 类型定义及不必要的空行,也只有六十行左右。

具体代码感兴趣的话可以自行研究,这里我只介绍它的使用方法。

还是以拖拽弹窗移动位置举例,在弹窗特定区域触发 mousedown 事件时,调用 dragHandler 方法,并且将当前的 mouseevent 传递进去,在第二个参数 options 中,stableStartmoveendcancel 均为拖拽过程中的事件回调。

其中 stableStart 回调需要 配合 stableDistance 参数使用,作用是拖拽行为的保护。例如同一个元素既要响应点击事件,又要响应拖拽事件,不能因为鼠标按下抬起过程中的轻微位移就判定为是拖拽。

原生的 mousemove 事件只会单纯的将鼠标位置传递给回调函数,这里的 moveend 回调已经将拖拽过程中的水平、垂直方向上的位移差计算好,在绝大多数的拖拽行为中位移差比坐标更有用。

stableStart 中,将弹窗的初始位置获取好,并缓存下来。 在 move 中只需要将计算好的位移差与缓存下来的初始位置做计算,即可得到新的坐标。

是不是很简单?

我们再换一个更复杂的例子。

3.2.2、业务代码逻辑

前面提到的桌面图标的拖拽移动、合并、删除、调整大小要如何在一个拖拽行为中完成?

desktop

用前面提到过的 stableStartmoveendcancel 四个回调来完成拖拽行为。

stableStart 时机,获取被拖拽的元素,同时收集目标元素的尺寸、位置,并以 mapList 的形式记录下来。这里的目标元素包括桌面图标、删除区域、调整尺寸区域。

move 阶段,将鼠标所在坐标与 mapList 相计算,判定命中的行为及目标。

end 阶段同样需要计算鼠标所在坐标与 mapList 的关系,判定命中的行为及目标,并最终触发对应的操作。

cancel 回调想必我不需要解释,表示各种原因导致的拖拽行为结束的回调。

3.3、交互应该如何设计?

这里可以发挥自己的想象力,在不违反操作直觉的前提下有很大的发挥空间,这里就不展开聊了。

3.4、视觉反馈应该如何实现?

前面的【3.2、代码如何组织?】详细介绍了拖拽行为的实现过程,按照思路描述,完全可以完成拖拽的相关交互。

不知道你有没有疑问,就是视觉反馈有没有必要实现,要如何实现?

3.4.1、有没有必要实现视觉反馈 ?

还是回到桌面图标的拖拽交互中,如果没有视觉反馈,你将无法确定的知道,鼠标释放后将会是移动、还是合并。或者拖拽到了窗口中上方后,无法确定的知道将会执行删除,还是调整尺寸。

因此合理的视觉反馈可以让操作更加稳定可靠,减少心理恐慌。

3.4.2、如何实现视觉反馈 ?

在【3.2.2、业务代码逻辑】部分,其实已经能够将视觉反馈很好的嫁接在其中了。

stableStart 获取到的 mapList 可以作为反馈的基础,再结合 move 阶段判定出命中的行为及目标,借助于特定的形状绘制,即可起到视觉反馈的作用。

当然视觉反馈的表现可以是多种多样的,这里仅仅是提供一种思路。

四、为啥不用现成的工具?

拖拽交互在 WEB 设计中很重要,因此各个框架下都会有对应的组件来实现拖拽功能,其中最常见的功能要数拖拽排序了。

那小剧为什么不用呢 ?

常规的业务开发中,这些工具可以很好地帮助我们完成工作,并且使用合适的话能起到事半功倍的效果。

然而小剧也经常会听到这些的问题:

拖拽相关的组件因为特殊性,或多或少会对代码实施、DOM 布局、CSS 定义等方面增加限制。当你的交互足够复杂时,或者当你们的已有特性和限制冲突时,是否还要使用对应的组件就需要重新考量了。

了解拖拽交互的原理,不仅可以帮助我们更好的使用工具,更可以帮助我们在必要的时候摆脱工具的束缚。