关于前端:一个有趣的交互效果的分析与实现

31次阅读

共计 14097 个字符,预计需要花费 36 分钟才能阅读完成。

一个乏味的交互成果的实现

成果剖析

最近在做我的项目,碰到了这样一个需要,就是页面有一个元素,这个元素能够在限定的区域内进行拖拽,拖拽实现吸附到右边或者左边,并且在滚动页面的时候,这个元素要半隐状态,进行滚动的时候复原到原来的地位。如下视频所示:

依据视频所展现的成果,咱们得出了咱们须要实现的成果次要有 2 个局部:

  • 拖拽并吸附
  • 滚动半隐元素

那么如何实现这 2 个成果呢?咱们一个成果一个成果的来剖析。

ps: 因为这里采纳的是 react 技术栈,所以这里以 react 作为解说

首先对于第一个成果,咱们要想实现拖拽,有 2 种形式,第一种就是 html5 提供的拖拽 api,还有一种就是监听鼠标的 mousedown,mousemove 和 mouseup 事件,因为这里兼容的挪动端,所以我采纳的是第二种实现办法。

思路是有了,接下来我想的就是将这三个事件封装一下,写成一个 hook 函数,这样不便调用,也不便扩大。

对于拖拽的实现,咱们只须要在鼠标按下的时候,记录一下横坐标 x 和纵坐标 y,在鼠标拖动的时候用以后拖动的横坐标 x 和横坐标 y 去与鼠标按下的时候的横坐标 x 与 y 坐标相减就能够失去拖动的偏移坐标,而这个偏移坐标就是咱们最终要应用到的坐标。

在鼠标按下的时候,咱们还须要减去元素自身所在的 left 偏移和 top 偏移,这样计算出来的坐标才是正确的。

而后,因为元素须要通过设置偏移来扭转地位,因而咱们须要将元素脱离文档流,换句话说就是元素应用定位,这里我采纳的是固定定位。

hooks 函数的实现

基于以上思路,一个任意拖拽性能实现的 hooks 函数就构造就成型了。

当然因为咱们须要限定范畴,这时候咱们能够思考会有 2 个方向上的限定,即程度方向和垂直方向上的限定。除此之外,咱们还须要提供一个默认的坐标值,也就是说元素默认应该是在哪个地位上。当初咱们用伪代码来示意一下这个函数的构造,代码如下:

const useLimitDrag = (el,options,container) => {// 外围代码}
export default useLimitDrag;

参数类型

这个 hooks 函数有 3 个参数,第一个参数天然是须要拖拽的元素,第二个参数则是配置对象,而第三个参数则是限定的容器元素。拖拽的元素和容器元素都是属于 dom 元素,在 react 中,咱们还能够传递 ref 来示意一个 dom 元素,所以这两个参数,咱们能够约定一下类型定义。咱们先来定义元素的类型如下:

export type ElementType = Element | HTMLElement | null;

dom 元素的类型就是 Element | HTMLElement 这 2 个类型,当初咱们晓得 react 的 ref 能够传递 dom 元素,并且咱们还能够传入一个函数当作参数,所以基于这个类型,咱们又额定的扩大了参数的类型,也不便配置。让咱们持续写下如下代码:

import type {RefObject} from 'react';
export type RefElementType = RefObject<ElementType>;
export type FunctionElementType = () => ElementType;

这样 el 和 container 元素的类型就高深莫测,咱们再定义一个类型简略合并一下这两个类型,代码如下:

export type ParamType = RefElementType | FunctionElementType;

接下来,让咱们看配置对象,配置对象次要有 2 个中央,第一个就是默认值,第二个则是限定方向,因而咱们约定了 3 个参数,islimitX,isLimitY,defaultPosition,并且配置对象都应该是可选的,咱们能够应用 Partial 内置泛型将这个类型包裹一下,ok,来看看代码吧。

export type OptionType = Partial<{
    isLimitX: boolean,
    isLimitY: boolean,
    defaultPosition: {
        x: number,
        y: number
    }
}>;

嗯当初,咱们能够批改一下以上的外围函数了,代码如下:

const useLimitDrag = (el: ParamType,options: OptionType,container?: ParamType)  => {// 外围代码}
export default useLimitDrag;

返回值类型

下一步,咱们须要确定咱们返回的值,首先必定是以后被计算出来的 x 和 y 坐标,其次因为咱们这个需要还有一个吸附成果,这个吸附成果是什么意思呢?就是说,以屏幕的两头作为划分界线为左右两局部,当拖动的 x 坐标大于两头,那么就吸附到最左边,否则就吸附到最右边。

依据这个需要,咱们能够将坐标分为最大 x 坐标,最小 x 坐标以及两头的 x 坐标, 当然因为需要只提到了程度方向上的吸附,垂直方向上并没有,然而为了思考扩大,与之对应的咱们同样要分成最大 y 坐标,最小 y 坐标以及两头的 y 坐标。

最初,咱们还能够返回一个是否正在拖动中,不便咱们做额定的操作。依据形容,以上的代码咱们也就能够结构如下:

export type PositionType = Partial<{x: number, y: number, isMove: boolean, maxX: number, maxY: number, minX: number, minY: number, centerX: number, centerY: number}>;
// 
const useLimitDrag = (el: ParamType,options: OptionType,container?: ParamType): PositionType  => {// 外围代码}
export default useLimitDrag;

外围代码实现第一步 — 判断以后环境

最根本的构造搭建好了,接下来第一步,咱们要做什么?首先当然是判断以后环境是否示意挪动端啊。那么如何判断呢?浏览器提供了一个 navigator 对象,通过这个对象的 userAgent 属性咱们就能够判断,这个属性是一个很长的字符串,然而咱们能够从其中一些值看出一些端倪,在挪动端的环境中,通常都会看到 iPhone|iPod|Android|ios 这些字符串值,比方在 iphone 手机中就会有 iPhone 字符串,同理 android 也是。所以咱们就能够通过写一个正则表达式来匹配这些字符串,如果有这些字符串就代表是挪动端环境,否则就是 pc 浏览器环境,代码如下:

const isMobile = navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i);

咱们为什么要判断是否是挪动端环境?因为在挪动端环境,咱们通常监听的是触摸事件,即 touchstart,touchmove 与 touchend,而非 mousedown,mousemove 和 mouseup。所以下一行代码天然就是定义好事件呢。如下:

const eventType = isMobile ? ['touchstart', 'touchmove', 'touchend'] : ['mousedown', 'mousemove', 'mouseup'];

外围代码实现第二步 — 一些初始化工作

下一步,咱们通过 useRef 办法来存储拖拽元素和限定拖拽容器元素。代码如下:

const element = useRef<ElementType>();
const containerElement = useRef<ElementType>();

接着咱们获取配置对象的值,而后咱们定义最大边界的值,代码如下:

const {isLimitX, isLimitY,defaultPosition} = option;
const globalWidthHeight = {
    offsetWidth: window.innerWidth,
    offsetHeight: window.innerHeight
}

随后,咱们用一个变量代表鼠标是否按下的状态,这样做的目标是让拖拽变得更丝滑晦涩一些,而不容易出问题,而后咱们用 useState 定义返回的值,再定义一个对象存储鼠标按下时的 x 坐标和 y 坐标的值。代码如下:

let isStart = false;
const [position, setPosition] = useState<PositionType>({
    x: defaultPosition?.x,
    y: defaultPosition?.y,
    maxX: 0,
    maxY: 0,
    centerX: 0,
    centerY: 0,
    minX: 0,
    minY: 0
});
const [isMove, setIsMove] = useState(false);
const downPosition = {
    x:0,
    y:0
}

另外为了确保拖动在限定区域内,咱们须要设置滚动截断的款式,让元素不能在呈现滚动条后还能拖动,因为这样会呈现问题。咱们定义一个办法用来设置,代码如下:

const setOverflow = () => {const limitEle = (containerElement.current || document.body) as HTMLElement;
    if (isLimitX) {limitEle.style.overflowX = 'hidden';} else {limitEle.style.overflowX = '';}
    if (isLimitY) {limitEle.style.overflowY = 'hidden';} else {limitEle.style.overflowY = '';}
}

这个办法也就比拟好了解了,如果应用的时候传入 isLimitX 那么就设置 overflowX 为 hidden,否则不设置,y 方向同理。

外围代码的实现第三步 — 监听事件

接下来,咱们在 react 的钩子函数中监听事件,此时有了一个抉择就是钩子函数咱们应用 useEffect 还是 useLayoutEffect 呢?要决定应用哪个,咱们须要晓得这两个钩子函数的区别,这个超出了本文范畴,不提及,能够查阅相干材料理解,这里我抉择的是 useLayoutEffect。

在钩子函数的回调函数中,咱们首先将拖拽元素和容器元素存储下来,而后如果拖拽元素不存在,咱们就不执行后续事件,回调函数返回一个函数,在该函数中咱们移除对应的事件。代码如下:

useLayoutEffect(() => {element.current = typeof el === 'function' ? el() : el.current;
    containerElement.current = typeof containerRef === 'function' ? containerRef() : containerRef?.current;
    if (!element.current) {return;}
    element.current.addEventListener(eventType[0], onStartHandler);
    return () => {element.current?.removeEventListener(eventType[0], onStartHandler);
    }
}, []);

外围代码实现第四步 — 拖动开始事件回调

接下来,咱们来看一下 onStartHandler 函数的实现,在这个函数中,咱们次要其实就是存储按下时候的坐标值,并且设置状态以及拖拽元素的鼠标款式和滚动截断的款式,随后当然是监听拖动和拖动完结事件,代码如下:

const onStartHandler = useCallback((e:Event) => {
    isStart = true;
    const target = element.current as HTMLElement;
    if (target) {target.style.cursor = 'move';} 
    const event: Touch | MouseEvent = e instanceof TouchEvent ? e.changedTouches[0] : e as MouseEvent;
    const {clientX, clientY} = event;
    downPosition.x = clientX - target.offsetLeft;
    downPosition.y = clientY - target.offsetTop;
    setOverflow();
    window.addEventListener(eventType[1], onMoveHandler);
    window.addEventListener(eventType[2], onUpHandler);
}, []);

pc 端是能够间接从事件对象中拿进去坐标,可是在挪动端咱们要通过一个 changedTouches 属性,这个属性是一个伪数组,第一项就是咱们要获取到的坐标值。

接下来就是拖动事件的回调函数以及拖动完结的回调函数的实现了。

外围代码实现第五步 — 拖动事件回调

这是一个最外围实现的回调,咱们在这个函数当中是要计算坐标的,首先当然是依据 isStart 状态来确定是否执行后续逻辑,其次,还要获取到以后拖拽元素,因为咱们要依据这个拖拽元素的宽高做坐标的计算,另外还要获取到容器元素,如果没有提供容器元素,那么就是咱们最开始定义的 globalWidthHeight 中取,而后获取鼠标按下时的 x 和 y 坐标值,将以后挪动的 x 坐标和 y 坐标别离与按下时相减,就是咱们的挪动 x 坐标和 y 坐标,如果有设置 isLimitX 和 isLimitY, 咱们还要额定设置滚动截断款式,并且咱们通过将 0 和 moveX 以及最大值(也就是屏幕或者是容器元素的宽高减去拖拽元素的宽高)失去咱们的最终的 moveX 和 moveY 值。

最初,咱们将最终的 moveX 和 moveY 用 react 的状态存储起来即可。代码如下:

const onMoveHandler = useCallback((e: Event) => {if (!isStart) {return;}
    setOverflow();
    const event: Touch | MouseEvent = e instanceof TouchEvent ? e.changedTouches[0] : e as MouseEvent;
    const {clientX, clientY} = event;
    if (!element.current) {return;}
    const {offsetWidth, offsetHeight} = element.current as HTMLElement;
    const {offsetWidth: containerWidth, offsetHeight: containerHeight} = (containerElement.current as HTMLElement ||globalWidthHeight);
    const {x,y} = downPosition;     
    const moveX = clientX - x,
          moveY = clientY - y;     
    const data = {x: isLimitX ? Math.max(0, Math.min(containerWidth - offsetWidth, moveX)) : moveX,
        y: isLimitY ? Math.max(0, Math.min(containerHeight - offsetHeight, moveY)) : moveY,
        minX: 0,
        minY: 0,
        maxX: containerWidth - offsetWidth,
        maxY: containerHeight - offsetHeight,
        centerX: (containerWidth - offsetWidth) / 2,
        centerY: (containerHeight - offsetHeight) / 2
    }
    setIsMove(true);
    setPosition(data);
}, []);

外围代码实现第六步 – 拖动完结回调

最初在拖动完结后,咱们须要重置咱们做的一些操作,比方款式溢出截断,再比方移除事件的监听,以及复原鼠标的款式等。代码如下:

const onUpHandler = useCallback(() => {
    const target = element.current as HTMLElement;
    if (target) {target.style.cursor = '';}
    isStart = false;
    setIsMove(false);
    const limitEle = (containerElement.current || document.body) as HTMLElement;
    limitEle.style.overflowX = '';
    limitEle.style.overflowY = '';
    window.removeEventListener(eventType[1], onMoveHandler);
    window.removeEventListener(eventType[2],onUpHandler);
}, []);

到此为止,咱们的第一个成果外围实现就曾经算是实现大半局部了,最初咱们再把须要用到的状态值返回进来。代码如下:

return {
    ...position,
    isMove
}

合并以上的代码,就成了咱们最终的 hooks 函数,代码如下:

import {useState, useCallback, useLayoutEffect, useRef} from 'react';
import type {RefObject} from 'react';

export type ElementType = Element | HTMLElement | null;
export type RefElementType = RefObject<ElementType>;
export type FunctionElementType = () => ElementType;
export type ParamType = RefElementType | FunctionElementType;
export type PositionType = Partial<{x: number, y: number, isMove: boolean, maxX: number, maxY: number, minX: number, minY: number, centerX: number, centerY: number}>;
export type OptionType = Partial<{
    isLimitX: boolean,
    isLimitY: boolean,
    defaultPosition: {
        x: number,
        y: number
    }
}>;
const useAnyDrag = (el: ParamType, option: OptionType = { isLimitX: true, isLimitY: true,defaultPosition:{ x:0,y:0} }, containerRef?: ParamType): PositionType => {const isMobile = navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i);
    const eventType = isMobile ? ['touchstart', 'touchmove', 'touchend'] : ['mousedown', 'mousemove', 'mouseup'];
    const element = useRef<ElementType>();
    const containerElement = useRef<ElementType>();
    const {isLimitX, isLimitY,defaultPosition} = option;
    const globalWidthHeight = {
        offsetWidth: window.innerWidth,
        offsetHeight: window.innerHeight
    }
    let isStart = false;
    const [position, setPosition] = useState<PositionType>({
        x: defaultPosition?.x,
        y: defaultPosition?.y,
        maxX: 0,
        maxY: 0,
        centerX: 0,
        centerY: 0,
        minX: 0,
        minY: 0
    });
    const [isMove, setIsMove] = useState(false);
    const downPosition = {
        x:0,
        y:0
    }
    const setOverflow = () => {const limitEle = (containerElement.current || document.body) as HTMLElement;
        if (isLimitX) {limitEle.style.overflowX = 'hidden';} else {limitEle.style.overflowX = '';}
        if (isLimitY) {limitEle.style.overflowY = 'hidden';} else {limitEle.style.overflowY = '';}
    }
    const onStartHandler = useCallback((e:Event) => {
        isStart = true;
        const target = element.current as HTMLElement;
        if (target) {target.style.cursor = 'move';} 
        const event: Touch | MouseEvent = e instanceof TouchEvent ? e.changedTouches[0] : e as MouseEvent;
        const {clientX, clientY} = event;
        downPosition.x = clientX - target.offsetLeft;
        downPosition.y = clientY - target.offsetTop;
        setOverflow();
        window.addEventListener(eventType[1], onMoveHandler);
        window.addEventListener(eventType[2], onUpHandler);
    }, []);
    const onMoveHandler = useCallback((e: Event) => {if (!isStart) {return;}
        setOverflow();
        const event: Touch | MouseEvent = e instanceof TouchEvent ? e.changedTouches[0] : e as MouseEvent;
        const {clientX, clientY} = event;
        if (!element.current) {return;}
        const {offsetWidth, offsetHeight} = element.current as HTMLElement;
        const {offsetWidth: containerWidth, offsetHeight: containerHeight} = (containerElement.current as HTMLElement || globalWidthHeight);
        const {x,y} = downPosition;     
        const moveX = clientX - x,
              moveY = clientY - y;     
        const data = {x: isLimitX ? Math.max(0, Math.min(containerWidth - offsetWidth, moveX)) : moveX,
            y: isLimitY ? Math.max(0, Math.min(containerHeight - offsetHeight, moveY)) : moveY,
            minX: 0,
            minY: 0,
            maxX: containerWidth - offsetWidth,
            maxY: containerHeight - offsetHeight,
            centerX: (containerWidth - offsetWidth) / 2,
            centerY: (containerHeight - offsetHeight) / 2
        }
        setIsMove(true);
        setPosition(data);
    }, []);
    const onUpHandler = useCallback(() => {
        const target = element.current as HTMLElement;
        if (target) {target.style.cursor = '';}
        isStart = false;
        setIsMove(false);
        const limitEle = (containerElement.current || document.body) as HTMLElement;
        limitEle.style.overflowX = '';
        limitEle.style.overflowY = '';
        window.removeEventListener(eventType[1], onMoveHandler);
        window.removeEventListener(eventType[2],onUpHandler);
    }, []);
    useLayoutEffect(() => {element.current = typeof el === 'function' ? el() : el.current;
        containerElement.current = typeof containerRef === 'function' ? containerRef() : containerRef?.current;
        if (!element.current) {return;}
        element.current.addEventListener(eventType[0], onStartHandler);
        return () => {element.current?.removeEventListener(eventType[0], onStartHandler);
        }
    }, []);
    return {
        ...position,
        isMove
    }
}
export default useAnyDrag;

接下来咱们来看第二个成果的实现。

半隐成果的实现剖析

第二个成果实现的难点在哪里?咱们都晓得监听元素的滚动事件能够晓得用户正在滚动页面,可是咱们并不知道用户是否进行了滚动,而且也没有相干的事件或者是 API 可能让咱们去监听用户进行了滚动,那么难点就在这里,如何晓得用户是否进行了滚动。

要解决这个问题,咱们还得从滚动事件中作文章,咱们晓得如果用户始终在滚动页面的话,滚动事件就会始终触发,假如咱们在该事件中提早个数百毫秒执行某个操作,是否就代表用户进行了滚动,而后咱们能够执行相应的操作?

侥幸的是,我从这里找到了答案,还真的是这么做。

如此一来,这个成果咱们就实现了一大半了,咱们实现一个 useIsScroll 函数,而后返回一个布尔值代表用户是否正在滚动和进行滚动两种状态,为了实现额定的操作,咱们还能够返回一个用户滚动进行时的以后元素间隔文档顶部的一个间隔,也就是 scrollTop。

如此一来,这个函数的实现就比较简单了,还是在 react 钩子函数中监听滚动事件,而后执行批改状态值的操作。然而当初还有一个问题,那就是咱们如何去存储状态?

外围代码实现第一步 – 解决状态存储的响应式

如果应用 useState 来存储的话,仿佛并没有达到响应式,好在 react 还提供了一个 useRef 函数,这个是一个响应式的,咱们能够基于这个 hooks 函数联合 useReducer 函数来封装一个 useGetSet 函数,这个函数也在这里有总结到,感兴趣的能够去看看。

这个函数的实现其实也不难,次要就是利用 useReducer 的第二个参数强行去更新状态值,而后返回更新后的状态值。代码如下:

export const useGetSet = <T>(initialState:T):[() => T,(s:T) => void] => {const state = useRef<T>(initialState);
    const [,update] = useReducer(() => Object.create(null),{});
    const updateState = (newState: T) => {
        state.current = newState;
        update();}
    return useMemo(() => [() => state.current,
        updateState
    ],[])
}

外围代码实现第二步 – 构建 hooks 函数

接下来咱们来看这个 hooks 函数,很显著这个 hooks 函数有 2 个参数,第一个则是监听滚动事件的滚动元素,第二个则是一个延迟时间。滚动元素的类型与后面的拖拽函数保持一致,咱们来具体看看。

const useIsScroll = <T extends HTMLElement>(el: Window | T | (() => T) = window,throlleTime:number = 300): {
    isScroll: boolean,
    scrollTop: number
} => {// 外围代码}

须要留神这里设置了默认值,el 默认值时 window 对象,throlleTime 默认值时 300

接下来咱们就是应用 useSetGet 办法存储状态,定义一个 timer 用于提早函数定时器,而后监听滚动事件,在事件的回调函数中执行相应的批改状态值的操作,最初就是返回这两个状态值即可。代码如下:

const useIsScroll = <T extends HTMLElement>(el: Window | T | (() => T) = window,throlleTime:number = 300): {
    isScroll: boolean,
    scrollTop: number
} => {const [isScroll,setIsScroll] = useGetSet(false);
    const [scrollTop,setScrollTop] = useGetSet(0);
    const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
    const onScrollHandler = useCallback(() => {setIsScroll(true);
        setScrollTop(window.scrollY);
        if(timer.current){clearTimeout(timer.current);
        }
        timer.current = setTimeout(() => {setIsScroll(false);
        },throlleTime)
    },[])
    useLayoutEffect(() => {const ele = typeof el === 'function' ? (el as () => T)() : el;
        if(!ele){return;}
        ele.addEventListener('scroll',onScrollHandler,false);
        return () => {ele.removeEventListener('scroll',onScrollHandler,false);
        }
    },[]);
    return {isScroll: isScroll(),
        scrollTop: scrollTop()};
}

整个 hooks 函数代码实现起来简单明了,所以也没什么难点,只有了解到了思路,就很简略了。

两个 hooks 函数的应用

外围性能咱们曾经实现了,接下来应用起来也比较简单,款式代码如下:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
body,html {height: 100%;}
body {overflow:auto;}
.App {position: relative;}
.overHeight {height: 3000px;}
.drag {
    position: fixed;
    width: 150px;
    height: 150px;
    border: 3px solid #2396ef;
    background: linear-gradient(135deg,#efac82 10%,#da5b0c 90%);
    z-index: 2;
    left: 0;
    top: 0;
}
.transition {transition: all 1.2s ease-in-out;}

组件代码如下:

import useAnyDrag from "./hooks/useAnyDrag";
import './App.css';
import useIsScroll from "./hooks/useIsScroll";
import {createRef} from "react";

const App = () => {
    // 这里是应用外围代码
   const {x, y, isMove, centerX, minX, maxX} = useAnyDrag(() => document.querySelector('#drag'));
   // 这里是应用外围代码    
   const {isScroll} = useIsScroll();
   const scrollElement = createRef<HTMLDivElement>();
   const getLeftPosition = () => {if (!x || !centerX || isMove) {return x;}
      if (x <= centerX) {return minX || 0;} else {return maxX;}
   }
   const scrollPosition = () => {if (typeof getLeftPosition() === 'number') {if (getLeftPosition() === 0) {return -((scrollElement.current?.offsetWidth || 0) / 2);
         } else {return getLeftPosition() as number + (scrollElement.current?.offsetWidth || 0) / 2;
         }
      }
      return 0;
   }
   return (
      <div className="App">
         <div className="overHeight"></div>
         <div className={`${ isScroll ? 'drag transition' : 'drag'}`}
            style={{left: (isScroll ? scrollPosition() : getLeftPosition()) + 'px', top: y + 'px' }}
            id="drag"
            ref={scrollElement}
         ></div>
      </div>
   )
}
export default App;

结语

通过以上的剖析,咱们就实现了这样一个需要,感觉实现完了之后,还是播种满满的,总结一下我学到了什么。

  1. 拖拽事件的监听以及拖拽坐标的计算
  2. 滚动事件的监听以及 react 响应式状态的实现
  3. 挪动端环境与 pc 环境的判断
  4. 如何晓得用户进行了滚动

本文就到此为止了,感激大家观看。

正文完
 0