时间:2022-03-19 作者:剧中人
如果你还没有用过小剧起始页,建议你先体验一番之后,再回来阅读这篇文章。
小剧起始页上线几个月来,受到很多小伙伴的喜欢。在达成自己学习目标的前提下,还能得到你们的认可,并且激励小剧持续迭代下去,这是最开始完全没有想到的。
收到部分小伙伴的反馈后了解到,小剧起始页让他们眼前一亮的地方集中在两个地方。
今天来聊一聊小剧起始页中,拖拽相关的设计及开发思路。
在 PC 的操作上,主要借助于鼠标、键盘实现行为输入。键盘一般可以设计快捷键来辅助操作,而鼠标可以设计出更为丰富复杂的交互。
mousemove
、 mouseenter
、mouseleave
。将按下、释放组合起来,是我们交互中使用最广泛的点击、右键菜单行为。多事件延迟触发,还可以模拟出不太常用的双击、长按事件。
将鼠标的基本操作全部结合在一起,拖拽行为就可以被设计出来,鼠标按下是拖拽触发的时机,移动是执行拖拽的预操作,释放则是拖拽行为的最终确定。
拖拽可以用在移动位置、调整大小等常见的交互中,也可用于实现模拟手势,结合业务特定,还能设计出更多好玩的花样。
点击操作多数情况下需要占用 UI 界面,体现在 UI 组件上可以是各种链接、按钮、下拉框、右键菜单之类的基础组件。
而拖拽因为其交互特性,可以省略掉额外的 UI 设计,常见的 Slider 滑动、textarea 的缩放大小、侧边栏宽度调节、弹窗位置移动等交互,都不需要或者仅需极小的尺寸就可以辅助完成操作的执行。
排序、缩放等操作若是使用点击交互,你得预先知道排序的规则、调整后的目标值。当你了解这一切之后,你才能顺利完成一次排序操作。
拖拽交互则不一样,配合实时反馈的过程展示,操作更加容易,所见即所得的表现可以屏蔽很多内部逻辑设计。
常见的操作,在点击的交互下需要分解为 1、2、3 或者更多步才能完成,例如将 A 分类下的文章移动到 B 分类,常规操作路径如下:
而在拖拽交互中,只需要拖住文章应对的 UI 组件,移动到 B 分类所在的位置,释放鼠标即可完成拖拽,非常简单高效。
拖拽这么好用,但事实是在 WEB 设计中的使用率非常低,你可以打开淘宝、微博等网站,很少能够看到拖拽交互的影子。 这就不得不提一下拖拽交互的弊端了。
前面提到拖拽交互不过分占用 UI 界面,这是其优势,也是弊端。相比点击交互,初次使用产品时可能不会使用拖拽交互,甚至不太容易发现还有拖拽可用。
拖拽比较强依赖鼠标操作,在一些不太灵敏的触摸板上表现很差。对于一些手指活动不是很灵活的老年人、上肢行动不便的残障人士也是使用上的一大障碍。
大型的 WEB 应用的用户群体非常广泛,有对电子设备操作非常流畅的青少年,也有轻度使用电脑的耄耋老人,还有遭遇不幸的残障人士。
为了降低对用户的教育成本,减轻界面学习负担,提高用户覆盖面。均衡考量下,大型 WEB 应用更愿意在交互设计中,使用最为稳妥的点击操作方案。
前面提到了拖拽交互的特点,其中的弊端导致市面上很少看到大面积使用拖拽完成的交互。
那为什么小剧起始页要使用拖拽完成大部分功能交互呢?
首先小剧起始页是非常个人化的网站,绝大多数需求的出发点源于小剧自己,个人的喜好在这其中起到了非常决定性的作用。
其次愿意使用小剧起始页的小伙伴大多和我一样,是深谙 WEB 各类交互,对简洁的视觉有一定追求年轻人。
基于以上两点原因,小剧起始页将很多操作都设计为拖拽交互,比如下面几例:
桌面图标的排序调整顺序,两个图标合并成组,图标移入图标组,图标删除,调整图标尺寸等操作,均可以借助拖拽完成。
这里的交互和桌面非常像,差异在于缺少了图标合并成组操作,多了放回桌面。
小书房是一个类似于浏览器书签管理器的地方,这里的排序调整、合并成组、移动目录、删除等操作都是借助于拖拽交互完成。
Slider 是数值调整中非常常见的交互组件。小剧起始页中,桌面布局的各个尺寸调节,以及三角形生成器工具中,三角形边长微调的工具都是借助于 Slider 组件完成。
小剧在实现 Slider 组件的交互逻辑时,核心交互也是基于拖拽来实现。
鼠标按下是拖拽触发的时机,移动是执行拖拽的预操作,释放则是拖拽行为的最终确定。
这句话在【鼠标的基本操作】部分有提到过,我们将鼠标的按下、移动、释放结合在一起,拖拽行为就可以被设计出来。
听起来是不是很简单,然而实际开发中仍然有几点需要考虑:
很多小伙伴在处理拖拽交互的时候,经常性地发现整个体验非常拉垮。要么是拖拽过快容易导致拖拽行为失效,要么是鼠标移动过程非常生涩不流畅。
这是拖拽交互中最常见的两类问题,具体的原因及解决方法如下:
我们以拖拽弹窗移动位置举例,分析一下问题出在哪儿?
通常情况下,我们会把 mousedown
和 mousemove
事件都绑定在弹窗组件上,并且在 mousemove
过程中实时修改弹窗坐标位置,这样即完成了弹窗拖拽移动位置的交互。
是不是听起来很合理?
问题其实就发生在 mousemove
事件的绑定上。因为 mousemove
事件的触发精度并不高,并且事件监听本身是异步的,所以从鼠标发生移动,到弹窗实际发生位移是有一定时间差的。如果弹窗再定义了 transition
缓动,问题会更加明显。
正是因为这个时间差,导致鼠标移动过快时很可能离开弹窗所在区域,自然也不会触发后续的 mousemove
事件,因此拖拽行为到此便被中断。
解决办法很简单,在 mousedown
触发拖拽开始行为后,mousemove
事件绑定在 body 上,而非弹窗本身。这样即使 UI 反馈延迟也不影响整个拖拽逻辑的响应。
当然你需要注意处理 body 的 mousemove
事件的解除,避免交互异常及内存泄漏。
同样再拿拖拽弹窗移动位置举例,分析一下问题出在哪儿 ?
我们在鼠标移动过程中,计算当前鼠标与上一次触发 mousemove
的坐标差,再获取弹窗的位置,拿坐标差与当前弹窗位置做计算,即可算出弹窗新的位置。
是不是同样听起来很合理?
这里的问题在于弹窗位置的获取时机。因为这个行为涉及到 DOM 属性的读取,在 mousemove
相对高频触发的条件下会引起不必要的性能开销。
如果切换到拖拽排序的场景下,mousemove
过程中频繁获取列表尺寸、位置数据,性能损耗会更加明显。
想要优化这里的体验,提升操作流畅度,就要想办法减少不必要的 DOM 属性读取。
我们可以充分利用 mousedown
这个时机,将 dom 的尺寸、位置在这个时机获取,并缓存下来,同时缓存下来当前鼠标所在位置。在 mousemove
过程中仅仅根据新的坐标点和缓存的数据相计算,最后再应用到 dom 上,即可大幅提升拖拽的流畅度。
这一优化的效果在列表排序中分尤为明显。
拖拽因为有通用的交互共性,在具体的业务中又有着不同的交互特性,因此这里的代码组织分为两部分,分别是通用代码逻辑和业务代码逻辑。
这是小剧使用了很多年的一段拖拽基础代码,绝大多数拖拽场景下小剧都是基于这段代码来实现交互。
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
中,stableStart
、move
、end
、cancel
均为拖拽过程中的事件回调。
其中 stableStart
回调需要 配合 stableDistance
参数使用,作用是拖拽行为的保护。例如同一个元素既要响应点击事件,又要响应拖拽事件,不能因为鼠标按下抬起过程中的轻微位移就判定为是拖拽。
原生的 mousemove
事件只会单纯的将鼠标位置传递给回调函数,这里的 move
、end
回调已经将拖拽过程中的水平、垂直方向上的位移差计算好,在绝大多数的拖拽行为中位移差比坐标更有用。
在 stableStart
中,将弹窗的初始位置获取好,并缓存下来。 在 move
中只需要将计算好的位移差与缓存下来的初始位置做计算,即可得到新的坐标。
是不是很简单?
我们再换一个更复杂的例子。
前面提到的桌面图标的拖拽移动、合并、删除、调整大小要如何在一个拖拽行为中完成?
用前面提到过的 stableStart
、move
、end
、cancel
四个回调来完成拖拽行为。
stableStart
时机,获取被拖拽的元素,同时收集目标元素的尺寸、位置,并以 mapList
的形式记录下来。这里的目标元素包括桌面图标、删除区域、调整尺寸区域。
move
阶段,将鼠标所在坐标与 mapList
相计算,判定命中的行为及目标。
end
阶段同样需要计算鼠标所在坐标与 mapList
的关系,判定命中的行为及目标,并最终触发对应的操作。
cancel
回调想必我不需要解释,表示各种原因导致的拖拽行为结束的回调。
这里可以发挥自己的想象力,在不违反操作直觉的前提下有很大的发挥空间,这里就不展开聊了。
前面的【3.2、代码如何组织?】详细介绍了拖拽行为的实现过程,按照思路描述,完全可以完成拖拽的相关交互。
不知道你有没有疑问,就是视觉反馈有没有必要实现,要如何实现?
还是回到桌面图标的拖拽交互中,如果没有视觉反馈,你将无法确定的知道,鼠标释放后将会是移动、还是合并。或者拖拽到了窗口中上方后,无法确定的知道将会执行删除,还是调整尺寸。
因此合理的视觉反馈可以让操作更加稳定可靠,减少心理恐慌。
在【3.2.2、业务代码逻辑】部分,其实已经能够将视觉反馈很好的嫁接在其中了。
stableStart
获取到的 mapList
可以作为反馈的基础,再结合 move
阶段判定出命中的行为及目标,借助于特定的形状绘制,即可起到视觉反馈的作用。
当然视觉反馈的表现可以是多种多样的,这里仅仅是提供一种思路。
拖拽交互在 WEB 设计中很重要,因此各个框架下都会有对应的组件来实现拖拽功能,其中最常见的功能要数拖拽排序了。
那小剧为什么不用呢 ?
常规的业务开发中,这些工具可以很好地帮助我们完成工作,并且使用合适的话能起到事半功倍的效果。
然而小剧也经常会听到这些的问题:
拖拽相关的组件因为特殊性,或多或少会对代码实施、DOM 布局、CSS 定义等方面增加限制。当你的交互足够复杂时,或者当你们的已有特性和限制冲突时,是否还要使用对应的组件就需要重新考量了。
了解拖拽交互的原理,不仅可以帮助我们更好的使用工具,更可以帮助我们在必要的时候摆脱工具的束缚。