开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第七天,点击查看活动详情
相关技术
react
, hooks
, ts
功能描述
根据用户的触摸,对卡片进行一个圆形的旋转滚动。
码上掘金
引入组件好像不支持ts类型会报错,所以功能函数就丢到一个文件里面了
使用
引入 ScrollRotate
组件,在需要使用的数据列表外包裹一层,传入 list
和该区域的高度 height
;内部的卡片需要使用 ScrollRotate.Item
包裹,并传入每个卡片的索引值。
<ScrollRotate list={list} height={`calc(100vh - 100px)`}>
{list?.map((item,i) => (
<ScrollRotate.Item key={item._id} index={i}>
<View className={`card`}>
<View className="cardTitle">{item.title}</View>
</View>
</ScrollRotate.Item>
))}
</ScrollRotate>
组件代码讲解
实现逻辑图
组件初始化
需要先获取 可滚动区域高度,卡片高度,圆的半径,卡片间的角度和可滚动区域占的度数的信息。
这里需要运用到 高中知识
,通过 三角函数
和 角度
跟 弧度
的转化。
- 弧度 = 弧长 / 半径 = 角度 * π / 180; 弧长 = (角度 / 360) * 周长
- 求sin 例: const sin30 = Math.sin(30 * Math.PI / 180) // 0.5 sin30度
- 求角度 例: const deg_30 = 180 * Math.asin(1 / 2) / Math.PI // 30度
例:比如这里要计算两个卡片间的角度
代入求角度的公式就是: a的度数 = 180 * Math.atan((w / 2) / (r - h / 2)) / Math.PI
这里和实际组件中的代码的宽和高写反了(w和h)
useEffect(() => {
/** 获取card的信息 */
const getCardH = async () => {
const cWrapH = document.querySelector(`.comScrollCircleWrap`)?.clientHeight ?? 0
info.current.circleWrapHeight = cWrapH
const cInfo = document.querySelector(`.comScrollCircle-cardWrap`)
info.current.cardH = cInfo?.clientHeight ?? 0
const cW = cInfo?.clientWidth ?? 0
info.current.circleR = Math.round(systemInfo.screenHeight)
// 卡片间的角度
cardDeg.current = 2 * 180 * Math.atan(((info.current.cardH ?? 0) / 2) / (info.current.circleR - cW / 2)) / Math.PI + cardAddDeg
// 屏幕高度对应的圆的角度
info.current.scrollViewDeg = getLineAngle(info.current.circleWrapHeight, info.current.circleR)
console.log(`可滚动区域高度: ${info.current.circleWrapHeight};\n卡片高度: ${info.current.cardH};\n圆的半径: ${info.current.circleR};\n卡片间的角度: ${cardDeg.current}度;\n可滚动区域占的度数: ${info.current.scrollViewDeg}度;`);
setRotateDeg(cardDeg.current * initCartNum)
}
if(list?.length) {
setTimeout(() => {
getCardH()
}, 10);
}
}, [list, cardAddDeg])
给每个卡片设置初始样式
由于我这里每个卡片是一开始直接定位到一个圆上的,所以组件初始化后,需要计算出每个卡片的 top
left
和 rotate
,这里其实也是一些三角函数的处理了。
const cardStyle = useMemo(() => {
const deg = 90 + cardDeg * index
const top = circleR * (1 - Math.cos(deg * Math.PI / 180))
const left = circleR * (1 - Math.sin(deg * Math.PI / 180))
const rotate = 90 - deg
// console.log(top, left, rotate);
return {top: `${top}px`, left: `${left}px`, transform: `translate(-50%, -50%) rotate(${rotate}deg)`}
}, [circleR, cardDeg])
由于这里两个组件需要共享数据,item需要使用list的数据,所以这里使用 useContext
组件间共享数据的封装说明(精简)
首先需要创建一个上下文对象。
const ScrollCircleCtx = React.createContext({
circleR: 0,
cardDeg: 0
})
然后最外层组件 使用上下文对象的 Provider
包裹。
const ScrollCircle = () => {
return (
<ScrollCircleCtx.Provider
value={{
circleR: info.current.circleR,
cardDeg: cardDeg.current
}}
>
{children}
</ScrollCircleCtx.Provider>
)
}
item 组件使用 useContext()
获取上下文数据。
const ScrollRotateItem = () => {
const {circleR, cardDeg} = useContext(ScrollCircleCtx)
return (
<div>
{children}
</div>
)
}
触摸旋转滚动
监听鼠标的事件 onMouseDown
, onMouseMove
, onMouseUp
和 onMouseLeave
事件。如果是移动端的话改成 onTouchStart
, onTouchMove
和 onTouchEnd
即可。
<div
className="comScrollcircle"
onMouseDown={onTouchStart}
onMouseMove={onTouchMove}
onMouseUp={onTouchEnd}
onMouseLeave={onTouchEnd}
style={{
width: `${info.current.circleR * 2}px`,
height: `${info.current.circleR * 2}px`,
transform: `translate(calc(-50% + ${systemInfo.screenWidth / 2}px), -50%) rotate(${rotateDeg}deg)`
}}
>
{children}
</div>
onTouchStart
记录鼠标点击初始化的信息
const onTouchStart = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
touchInfo.current.isTouch = true
touchInfo.current.startY = e.clientY
touchInfo.current.startDeg = rotateDeg
touchInfo.current.time = Date.now()
}
onTouchMove
根据触摸移动的距离,计算出应该旋转的角度,我这里的计算公式为:
初始位置的角度 + (触摸距离 / 可触摸的整个区域高度) * 触摸区域高度所占的角度
我这里的惯性滚动效果是采用 transition
里面的 ease-out
来简单实现的。
const onTouchMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if(!touchInfo.current.isTouch) {
return
}
const y = e.clientY - touchInfo.current.startY
const deg = Math.round(touchInfo.current.startDeg - info.current.scrollViewDeg * (y / info.current.circleWrapHeight))
setRotateDeg(deg)
}
onTouchEnd
当触摸结束时,该次触摸如果小于300ms,且触摸距离大于卡片高度一半的话,则表示用户的该次触摸是快速滚动,则需要旋转更多的角度,这里的计算和上面 move 的同理。
const onTouchEnd = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const {startY, startDeg, time } = touchInfo.current
// 移动的距离
const _y = e.clientY - startY
// 触摸的时间
const _time = Date.now() - time
let deg = rotateDeg
// 触摸的始末距离大于卡片高度的一半,并且触摸时间小于300ms,则触摸距离和时间旋转更多
if((Math.abs(_y) > info.current.cardH / 2) && (_time < 300)) {
// 增加角度变化
const v = _time / 300
const changeDeg = info.current.scrollViewDeg * (_y / info.current.circleWrapHeight) / v
deg = Math.round(startDeg - changeDeg)
}
// 处理转动的角度为:卡片的角度的倍数 (_y > 0 表示向上滑动)
const _deg = cardDeg.current * Math[_y > 0 ? 'floor' : 'ceil'](deg / cardDeg.current)
setRotateDeg(_deg)
touchInfo.current.isTouch = false
}
完整使用样例
import { useState, useEffect } from 'react';
import './index.scss';
import ScrollRotate from './scrollRotate';
export default () => {
const [list, setList] = useState<any[]>([])
useEffect(() => {
init()
}, [])
/** 初始化获取数据 */
const init = async () => {
setTimeout(() => {
const newList = new Array(23).fill('Tops').map((a,i) => (
{_id: 'id' + i, title: a + i}
))
setList(newList)
}, 300);
}
return (
<div className='page-categories-test-1'>
<div className="top" style={{height: '50px', background: '#458cfe'}}></div>
<ScrollRotate list={list} height={`calc(100vh - 100px)`}>
{list?.map((item,i) => (
<ScrollRotate.Item key={item._id} index={i}>
<div className={`card`}>
<div className="cardTitle">{item.title}</div>
</div>
</ScrollRotate.Item>
))}
</ScrollRotate>
<div className="navWrap" onClick={()=>{}}>
<div className='navItem'>T</div>
<div className='navItem'>C</div>
<div className='navItem'>B</div>
</div>
<div className="bottom" style={{height: '50px', background: '#458cfe'}}></div>
</div>
)
}
完整代码
组件代码比较长,就保存到 码上掘金
了。