缓动函数

1,062 阅读3分钟

参考文章

  1. aaron-bird.github.io/2019/03/30/…
  2. www.zhangxinxu.com/wordpress/2…

缓动函数

匀速运动

首先我们来认识一个缓动函数的核心。先以一个匀速运动的函数为例子

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

特点是一开始斜率较小,以 为例
在这里插入图片描述

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;
}