参考文章
缓动函数
匀速运动
首先我们来认识一个缓动函数的核心。先以一个匀速运动的函数为例子
function fn(currentTime, startValue, changeValue, duration) {
let x = currentTime / duration;
return changeValue * x + startValue;
}
*对应到坐标轴上理解
- currentTime:x轴上某点(记为点a),意思是开始运动后到当前的时间
- duration:x轴长度,意思是运动的总时长
- startValue:y轴起点,意思是起始位置
- changeValue:y轴的长度,意思是总移动距离
- 返回值是点a对应的y轴坐标,(当前运动时间,当前移动距离).
- 斜率:运动速度
缓入 ease-in
特点是一开始斜率较小,以 x² 为例
function easeInQuad(currentTime, startValue, changeValue, duration) {
let x = currentTime / duration;
return changeValue * x * x + startValue;
}
如果霎时间懵逼为什么是changeValue * x * x,可以捡起初中数学算一算
缓出 ease-out
特点是结束前斜率变小,较为平滑,以-x² + 2x为例
function easeOutQuad(currentTime, startValue, changeValue, duration) {
let x = currentTime / duration;
return -changeValue * x * (x- 2) + startValue;
}
缓进缓出 ease-in-out
与单纯ease-in/ease-out不同的是, ease-in-out需要两个阶段,因此把x归一化到[0,2]区间
easeInOutQuad(currentTime, startValue, changeValue, duration) {
// 把currentTime映射到[0,2]的区间
// [0,1]为ease-in, [1,2]为ease-out 【含义是ease-in/ease-out开始后经过的时间】
let x = currentTime / duration / 2;
// 是否过半
if (currentTime < 1)
// changeValue / 2 是因为ease-in和ease-out各占一半的移动距离
return changeValue / 2 * x * x + startValue;
// 过半了 要变成ease-out
// 由于[0,1]是ease-in状态,要获取时间在ease-out[1,2]上走过的时间
x--;
return -changeValue / 2 * (x * (x - 2) - 1) + startValue;
}
用缓动函数做一个抽奖组件
要点:
抽奖活动用到缓动函数的地方其实就是奖项滚动的速率,一开始非常快,后面慢慢降速,其实就是一个ease-in-out的场景。
easeInOutQuad(currentTime, startValue, changeValue, duration)中四个参数分别对应
currentTime: x轴当前值,当前经过的时间
duration: x轴总长,设定的抽奖总时间
changeValue: y轴总长,设定的最大动画间隔时间
startValue: y轴初始值,默认的动画间隔时间
代码:
import React from 'react'
import './index.less'
interface IState {
active: number
canClick: boolean
}
type listItem = {
key: string; render: string
}
export interface IProps {
itemSize: number // 每个抽奖项的size
totalTime?: number
resultIndex?: number
defaultIndex?: number
changeValue?: number
format?: Array<Array<number>>
list?: Array<listItem>
onClickStart?: () => void
onFinish?: (key: string | number) => void
onOverTime?: () => void
clickNode?: React.ReactElement
}
const BUTTON_SEQ = -1 // button的序列号
const DEFAULT_ACTIVE = 0 // 默认的active
const DEFAULT_RESULT_INDEX = -1 // 默认的中奖序号(正常从1开始)
export default class Prize extends React.Component<IProps, IState> {
private timer = null
private times = 0 // 已滚动的时长
// eslint-disable-next-line react/static-property-placement
static defaultProps = {
itemSize: 100,
totalTime: 5000,
resultIndex: DEFAULT_RESULT_INDEX, // 后台决定中哪个
changeValue: Math.random() * 3 + 400, // 设定的最大动画间隔时间
format: [
[1, 2, 3],
[8, BUTTON_SEQ, 4],
[7, 6, 5],
], // 用-1代替抽奖button的位置
list: []
}
constructor(props: IProps) {
super(props)
this.state = {
active: 0,
canClick: true,
}
}
/**
* 获取抽奖项
* @returns
*/
getLength = (): number => {
let len = 0
const { format } = this.props
format.forEach((item) => {
len += item.length
})
return len - 1
}
/*
* 缓动函数
* currentTime 当前相对于ease-in/ease-out开始时的时间经过的时间(取决于当前时间有没有过半)
* duration 抽奖总时长
* startValue 动画时间间隔的初始值
* changeValue 动画时间间隔的最终value (延时从startValue一直增加到changeValue)
*/
easeOut = (currentTime: number, startValue: number, changeValue: number, duration: number): number => {
// 把currentTime映射到[0,2]的区间 [0,1]为ease-in, [1,2]为ease-out 【含义是ease-in/ease-out开始后经过的时间】
const x = currentTime / (duration / 2)
// 是否过半
// changeValue / 2 是因为ease-in和ease-out各占一半的增长空间
if (x < 1) {
return (changeValue / 2) * x * x + startValue
}
// 过半了 要变成ease-out
// 由于[0,1]是ease-in状态,要获取时间在ease-out[1,2]上走过的时间
const easeoutTime = x - 1
return (-changeValue / 2) * (easeoutTime * (easeoutTime - 2) - 1) + startValue
}
/**
* 滚动
*/
public Scrolle = (): void => {
const len = this.getLength()
this.setState(
(pre) => ({
active: pre.active === len ? 1 : pre.active + 1,
}),
() => {
const { active } = this.state
const { totalTime, resultIndex, changeValue, onFinish, list } = this.props
if (this.times > totalTime && resultIndex !== DEFAULT_RESULT_INDEX && resultIndex === active) {
// 达到预设时间 且 & 后端返回了结果 & 滚动到了结果
if (typeof onFinish === 'function') {
const { key } = list[resultIndex - 1]
onFinish(key)
}
this.setState({ canClick: true })
clearTimeout(this.timer)
return
}
// 超过我们预定得时间了但还是没返回结果
if (this.times > totalTime && resultIndex === DEFAULT_RESULT_INDEX) {
const { onOverTime } = this.props
if (typeof onOverTime === 'function') {
onOverTime()
}
clearTimeout(this.timer)
this.setState({ canClick: true, active: DEFAULT_ACTIVE })
return
}
const timeout = this.easeOut(this.times, 50, changeValue, totalTime)
this.times += timeout
this.timer = setTimeout(this.Scrolle, timeout)
},
)
}
handleStart = (): void => {
const { canClick } = this.state
const { onClickStart } = this.props
if (!canClick) {
return
}
// 外面可以开始
if (typeof onClickStart === 'function') {
onClickStart()
}
// reset an start
this.times = 0
this.setState(
{
active: DEFAULT_ACTIVE,
canClick: false,
},
() => {
this.Scrolle()
},
)
}
render(): React.ReactNode {
const { active } = this.state
const { format, itemSize, list, clickNode } = this.props
const rowSize = format[0].length
const wrapStyle = {
width: itemSize * rowSize,
height: itemSize * rowSize,
}
const itemStyle = {
width: itemSize,
heigth: itemSize,
}
const PrizeButton = (seq: number) => (
<div key={ `prize-btn-${ seq }` } style={ itemStyle } className="choujiang-button">
{React.cloneElement(clickNode, {
onClick: this.handleStart,
}) }
</div>
)
const renderImg = (seq: number, key: string, render: string) => (
<img alt="" src={ render } key={ `item-img-${ key }` } style={ itemStyle } className={ `${ active === seq ? 'active' : '' } square` } />
)
// format 配置的数目和list实际数目不符合
if (this.getLength() !== list.length) {
console.warn('format length ≠ list length')
return null
}
return (
<div style={ wrapStyle } className="container">
<div className="squares">
{ format.map((row) =>
row.map((seq) => {
if (seq === BUTTON_SEQ) {
return PrizeButton(seq)
}
const { render = '', key } = list[seq - 1]
if (typeof render === 'string') {
return renderImg(seq, key, render)
}
return <div key={ key }>{ render }</div>
}),
) }
</div>
</div>
)
}
}
.square {
box-sizing: border-box;
background-color: gray;
// border: 1px white solid;
}
.app {
height: 100%;
}
.squares {
display: flex;
flex-wrap: wrap;
width: 100%;
height: 100%;
justify-content: center;
position: relative;
}
.container {
width: 330px;
height: 330px;
position: relative;
}
.active {
opacity: 0.5;
}
.choujiang-button {
font-size: 16px;
z-index: 999;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}