一个乏味的交互成果的实现
成果剖析
最近在做我的项目,碰到了这样一个需要,就是页面有一个元素,这个元素能够在限定的区域内进行拖拽,拖拽实现吸附到右边或者左边,并且在滚动页面的时候,这个元素要半隐状态,进行滚动的时候复原到原来的地位。如下视频所示:
依据视频所展现的成果,咱们得出了咱们须要实现的成果次要有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;
结语
通过以上的剖析,咱们就实现了这样一个需要,感觉实现完了之后,还是播种满满的,总结一下我学到了什么。
- 拖拽事件的监听以及拖拽坐标的计算
- 滚动事件的监听以及react响应式状态的实现
- 挪动端环境与pc环境的判断
- 如何晓得用户进行了滚动
本文就到此为止了,感激大家观看。