实现一镜到底动画

2,688 阅读3分钟

1.gif

简介

本文会讲解如何使用框架实现一个,一镜到底动画。
使用 "pixi.js": "^5.3.7""gsap": "^3.6.0""alloytouch": "^0.3.0" 这三个框架。

pixi.js

Pixi 是一个超快的2D渲染引擎。
Pixi教程
Pixijs API
我们这里主要使用它来管理,动画中要在界面上展示的图片和手动绘制的图形。

先创建 Pixi 应用

// 设置样式 自适应画布宽高
<style>
.home {
  position: absolute;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
}

canvas {
  width: 100%;
  height: 100%;
}
</style>
import * as PIXI from "pixi.js";
// import AlloyTouch from 'alloytouch'
// import { TweenMax, TimelineMax } from 'gsap'

class Index {
  constructor (options) {

    // 事件监听
    this.callbacks = {
      loadProgress: []
    }

    this.width = options.width || window.innerWidth // 场景宽
    this.height = options.height || window.innerHeight // 场景高
    this.container = options.container || document.body // 场景容器

    // 创建场景
    this.app = new PIXI.Application({
      resolution: 2,	// 渲染器的分辨率/设备像素比率  设置为2 肉眼看就不会是模糊的
      transparent: true, // 取消背景颜色
      width: this.width,// 创建后的画布宽
      height: this.height// 创建后的画布高
    })
    // 加入容器
    this.container.appendChild(this.app.view)
  }
}
export default Index

加入静态资源

  1. 先组装资源数据,然后放入 PixiLoader 中。
  2. 通过 loader.onProgress.add((row)=>{}) 监听资源的加载进度,当 row.progress 为100时表示加载完成。
  3. 通过 loader.add() 添加资源。
// loader.add() 简介
// add() 是链式的 可以使用这种方式加载 --第一个参数 就是资源的名称
loader.add('bg1', './imgs/girl1.png').add('bg2', './imgs/girl2.png')
      .add('bg3','./imgs/girl3.png')
// 也可以直接传入多个  -- name 就是后面使用资源的名称
loader.add([name:"girl1",url:"/static/imgs/girl/160.png"])
// 或者直接传入路径 --路径就是资源的名称
loader.add("/static/imgs/girl/160.png")
  /**
   * 图片资源 组装
   */
  resources = () => {
    let sprites = [];
    // 女孩资源
    for (let i = 0; i < 62; i += 1) {
      sprites.push({
        name: `girl${i}`,
        url: `/static/imgs/girl/${160 + i}.png`,
      });
    }
    // 背景资源
    for (let i = 0; i < 50; i += 1) {
      sprites.push({
        name: `ani${i}`,
        url: `/static/imgs/ani/${701 + i}.png`,
      });
    }
    // 漂浮物品资源
    for (let i = 1; i < 8; i += 1) {
      sprites.push({
        name: `item${i}`,
        url: `/static/imgs/items/${i}.png`,
      });
    }
    // 飞机资源
    for (let i = 0; i < 25; i += 1) {
      sprites.push({
        name: `plane${i}`,
        url: `/static/imgs/plane/${408 + i}.png`,
      });
    }

    return sprites;
  }
  
  // 添加资源进入 应用
  load = () => {
    const sprites = this.resources(); // 图片资源
    const loader = new PIXI.Loader();
    // 图片加载进度
    // loader.onProgress.add((row) => {
    //   console.log("进度", row.progress);
    // });
    loader.add(sprites).load(this.loadDone);
  };

  // 精灵加载后执行
  loadDone = () => {
    ...
  };
  

实现动画

  1. 添加 pixi精灵 通用属性设置
  // pixi精灵的 通用属性设置
  /**
   * 设置尺寸
   * @param {*} obj PIXI.Sprite 对象
   * @param { mode: "", width: "", height:""} size 设置对象
   * mode : 类型  
   * width : 有类型 为比例  -- 无类型 自定义宽度
   * height : 有类型 为比例  -- 无类型 自定义高度
   */
  setSize (obj, size) {
    if (size.mode === 'widthFit') { // 按宽度适应 比例
      const scale = this.app.screen.width * size.width / obj.width
      obj.scale.x = scale
      obj.scale.y = scale
    } else if (size.mode === 'heightFit') { // 按高度适应 比例
      const scale = this.app.screen.height * size.height / obj.height
      obj.scale.x = scale
      obj.scale.y = scale
    } else { // mode为空 自定义 宽高
      obj.width = size.width
      obj.height = size.height
    }
  }
  
  /**
   * 设置锚点 位置
   * @param {*} obj PIXI.Sprite 对象
   * @param {*} anchor 字符串 -- 从类型中库中获取比例
   * 对象 -- 自定义 x,y 的比例
   */
  setAnchor (obj, anchor) {
    if (typeof anchor === 'string') {
      // 根据类型 获取比例
      const anchorMap = this.positionMap(anchor)
      obj.anchor.x = anchorMap.x
      obj.anchor.y = anchorMap.y
    } else {
      // 自定义比例
      obj.anchor.x = anchor.x
      obj.anchor.y = anchor.y
    }
  }

  /**
   * 设置位置 精灵在画布上对应的位置
   * @param {*} obj PIXI.Sprite 对象
   * @param {*} position 字符串 -- 从类型中库中获取比例
   * 对象 -- 自定义 x,y 的比例
   */
  setPosition (obj, position) {
    if (typeof position === 'string') {
      // 根据类型 获取比例
      position = this.positionMap(position)
    }
    // 根据比例 计算 页面位置
    obj.position.x = position.x * this.app.screen.width
    obj.position.y = position.y * this.app.screen.height
  }

  /**
   * 根据类型 获取比例 --通过比例计算位置
   * 比例库
   * @param {*} type 
   */
  positionMap (type) {
    const map = {
      top: { x: 0.5, y: 0 },
      right: { x: 1, y: 0.5 },
      bottom: { x: 0.5, y: 1 },
      left: { x: 0, y: 0.5 },
      topLeft: { x: 0, y: 0 },
      topRight: { x: 1, y: 0 },
      bottomLeft: { x: 0, y: 1 },
      bottomRight: { x: 1, y: 1 },
      center: { x: 0.5, y: 0.5 }
    }
    return map[type] || { x: 0, y: 0 }
  }
  1. 实现一个精灵交换动画
// 精灵加载后执行
  loadDone = () => {
    this.tesTspirit()
  };

  /**
   * 资源开始加载动画
   */
  tesTspirit = () => {
    // 创建 纹理对象
    const sprite = new PIXI.Sprite(PIXI.utils.TextureCache['girl0']);

    // 设置大小
    this.setSize(sprite, { mode: 'widthFit', width: 1 })
    // 设置锚点位置
    this.setAnchor(sprite, "center")
    // 设置画布位置
    this.setPosition(sprite, "center")

    // 资源加入舞台
    this.app.stage.addChild(sprite);
    
    // 组装同类资源名称
    const frames = []
    for (let i = 0; i < 62; i += 1) {
      frames.push(`girl${i}`)
    }

    sprite.currentFrame = 0
    sprite.frames = frames
    setInterval(() => {
      sprite.currentFrame += 1
      // 当该类型精灵 循环完后 重复 循环
      if (sprite.currentFrame >= sprite.frames.length) sprite.currentFrame = 0
      const frame = sprite.frames[sprite.currentFrame]
      sprite._texture = PIXI.utils.TextureCache[frame]
    }, 100)

  };

PIXI.utils.TextureCache 这里面缓存了,之前加入的所有资源,通过前面定义的名称获取。
PIXI.Sprite 是渲染到屏幕上的纹理对象。可以把图片资源放入纹理对象中进行展示。 this.app.stage.addChild(sprite) 画布中放入纹理对象中展示。
sprite._texture 图片资源对象,修改这个对象可以直接改变展示的图片。
我们通过修改纹理对象的 _texture 就实现了一个图片资源变换的动画。

gsap

gsap 是一款web动画库。我使用的是 TweenMaxTimelineMax

  • 使用 TimelineMax 创建时间轴动画与滑动高度关联。实现根据滚动高度来展示时间轴对应的展示。
  • 使用 TweenMax 创建补间动画加入时间轴动画中。

实现动画生成方法

这里实现方式分为两类,使用框架的动画,不使用框架的动画。

  1. 不使用框架,修改资源展示实现动画
  /**
   * 无限循环动画
   * @param {*} obj 要改变的对象
   * @param {*} frameRate 动画循环速度 --数越大 动画越快
   * @param {*} frames 精灵的名称 总和 --同类型的资源名称
   */
  onInfinite (obj, frameRate, frames) {
    obj.frames = frames
    obj.currentFrame = 0
    // 
    this.aniIntervals.push(setInterval(() => {
      obj.currentFrame += 1
      // 当该类型精灵 循环完后 重复 循环
      if (obj.currentFrame >= obj.frames.length) obj.currentFrame = 0
      const frame = obj.frames[obj.currentFrame]
      obj._texture = PIXI.utils.TextureCache[frame]
    }, 1000 / frameRate))
  }
  
  /**
   * 帧动画
   * 自定义 事件 根据对应的时间 修改纹理对象 展示的图片
   * @param {*} obj 要改变的对象
   * @param {*} duration 动画持续时间
   * @param {*} delay 动画延迟时间
   * @param {*} frames 精灵的名称 总和
   */
  onFrame (obj, duration, delay, frames) {
    // 在本地缓存中 保存自定义事件 在滑动事件是触发
    // progress 滑动事件 计算后 滚动位置 对应的 动画时间
    this.on('progress', (progress) => {
      const frameProgress = (progress - delay) / duration
      let index = Math.floor(frameProgress * frames.length)
      if (index < frames.length && index >= 0) {
        const frame = frames[index]
        obj._texture = PIXI.utils.TextureCache[frame]
      }
    })
  }

在帧动画中,我们自定义了事件。

  • 在本地创建 callbacks 变量缓存自定义事件。
  • 通过传入的名称保存事件,每一个事件可以对应多个回调函数,执行时会执行全部的回调函数。
  // 事件相关 ───────────────────────────────────────────────────────────────────────
  /**
   * 添加自定义事件
   * @param {*} name 事件名称
   * @param {*} callback 回调函数
   */
  on (name, callback) {
    this.callbacks[name] = this.callbacks[name] || []
    this.callbacks[name].push(callback)
  }

  /**
   * 删除自定义事件
   * @param {*} name 事件名称
   * @param {*} callback 回调函数 必须 是 添加时的函数
   */
  off (name, callback) {
    const callbacks = this.callbacks[name]
    if (callbacks && callbacks instanceof Array) {
      const index = callbacks.indexOf(callback)
      if (index !== -1) callbacks.splice(index, 1)
    }
  }

  /**
   * 执行自定义函数
   * @param {*} name 事件名称
   * @param {*} params 传入回调函数中的参数
   */
  trigger (name, params) {
    const callbacks = this.callbacks[name]
    if (callbacks && callbacks instanceof Array) {
      // 执行 所有的 函数
      callbacks.forEach((cb) => {
        cb(params)
      })
    }
  }
  1. 使用框架,修改纹理对象属性 TweenMax.to() 从当前对象中的属性,根据时间修改为传入对象的属性。
    TweenMax.from() 从传入对象的属性,根据时间修改为当前对象中的属性。
    TweenMax.fromTo() 传入两个对象,直接修改前对象中的属性为 from 对象的属性,然后根据时间修改为 to对象的属性
 /**
   * 挂载在时间轴上的动画
   * @param {*} obj 要改变的对象
   * @param {*} duration 动画持续时间
   * @param {*} delay 动画延迟时间
   * @param {*} from 动画的起始参数 和 一些特殊的控制参数(TweenMax 中的参数)
   * @param {*} to 动画的结束参数 和 一些特殊的控制参数(TweenMax 中的参数)
   */
  onTimeline (obj, duration, delay, from, to) {
    let action
    if (from && to) {
      action = TweenMax.fromTo(obj, duration, from, to)
    } else if (to) {
      action = TweenMax.to(obj, duration, to)
    } else if (from) {
      action = TweenMax.from(obj, duration, from)
    }
    // 创建时间轴动画
    const timeline = new TimelineMax({ delay })
    // 加入动画 从第0秒开始
    timeline.add(action, 0)
    // 开始运行
    timeline.play()
    // repeat === -1 表示重复动画 不放入主时间轴中
    if (!(to && to.repeat === -1)) {
      // 将时间轴动画 加入主轴 从第0秒开始
      this.timeline.add(timeline, 0)
    }
  }
  1. 实现根据参数加载不同的动画
/**
   * 动画设置
   * @param {*} obj 精灵对象
   * @param {*} animations 动画配置对象
   */
  setAnimation (obj, animations) {
    if (obj && animations && animations instanceof Array) {
      // frames 精灵的名称 总和
      // frameRate 动画循环速度 --数越大 动画越快
      // delay 动画延迟时间
      // duration 动画持续时间
      // tweenMax 框架参数
      // type 类型
      animations.forEach(({frames , frameRate, delay = 0, duration = 1, tweenMax, type }) => {
        if(type === "frame"){
          // 自定义 事件动画
          this.onFrame(obj, duration, delay, frames)
        }else if(type === "infinite"){
          // 无限 循环动画
          this.onInfinite(obj, frameRate, frames)
        }else if(type === "timelineMax"){
          // 时间轴动画 包括无限动画
          this.onTimeline(obj, duration, delay, tweenMax.from, tweenMax.to)
        }
      })
    }
  }
  1. 修改tesTspirit 使用动画方法
  /**
   * 资源开始加载动画
   */
  tesTspirit = () => {
  
    ...
    
    // 动画
    const animation = [
      {
        frames,
        frameRate: 10,
        delay: 0,
        duration: 1,
        type:"infinite",
      },
      {
        frameRate: 10,
        delay: 0,
        duration: 4,
        tweenMax:{
          from:{ y: -window.innerHeight},
          to:{ y: window.innerHeight * 0.5 }
        },
        type:"timelineMax",
      }
    ]

    // 开始设置时间动画
    this.timeline = new TimelineMax({
      paused: true
    })
    // 开始 过度动画
    this.timeline.play()

    this.setAnimation(sprite,animation)
  };

AlloyTouch

AlloyTouch 是一款触摸框架,能方便的获取触摸滑动距离。

  • 使用这个框架来获取页面滚动的距离。
  // ----------------------- 滑动相关
  /**
   * 初始化 滚动事件
   */
  initTouch = () => {
    this.alloyTouch = new AlloyTouch({
      touch: '.home', // 反馈触摸的dom
      initialValue: 0, // 起始位置
      sensitivity: 0.5, // 不必需,触摸区域的灵敏度,默认值为1,可以为负数
      maxSpeed: 0.5, // 不必需,触摸反馈的最大速度限制
      min: -this.height, // 滚动最小为 总高度 --向上滚动 计算是减
      max: 0,// 最大值 向下滚动值 最大也是 0
      value:0,
      change: this.touchmove
    })
  }

  /**
   * 滑动事件回调
   * @param {*} value 滑动距离
   */
  touchmove = (value) => {
    // 总播放进度 --通过当前滚动距离 计算总高度 在1秒内 的时间
    this.progress = -value / this.height
    this.progress = this.progress < 0 ? 0 : this.progress
    this.progress = this.progress > 1 ? 1 : this.progress
    // 时间轴动画 控制进度 --总时间1秒 
    this.timeline.seek(this.progress)
    // 触发事件 --触发在帧动画中保存的事件 传入计算后 高度 对应的时间
    this.trigger('progress', this.progress)
  }

加入滚动控制动画

修改 loadDonetesTspirit

  // 精灵加载后执行
  loadDone = () => {
    this.tesTspirit()
    this.initTouch()
  };
  
  tesTspirit = () => {
  	...
    // 动画
    const animation = [
      {
        frames,
        frameRate: 10,
        delay: 0,
        duration: 1,
        type:"infinite",
      },
      {
        frameRate: 10,
        delay: 0,
        duration: 0.2,
        tweenMax:{
          from:{ y: -window.innerHeight},
          to:{ y: window.innerHeight * 0.5 }
        },
        type:"timelineMax",
      }
    ]

    // 开始设置时间动画
    this.timeline = new TimelineMax({
      paused: true
    })
    // 开始 过度动画
    // this.timeline.play()
    ...
  }

完善动画

添加背景

// 使用Graphics 画笔 绘制一个 背景
initBg = () => {
  // 绘制背景
  this.bg = new PIXI.Graphics()
  this.bg.beginFill(0xfdfbe2)
  this.bg.drawRect(0, 0, this.app.screen.width, this.app.screen.height)
  this.bg.endFill()
  this.bg.x = 0
  this.bg.y = 0
  this.app.stage.addChild(this.bg)
}

添加开始文本

/**
   * 创建 文本
   */
  initTexts = () => {
    // 一次可创建建多个
    const texts = {
      guide: {
        text: '向上滑动,开始动画',
        position: 'center',
        anchor: 'center',
        options: { // 文字样式
          fontFamily: 'Arial',
          fontSize: window.innerWidth / 375 * 18,
          fill: 0xfb833f,
          align: 'center'
        }
      }
    }
    Object.keys(texts).forEach((key) => {
      // 创建
      const options = texts[key]
      const text = new PIXI.Text(options.text, options.options)
      // 锚点位置
      this.setAnchor(text, options.anchor)
      // 页面位置
      this.setPosition(text, options.position)
      // 设置点击事件
      if (options.link) {
        text.interactive = true
        text.on('tap', () => {
          location.href = options.link
        })
      }
      // 加入场景
      this.app.stage.addChild(text)
      // 保存  pixi 上的对象 后面动画时操作
      this.texts[key] = text
    })
  }

添加初始精灵

/**
   * 加载精灵
   */
  initSprites = () =>{
    const sprites = this.getSprites();
    Object.keys(sprites).forEach((key)=>{
      const obj = sprites[key];
      const sprite = new PIXI.Sprite(PIXI.utils.TextureCache[obj.key]);
      // 设置属性
      this.setSize(sprite, obj.size)
      this.setAnchor(sprite, obj.anchor)
      this.setPosition(sprite, obj.position)
      // 加入场景
      this.app.stage.addChild(sprite);
      // 缓存纹理对象
      this.sprites[key] = sprite
    })
  }

  /**
   * 初始化精灵 数据组装
   */
  getSprites () { 
    const sprites = {
      ani: {
        key: 'ani0',
        size: { mode: 'widthFit', width: 1 },
        position: 'center',
        anchor: 'center'
      },
      girl: {
        key: 'girl0',
        size: { mode: 'widthFit', width: 1 },
        position: 'center',
        anchor: 'center'
      },
      plane: {
        key: 'plane0',
        size: { mode: 'widthFit', width: 0.5 },
        position: {
          x: 0.5, y: 0.4
        },
        anchor: 'center'
      }
    }
    for (let i = 1; i < 8; i += 1) {
      const x = i % 2 === 0 ? 1.1 : -0.1
      sprites[`item${i}`] = {
        key: `item${i}`,
        size: { mode: 'widthFit', width: 0.8 },
        position: { x, y: 1.4 },
        anchor: 'center'
      }
    }
    return sprites
  }

添加动画

这里需要注意,每一种类型的纹理对象都可以添加多个动画,这里动画时长统一为1秒。
通过时长和延迟来实现动画要保持在1秒内,因为计算滑动高度,是按1秒钟计算的。

  /**
   * 添加动画
   */
  initTimeline () {
    this.timeline = new TimelineMax({
      paused: true
    })

    const spritesAnimations = this.getSpritesAnimations();
    // 设置精灵动画
    Object.keys(spritesAnimations).forEach((key) => {
      this.setAnimation(this.sprites[key], spritesAnimations[key])
    })

    // 设置文本动画
    const textsAnimations = {
      guide: [{
        delay: 0,
        duration: 1,
        type:"timelineMax",
        tweenMax:{
          from: { y: window.innerHeight * 0.5 },
          to: { yoyo: true, repeat: -1, ease: 'easeOut', y: window.innerHeight * 0.48 }
        }
      }, {
        delay: 0,
        duration: 0.1,
        type:"timelineMax",
        tweenMax:{
          to: { alpha: 0 }
        }
      }],
    }
    Object.keys(textsAnimations).forEach((key) => {
      this.setAnimation(this.texts[key], textsAnimations[key])
    })

  }

  /**
   * 精灵动画组装 需要考虑清楚 每一个纹理对象 什么时间 开始什么 动画
   */
  getSpritesAnimations = () => {
    const animations = {
      // 女孩
      girl: [{
        delay: 0,
        duration: 1,
        type:"frame",
        frames: this.getFrames('girl', 62)
      }, {
        delay: 0,
        duration: 0.2,
        type:"timelineMax",
        tweenMax:{
          from: { y: -window.innerHeight },
          to: { y: window.innerHeight * 0.5 }
        }
      }, {
        delay: 0.7,
        duration: 0.3,
        type:"timelineMax",
        tweenMax:{
          to: { y: window.innerHeight * 1.2 }
        }
      }],

      // 旋涡
      ani: [{
        delay: 0,
        duration: 0.6,
        type:"timelineMax",
        tweenMax:{
          from: { alpha: 0 },
          to: { alpha: 1 }
        }
      }, {
        delay: 0.1,
        duration: 0.6,
        type:"frame",
        frames: this.getFrames('ani', 50)
      }, {
        delay: 0.7,
        duration: 0.2,
        type:"timelineMax",
        tweenMax:{
          to: { alpha: 0 }
        }
      }, {
        delay: 0.7,
        duration: 0.2,
        type:"frame",
        frames: this.getFrames('ani', 50).reverse()
      }],
      
      // 飞机
      plane: [{
        frames: this.getFrames('plane', 25),
        frameRate: 10,
        type: "infinite",
      }, {
        delay: 0.8,
        duration: 0.2,
        type:"timelineMax",
        tweenMax:{
          from: { width: 0, height: 0, alpha: 0 }
        }
      }]
    }
    // 物品
    for (let i = 1; i < 8; i += 1) {
      // 动画开始时间  
      const delay = 0.21 + (i / 7 * 0.2)
      // 左右分布 
      const x = i % 2 === 0 ? window.innerWidth * 0.65 : window.innerWidth * 0.35
      // 每一个物品都有两个动画
      animations[`item${i}`] = [{
        delay,
        duration: 0.2,
        type:"timelineMax",
        tweenMax:{
          to: { x, y: -window.innerHeight * 0.2, width: 0, height: 0 }
        }
      }, {
        duration: 0.5 + Math.random(),
        type:"timelineMax",
        tweenMax:{
          to: { yoyo: true, repeat: -1, rotation: 0.1 }
        }
      }]
    }
    return animations
  }

  // 动画帧
  getFrames = (key, n, start = 0) => {
    const frames = []
    for (let i = start; i < n + start; i += 1) {
      frames.push(`${key}${i}`)
    }
    return frames
  }

最后需要修改初始化方法,添加需要缓存。

  constructor(options) {
    // 事件监听
    this.callbacks = {
      loadProgress: [],
    };

    this.sprites = []; // 精灵初始纹理对象
    this.texts = []; // pixi 中 文本对象
    this.aniIntervals = [];// 无线循环动画的 清除id

    this.width = options.width || window.innerWidth; // 场景宽
    this.height = options.height || window.innerHeight; // 场景高
    this.container = options.container || document.body; // 场景容器

    // 创建场景
    this.app = new PIXI.Application({
      resolution: 2, // 渲染器的分辨率/设备像素比率  设置为2 肉眼看就不会是模糊的
      transparent: true, // 取消背景颜色
      width: this.width, // 创建后的画布宽
      height: this.height, // 创建后的画布高
    });
    // 加入容器
    this.container.appendChild(this.app.view);

    // 加入精灵
    this.load();
  }
  
  // 精灵加载后执行
  loadDone = () => {
    this.initBg();
    this.initTexts();

    this.initSprites();// 加载精灵

    this.initTimeline();
    this.initTouch();
  };

参考资料

实现通用一镜到底H5 -- 素材来源
Pixi教程
tweenmax文档
AlloyTeam文档

代码地址