拖拽,让用户更爽的交互操作

6,323 阅读14分钟

前端业务中,产品想要通过拖拽来实现的一些交互十分广泛。因为比起点击或触摸之类简单操作,拖拽能够更直接的、可视化的响应用户操作。拖拽交互发生得如此自然,以至我们甚至都没有意识到,拖拽操作存在的本身就是提供更顺滑的过渡已提升用户体验,让交互发生得更加自然。

一. 拖拽适用的业务场景

我们日常所理解 的拖拽,不外乎是拖动某一物体,将其移动至我们指定的位置。而在我们开发中,“拖拽”这一动词会覆盖延申至很多业务场景。排开前端业务,拖动文件将其从原位置移动或复制到我们的目标文件,是我们平时最常见的拖拽操作。

1.1 操作布局

最基础的如拖拽调整物体位置,如下图所示:

1dfc4c343e9e8c9d9e37abc054479af9.gif

1.2 定义拖拽功能

除去布局、位置调整外,我们还可以对拖拽操作进行延申,对于拖拽的操作对象、目标对象甚至拖拽过程本身赋予一些功能。 比如之前很火的一个软件,左右滑动以保留或删除对应信息,让用户筛选符合自己喜好的信息。这就是在拖拽过程中,将拖拽过程定义为保留或者删除功能。

8c40263b4532dcd40970530594c6ad24.gif

将文件拖拽至上传组件上进行文件上传,是将我们拖拽操作的“目标对象”定义了上传功能。

0880e8dc-7f94-467a-871a-0850fae36fad.jpg

实现一个可涂鸦黑板,将拖拽赋予“画画”的功能。

496e6351f57d1fabe20d3245551a04e5.gif

1.2 拖拽动画处理

拖动过程中,我们通常只希望操作对象跟随鼠标(手指)准确的移动。但是某些业务场景中,拖拽到释放后,我们并不想看到操作对象了无生趣的停留在释放它的位置,拖拽释放物体如何符合“惯性”的动画以带来更高的用户体验,是值得我们反复揣摩的事情。ios的橡皮筋效果就是以动画的方式,更为生动的告诉用户:“页面已经到尽头了”。就是因为诸如此类的用户交互细节处理的更好,果粉的粘性才这么好。 比如移动端H5中,处理左右滑动进行翻页,用户手指滑动至一定距离后后释放滑动操作,我们研发需要用根据一系列逻辑以动画的形式来“自然”的帮用户完成剩下的操作——自动翻至下(上)一页,或者让拖动的内容回到原来的位置。

vip.gif

再比如,在移动端H5中,下拉刷新和上拉加载更多数据这样高频率交互方式的动画。

624824fa9ad70190bf725da28c4dc451.gif

1.3 优秀的拖拽事件处理案例

1.3.1 腾讯语音气泡

作为一优秀的案列,处理用户操作可能需要我们考虑到非操作本身的一些问题: · 操作对象面积较小时,如何确保用户准确选中操作对象 · 如何处理用户操作过程中脱离操作界面(范围)的行为 腾讯语音气泡在很细致的在业务反方向上考虑到了这两个问题,首先设计师将拖拽的热区扩大,由气泡本身适当的扩大到气泡边缘外区域,

0.49056003915085533.png

其次,对于用户脱离操作界面的行为,腾讯语音气泡将拖动的响应范围扩大到全屏。既处理了操作边界问题,又二次放大用户拖拽可响应范围,可谓是十分优秀

0.11916630807082074.png

1.3.2 可视化交互

拖拽在可视化中也是非常常见的交互,最主要的拖拽操作在画布内部,大体上分为拖拽图形(改变位置、大小、容器等)和拖拽画布两大类:

v2-9db273d1eab4dcdad8c98e0401150af0_b.gif

所以说拖拽在业务中的应用十分广泛,从以上案例我们可以了解并学习拖拽可应用的场景以及一些对于拖拽交互细节的处理,已最大化的提升项目的用户体验。

二. 拖拽的实现方式

2.1 鼠标事件

利用鼠标mousedown、mousemove和mouseup三个事件可以实现拖拽操作对象跟随鼠标任意移动的效果。 我们定义id为“dragbox”的dom为操作对象,当前网页为dragbox的可操作区域。

// html
<div className={'app'}>
   <div   id="dragbox" />
</div>

确定了操作对象(dragbox)和它的可操作区域后,监听dragbox的mousedown事件,在mousedown事件触发时,给它的可移动区域(document)添加mousemove和mouseup事件监听器,通常为了效果更好,我们还会在这个时候记录鼠标箭头和dragbox的位置偏差。 可移动区域的mousemove事件处理中,根据鼠标的位置以及鼠标箭头和dragbox的位置偏差,来计算并改变dragbox的位置。一招惹的就甩不掉的感觉十分糟糕,mouseup的时候记得移除document对于mousemove事件的监听,让dragbox做个又乖又粘好同志。

window.onload = function(){
  const dragbox = document.getElementById('dragbox');
  let diffX = 0
  let diffY = 0
  
  dragbox.onmousedown = function(e){
    const event = e || window.event
    // 鼠标箭头和dragbox的位置偏差
    diffX = event.clientX - dragbox.offsetLeft;
    diffY = event.clientY - dragbox.offsetTop;

    document.onmousemove = function(e){
      const event = e || window.event;
      let moveX = event.clientX - diffX;
      let moveY = event.clientY - diffY;

      // dragbox可移动区上的边界
      const desX = window.innerWidth - dragbox.offsetWidth
      const desY = window.innerHeight - dragbox.offsetHeight

      moveX = moveX < 0 ? 0 : moveX > desX ? desX : moveX
      moveY = moveY < 0 ? 0 : moveY > desY ? desX : moveY

      dragbox.style.left = moveX + 'px';
      dragbox.style.top = moveY + 'px'
    }
  }
  document.onmouseup = function(event){
    document.onmousemove = null
  }
}

2.2 移动端Touch事件

相较于PC端的鼠标事件,移动端对应的是touch事件。把上段代码中的mousedown、mousemove和mouseup事件在移动端分别换成touchstart、ontouchmove和touchend,再把监听位置的对象由mouse事件’‘e''改为“e.touches[0]“即可。 话说PC端中鼠标箭头只有一个,移动端用户十个手指,如果用户乱摸,更甚者手脚并用怎么办?值得一提的是移动端还可以监听触摸中断触发touchcancel事件。而且中断方式还能基于特定实现而有所不同。比如, 用户乱摸创建了太多的触摸点;再比如用户手机突然来电打断触摸。

2.3 H5 draggable

以上通过mouse事件来实现拖拽已经是很古老的方法了。H5提供拖放AIP(移动端不支持),元素的draggable属性设置为true,既表示元素是可以拖动的(图片和链接的draggable属性默认为true)。用mouse事件处理拖拽与拖放事件做一个类比:

  • dragstart = mousedown + mousemove
  • drag = mousemove
  • dragend = mouseup 除了操作对象元素在拖放过程中会触发的事件外,还有一类是拖放目标元素触发的事件:
  • dragenter 操对象进入目标元素时触发
  • dragover 当操作对象在目标元素中,离开目标元素前持续触发
  • dragleave 操作对象离开目标元素时触发
  • drop 拖拽操作在目标元素上释放时触发 拖拽操作时,各个事件的触发顺序如下图所示:

0.202511205858678.png

附上一个实践小demo:

// html
<div className={'app'}>
    <div id="target" />
    <span id="ball" className="ball" draggable={true} />
</div>
// js
window.onload = function(){
  const ball = document.getElementById('ball') // 操作对象
  const target = document.getElementById('target') // 目标对象

  ball.ondragstart = function(e){
    console.log('e', e)
    e.dataTransfer.setData('Text',e.target.id)
    console.log('操作对象被拖拽,拖拽开始')
  }
  target.ondragenter = function(e){
    e.preventDefault()
   console.log('操作对象进入目标对象')
  }
  target.ondragover = function(e){
    // 阻止浏览器默认事件
    e.preventDefault()
    console.log('操作对象在目标对象中移动')
  }
  target.ondragleave = function(e){
    // 阻止浏览器默认事件
    e.preventDefault()
    console.log('操作对象离开目标对象')
  }
  target.ondrop = function(e){
    // 阻止浏览器默认事件
    e.preventDefault()
    var data=e.dataTransfer.getData("Text")
    e.target.appendChild(document.getElementById(data))
    document.getElementById(data).style = 'top: 50%; left: 50%'
    console.log('拖拽施放')
  }
  ball.ondragend = function(e){
    // 阻止浏览器默认事件
    e.preventDefault()
    console.log('拖拽结束')
  }
}

拖拽API接受的事件参数中有一个dataTransfer 属性,用于保存拖放过程中的数据,还可以自定义拖动的图像、拖拽效果和获取拖动操作中的文件列表等。具体参考dataTransfer

2.4 canvas

在canvas画布中绘制出来的图像无法像浏览器dom一样添加事件或属性来进行拖拽移动等操控,canvas画布内部的移动都是通过清空画布再重新绘制。canvas本身作为HTML节点是可以绑定事件来响应我们的“拖拽”行为。国际惯例,先上个demo:

// js
window.onload = function(){
  this.startX = 0
  this.startY = 0
  this.diffX = 0
  this.diffY = 0
  this.boxWidth = 150
  this.boxHeight = 75

  const myCanvas=document.getElementById("myCanvas")
  const ctx=myCanvas.getContext("2d");
  ctx.fillStyle="rgb(255, 238, 0)"
  ctx.fillRect(this.startX,this.startY,150,75)

  myCanvas.onmousedown = (e)=>{
    const event = e || window.event
    // 鼠标箭头和目标对象的位置偏差
    this.diffX = event.clientX - myCanvas.offsetLeft - this.startX
    this.diffY = event.clientY - myCanvas.offsetTop - this.startY
    // 判断鼠标箭头是否在拖拽目标上
    if(this.diffX<0 || this.diffX>this.boxWidth || this.diffY<0 || this.diffY>this.boxHeight){
      return 
    }

    document.onmousemove = (e)=>{
      const event = e || window.event;
      let moveX = event.clientX - myCanvas.offsetLeft - this.diffX;
      let moveY = event.clientY - myCanvas.offsetTop - this.diffY;

      // dragbox可移动区上的边界
      const desX = myCanvas.offsetWidth - this.boxWidth
      const desY = myCanvas.offsetHeight - this.boxHeight

      moveX = moveX < 0 ? 0 : moveX > desX ? desX : moveX
      moveY = moveY < 0 ? 0 : moveY > desY ? desX : moveY

      ctx.fillRect(moveX, moveY,150,75)
      this.startX = moveX
      this.startY = moveY
    }
  }

  document.onmouseup = (event)=>{
    document.onmousemove = null
  }
}

// html
<div className="App">
     <canvas id="myCanvas" width="500" height="400" style={{border:'1px solid #c3c3c3'}}>
         您的浏览器不支持 HTML5 canvas 标签。
     </canvas>
 </div>

效果图如下,我们之前介绍过一种黑板涂鸦的场景就是用这种方式实现的:

canvas_5.gif

添加清空画布后效果如下;

ctx.clearRect(0,0,500,400);  
ctx.fillRect(moveX, moveY,150,75)

canvas_7.gif

总之以上案例足以体现在canvas中的拖拽移动操作都是通过每次清空画布再重新绘制的,核心还是在于根据鼠标箭头拖动位置与拖拽目标对象位置的计算。这与用 Touch事件和鼠标事件来实现拖拽一样。在以上Demo中我们的实现方式均为事件驱动,都是直接给DOM绑定事件,在操作ui时通过触发事件,事件回调响应处理最后呈现为ui更新。

三. 开源的解决方案及其设计原理

关于拖拽的组件库有很多如react-dnd , react-draggable, react-resizable 等,以react-dnd为案例做一个简单的了解,窥探一下冰山一角。 先用实现一个简单的demo来了解react-dnd 的基本使用, 其中Item组件既是 Drag Source 也是 Drop Target:

import React from 'react'
import { DndProvider, DragSource, DropTarget } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import "./index.css"

const data = [
  {id: 10, text: '1'},
  {id: 11, text: '2'},
  {id: 12, text: '3'},
  {id: 13, text: '4'},
  {id: 14, text: '5'}
]

export const ItemTypes = {
  LI: 'li',
}
class Item extends React.Component {
  constructor(props) {
    super(props)
  }

  render() {
    const {connectDragSource, connectDropTarget, move, ...restProps} = this.props;
    return (
      connectDragSource(
        connectDropTarget(
          <div {...restProps}>{restProps.text}</div>
        )
      )
    )
  }
}

const DragNode = DragSource(ItemTypes.LI, {
  beginDrag(props, monitor, component) {
    return {
      index: props.index,
    };
  },
}, connect => ({
  connectDragSource: connect.dragSource(),
}))(Item);

const DropNode = DropTarget(ItemTypes.LI, {
    drop(props, monitor) {
      const dragIndex = monitor.getItem().index;
      const hoverIndex = props.index;
      if (dragIndex === hoverIndex) return;
      props.move(dragIndex, hoverIndex);
    }
  }, (connect)=> {
    return {
      connectDropTarget: connect.dropTarget()
    }
  }
)(DragNode)

class Demo extends React.Component {
  state = {
    data
  };

// 实现拖拽操作对象和拖拽目标对象位置进行交换
  moveRow = (start, end) => {
    let {data} = this.state;
    let temp = data[start]
    data[start] = data[end];
    data[end] = temp;
    this.setState({data})
  }

  render() {
    return (
      <DndProvider backend={HTML5Backend}>
        <div className='move'>
        {
          this.state.data.map( (item, index) => {

            const prop = {
              move: this.moveRow,
              key: item.id,
              id: item.id,
              text: item.text,
              index: index
            }
            return <DropNode {...prop}/>

          })
        }
      </div>
      </DndProvider>
    )
  }
}

export default Demo;

运行效果如图:

react-dnd.gif

3.1 react-dnd基本概念

结合上述demo了解一下react-dnd基本概念

  • Backends React DnD抽象了后端的概念,分html5-backend和touch-backend两种后端。

  • Item and Types Item代表拖拽操作对象,是一个javascript对象。React DnD 是数据驱动模式,内部处理DOM事件同时将事件转化为React DnD内部的redux actionc。Type 则是定义应用程序里支持的拖拽类型,是一个类型常量的枚举,类似于Redux操作类型枚举。

  • Monitor Monitor 用于更新组件的属性以响应拖放状态的更改。对于每个需要跟踪拖放状态的组件,可以定义一个收集函数,React DnD 通过调用收集函数来存储这些状态。

  • Connectors Backend 处理DOM事件,但是组件使用React来描述DOM的施放状态,connector 连接组件和 Backend,让 Backend 获取要监听的DOM节点

  • Drag Sources and Drop Targets 即 React-DnD 的主要抽象单元:拖放源 和 拖放目标;它们将类型、项目、副作用和收集功能与组件联系在一起。DropTarget 和 DragSource 是一个高阶组件,要使组件或其某些部分可拖动,都需要将该组件包装到DragSource 声明中;将组件使用 DropTarget 包裹变得可以响应 drop。

//  DragSource使用示例
import { DragSource } from 'react-dnd'
class MyComponent {
  /* ... */
}
export default DragSource(type, spec, collect)(MyComponent)

3.2 React DnD 设计原理

86509a01-0f46-4cdd-8d9e-9d919debe686.png

3.2.1 react-dnd:
  1. 通过 Provide 机制将创建的 DragDropManager 实例注入到被包装的根组件,连接业务层与核心层。
  2. 获取将业务层backend 和 组件状态数据传递给核心工厂函数。
  3. 将从核心层获取到的组件状态传递给业务层。 DragDropContext 从业务层接受 backendFactory 和 backendContext 并返回一个dragDropManager 实例,
// DndContext.ts
/**
 * Create the React Context
 */
export const DndContext = React.createContext<DndContextType>({
    dragDropManager: undefined,
})

/**
 * Creates the context object we're providing
 * @param backend
 * @param context
 */
export function createDndContext<BackendContext, BackendOptions>(
    backend: BackendFactory,
    context?: BackendContext,
    options?: BackendOptions,
    debugMode?: boolean,
): DndContextType {
    return {
        dragDropManager: createDragDropManager(
            backend,
            context,
            options,
            debugMode,
        ),
    }
}

Provider 注入dragDropManager 通过React Context 创建上下文进行传递,传递到DragDropContext 内部的 DragSource 等高阶组件

// DndProvider.tsx
import * as React from 'react'
import { memo } from 'react'
import { BackendFactory, DragDropManager } from 'dnd-core'
import { DndContext, createDndContext } from './DndContext'
...
export const DndProvider: React.FC<DndProviderProps<any, any>> = memo(
    ({ children, ...props }) => {
        // getDndContextValue获取manager
        const [manager, isGlobalInstance] = getDndContextValue(props) 

        /**
         * If the global context was used to store the DND context
         * then where theres no more references to it we should
         * clean it up to avoid memory leaks
         */
        React.useEffect(() => {
            if (isGlobalInstance) {
                refCount++
            }

            return () => {
                if (isGlobalInstance) {
                    refCount--

                    if (refCount === 0) {
                        const context = getGlobalContext()
                        context[instanceSymbol] = null
                    }
                }
            }
        }, [])
        return <DndContext.Provider value={manager}>{children}</DndContext.Provider>
    },
)
...
 // decorateHandler.tsx
...
export function decorateHandler<Props, CollectedProps, ItemIdType>({
    DecoratedComponent,
    createHandler,
    createMonitor,
    createConnector,
    registerHandler,
    containerDisplayName,
    getType,
    collect,
    options,
}: DecorateHandlerArgs<Props, ItemIdType>): DndComponent<Props> {
    const { arePropsEqual = shallowEqual } = options
    const Decorated: any = DecoratedComponent

    const displayName =
        DecoratedComponent.displayName || DecoratedComponent.name || 'Component'

    class DragDropContainer
        extends React.Component<Props>
        implements DndComponent<Props> {
       ...
        private manager: DragDropManager | undefined
        private handlerMonitor: HandlerReceiver | undefined
        private handlerConnector: Connector | undefined
        private handler: Handler<Props> | undefined
        ...
        public constructor(props: Props) {
            super(props)
            this.disposable = new SerialDisposable()
            this.receiveProps(props)
            this.dispose()
        }
        ...
        public render() {
            return (
                 //  使用 consume 获取 dragDropManager 并传递给 receiveDragDropManager
                <DndContext.Consumer>
                    {({ dragDropManager }) => {
                        // receiveDragDropManager 将 dragDropManager 保存在 this.manager 上,并通过 dragDropManager 创建 monitor,connector
                        this.receiveDragDropManager(dragDropManager)
                        if (typeof requestAnimationFrame !== 'undefined') {
                            requestAnimationFrame(() => this.handlerConnector?.reconnect())
                        }
                        return (
                            <Decorated
                                {...this.props}
                                {...this.getCurrentState()}
                                // NOTE: if Decorated is a Function Component, decoratedRef will not be populated unless it's a refforwarding component.
                                ref={isRefable(Decorated) ? this.decoratedRef : null}
                            />
                        )
                    }}
                </DndContext.Consumer>
            )
        }
    ...
    return (hoistStatics(
        DragDropContainer,
        DecoratedComponent,
    ) as any) as DndComponent<Props>
}
3.2.2 dnd-core:

dnd-core内部通过redux做状态管理,将组件的数据存储入store,同时根据store的数据来计算组件的状态

  1. 其中实现DragDropManager连接 Backend 和 Monitor。
import { DragDropManagerImpl } from './DragDropManagerImpl'
import { DragDropManager, BackendFactory } from './interfaces'

export function createDragDropManager(
    backendFactory: BackendFactory,
    globalContext: unknown,
    backendOptions: unknown,
    debugMode?: boolean,
): DragDropManager {
    const manager = new DragDropManagerImpl(debugMode)
    const backend = backendFactory(manager, globalContext, backendOptions)
    manager.receiveBackend(backend)
    return manager
}
  1. DragDropMonitor用于从 store 获取item状态
public constructor(debugMode = false) {
        const store = makeStoreInstance(debugMode)
        this.store = store
        this.monitor = new DragDropMonitorImpl(
            store,
            new HandlerRegistryImpl(store),
        )
        store.subscribe(this.handleRefCountChange)
    }

诸如是否为拖拽对象、是否在拖拽进行中、是否可以拖拽、拖拽组件位置变化等状态 3. 给Backends提供施放action

export function createDragDropActions(
    manager: DragDropManager,
): DragDropActions {
    return {
        beginDrag: createBeginDrag(manager),
        publishDragSource: createPublishDragSource(manager),
        hover: createHover(manager),
        drop: createDrop(manager),
        endDrag: createEndDrag(manager),
    }
}
3.2.3 Backends:

处理具体实现拖拽的实现和事件,用插件的方式支持拖拽的具体实现,将来可能还会支持更多。在后台,所有后端都将DOM事件转换为React DnD可以处理的内部Redux动作。 其中 html5-backend 基于HTML5拖放AIP封装,是React DnD主要支持的Backend:

// HTML5BackendImpl.ts
...
private addEventListeners(target: Node) {
        // SSR Fix (https://github.com/react-dnd/react-dnd/pull/813
        if (!target.addEventListener) {
            return
        }
        target.addEventListener(
            'dragstart',
            this.handleTopDragStart as EventListener,
        )
        target.addEventListener('dragstart', this.handleTopDragStartCapture, true)
        target.addEventListener('dragend', this.handleTopDragEndCapture, true)
        target.addEventListener(
            'dragenter',
            this.handleTopDragEnter as EventListener,
        )
        target.addEventListener(
            'dragenter',
            this.handleTopDragEnterCapture as EventListener,
            true,
        )
        target.addEventListener(
            'dragleave',
            this.handleTopDragLeaveCapture as EventListener,
            true,
        )
        target.addEventListener('dragover', this.handleTopDragOver as EventListener)
        target.addEventListener('dragover', this.handleTopDragOverCapture, true)
        target.addEventListener('drop', this.handleTopDrop as EventListener)
        target.addEventListener(
            'drop',
            this.handleTopDropCapture as EventListener,
            true,
        )
    }
...

touch-backend 实现原理也是基于上述拖拽的实现方式中移动端Touch事件和mouse事件:

// TouchBackendImpl.ts
...
const eventNames: Record<ListenerType, EventName> = {
    [ListenerType.mouse]: {
        start: 'mousedown',
        move: 'mousemove',
        end: 'mouseup',
        contextmenu: 'contextmenu',
    },
    [ListenerType.touch]: {
        start: 'touchstart',
        move: 'touchmove',
        end: 'touchend',
    },
    [ListenerType.keyboard]: {
        keydown: 'keydown',
    },
}
...

React DnD原理最底层就是利用数据驱动,react-dnd处理业务,将绑定的DOM处理为数据对象item,Backends定义绑定事件,在用户操作ui的时候触发事件,dnd-core响应处理事件并更新数据,最终呈现在ui更新上。其中利用Redux来做数据状态管理。这样降低DOM和拖拽事件处理的耦合以便扩展,我们在移动端和PC端上可以选择对应支持的Backend。 关于 React DnD内部实现有兴趣了解更多的,推荐这篇文章——拖拽组件:React-DnD用法及源码解析,配合源码观看更佳

四. 总结

现实需求中拖拽适用场景以及设计方案远不止上文介绍的几种,拖拽是非常常见的交互,但是想把交互做好并不容易。在设计拖拽交互时应注意利用视觉符号告知用户可拖拽、拖拽中被拖拽对象状态明确变化、拖拽中相关对象要有对应反馈,从细节处优化拖拽交互已提升用户体验。在实现上复杂的拖拽交互时很可能现有的js库和组件不能够满足产品或ui需求,这个时候非常考验前端开发者的代码功底,所以了解一下拖拽原理、学习优秀的拖拽交互案例很有必要。

欢迎指正!