一镜到底的动画总结

1,822 阅读7分钟

pixi.js的使用经验

背景:为了实现2345天气王年终运营活动-疫情活动的动画效果,(欢迎扫码预览)

线上地址

功能要点:

开发之前主要调研是否能够满足这些主要功能

1)页面动画元素可以根据用户滚动页面的单位时间内滚动的距离,控制动画效果(位移、速度即持续时间)

2)页面元素(精灵元素)支持点击事件

3)页面资源图片的加载进度(loading动画)

开发过程中踩坑记录:

1)将图片通过pixi.js转化为canvas之后,出现元素模糊的情况,绘画出的元素有锯齿;

解决方案:先绘制canvas的宽高为当前视口的2倍,然后通过transform进行缩小,平移到正确的位置;代码如下:

   	let app  = new PIXI.Application({
		view:document.getElementById('canvas'),
		width:innerWidth * 2, // 放大两倍
		height:innerHeight * 2, // 放大两倍
		antialias: true, // 是否平滑抗锯齿,取消字体平滑,抗混叠。
		forceCanvas: false,
		resolution: 1,
		clearBeforeRender: true
	}); 
canvas {
  transform: scale(0.5, 0.5) translate(-50%, -50%);
}

2)动态元素如:文案数据,怎么能跟着用户滚动的时候一起滚动;

解决方案:获取当前页面的滚动距离,对dom元素进行平移;

3)背景音乐的自动播放(目前手机在无用户行为的情况下几乎都不支持背景音乐的自动播放)

解决方案: (微信环境下)监听WeixinJSBridgeReady事件;

        // 微信环境下的自动播放音乐
        if(isWXBrowser()) {
            document.addEventListener('DOMContentLoaded', function () {
                function audioAutoPlay() {
                    let audio = document.getElementById('bg-music')
                    audio.play()
                    document.addEventListener("WeixinJSBridgeReady", function () {
                        audio.play()
                    }, false)
                }
                audioAutoPlay()
            }); 
        } 

别的app,webview环境下依旧没有办法支持自动播放音乐,可以考虑下用户体验。建议把这部分功能去除掉; 或者改为当用户触摸屏幕的时候,自动播放;代码如下:

        let audio = document.getElementById('bg-music')
        function musicInBrowserHandler() {
             audio.play()
             document.body.removeEventListener('touchstart', musicInBrowserHandler)
        }
        document.body.addEventListener('touchstart', musicInBrowserHandler)  

4)如何与UI确定动画元素的运动参数,尽可能还原实现动画效果;

解决方案: 页面中的所有具有运动效果的元素,首先:都遵循一个原则;具有初始位置,以及动画结束位置(x,y);

动画开始的时间设置为delay:滚动页面的距离 / 页面的总高度;

动画持续的时间设置为duration: 动画结束时候元素的位置 - 动画开始时候元素的位置 / totalHeight; 以上位置信息需要和UI动画设计师确认;

技术选型:结合调研方案,确定以PixiJS+TweenMax+TimelineMax+AlloyTouch就能实现一镜到底的套路,每个库发挥它自己应有的作用,相互配合;

参考资源如下:

参考博文:墨霁青玉:网易四字魔咒

github链接: pixi中文翻译

官网链接: TweenMax

流程简介

(1) 创建pixi应用,预加载图片资源(loader.add)

// 创建应用
 let app = new PIXI.Application({
      width:1334, 
      height:750
  });
// 
 // 创建资源加载器loader ,进行资源预加载
  const loader = new PIXI.loaders.Loader();
 
  // 链式调用添加图片资源
  loader.add('bg1', './imgs/bg1.png')
       .add('bg_desk', './imgs/bg_desk.png')
       .add('bg_person','./imgs/bg1_person.png')
 
  // 监听加载进度,显示加载进度
  loader.on("progress", function(target, resource) {  //加载进度
    document.getElementById('percent').innerText = parseInt(target.progress)+"%";
  });
 
  // 监听加载完毕
  loader.once('complete', function(target, resource) {  //加载完成
    document.getElementById('loading').style.display = 'none'// 隐藏进度条
    document.body.appendChild(app.view);   // 将pixi应用插入真实DOM中
    initScenes(); // 初始化场景
    initSprites();  // 初始化精灵
    initAnimation(); // 初始化动画
    initTouch(true, 'y');
  });
   
  // 开始加载资源
  loader.load();

(2) 初始化场景:通过new PIXI.Container()函数创建每个场景,将他们加入PIXI舞台中, 每个场景包含宽高,位置等信息;

根据设计图,得出每个场景的长宽与位置。新建场景容器,等建完场景,才可以进行下一步的操作,将精灵放进场景里。

建场景的操作比较简单,定义每个场景的数据,新建Container对象并加入舞台中。这里的实现主要有三个东西:

①场景数据 scenesOptions:定义每个场景的数据

②对象集合 scenes:PIXI.Container对象,在初始化精灵的时候需要用到,所以需要将每个场景对象进行存储

③循环函数 initScenes:初始化场景并加入舞台里

  const scenesOptions = [ // 场景数据:定义每个场景的宽高,x/y距离
    {
      name:"scene1",
      x:0,y:0,
      width:2933,height:750
    },
    {
      name:"scene2",
      x:2933,y:0,
      width:1617,height:750
    },
    ....
  ];
   
  const scenes = {};  // 场景集合 - pixi对象
   
  function initScenes(){ // 循环场景数组初始化场景
    for (let i = scenesOptions.length-1; i >= 0 ; i--) {
      scenes[scenesOptions[i].name] = new PIXI.Container({
        width:scenesOptions[i].width,
        height:scenesOptions[i].height
      });
      scenes[scenesOptions[i].name].x = scenesOptions[i].x;
      app.stage.addChild(scenes[scenesOptions[i].name]);
    }
  }

(3) 初始化精灵:定义每个精灵的位置等属性数据,将其加到对应的场景中

这一步的操作与上一步操作一致:数据集,对象集,循环函数。

只是在这里我把精灵的初始化拆分成两个函数,一个是循环精灵数组,一个是循环每一个精灵的属性。再加了一个特殊属性的函数。根据需要,可以对某些精灵进行特殊的操作,都统一放在initSpecialProp函数里。

  const spritesOptions = [ // 精灵数据:定义每个精灵的坐标
    { // 第一个场景的精灵
      bg1:{
        position:{x:0,y:0}
      },
      bg_person:{
        position:{x:0,y:19},
        anchor:{x:0.5,y:0.5}
      },
      ....
    },
    { // 第二个场景的精灵
      bg_desk:{
        position:{x:2213,y:38}
      },
      ....
    }
  ];
  const sprites = {}; // 精灵集合 - pixi对象
   
  function initSprites(){  // new出所有精灵对象,并交给函数initSprite分别赋值
    for (let i = 0; i < spritesOptions.length; i++) {
      let obj = spritesOptions[i];
      for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
          sprites[key] = PIXI.Sprite.fromImage(key);
          initSprite(sprites[key],obj[key],i+1);
        }
      }
    }
    initSpecialProp();
  }
  function initSprite(sprite,prop,i){  // 初始化单个精灵的属性并加入对应的场景中
    for (let key in prop) {
      if (prop.hasOwnProperty(key)) {
        sprite[key] = prop[key];
      }
    }
    scenes['scene'+i].addChild(sprite);
  }
  function initSpecialProp(){  // 若有特殊精灵要处理特殊属性,可在此函数内处理
    // sprites.mother_left.pivot.set(0,51);
    // sprites.mother_right.pivot.set(95,50)
  }

(4) 进行到第三步,所有的精灵都绘制出来了,但是此时屏幕还不可以拖动,这里需要用到一个滑动的库,我用的是AlloyTouch。通过AlloyTouch可以检测用户滑动的距离,在对应的滑动的change回调函数里可以改变舞台的位置app.stage.position.x来实现舞台的拖动效果。这样用户就可以通过滑动查看了。

new AlloyTouch({ ... }):

①touch定义触摸的DOM对象,在这里我们就直接是body了;

②vertical定义触摸的方向(横向滑动,还是竖向滑动。这里涉及设备是横屏还是竖屏,所以通过值vertical传进来。横屏则为false,竖屏则为true);

③可滚动的最大距离max跟最小距离min。因为我们都是往左滑,往上滑,所以为负距离,所以最大值max为0。最小值为舞台的整体宽度再减去一整屏的宽度,然后再取负值;

④关键点在于change函数,他可以返回实时滚动的距离。通过计算可以得到当前滚动的距离占全部距离的百分比,这个百分比就是我们当前的进度。拿到这个百分比。(默认总进度为1)就可以通过timeline.seek函数就可以随时改变播放的进度。这就是为什么往回滑动动画会往回撤的原因。想一下我们平时看视频,我们滚动进度条的行为就是用户滑动页面的行为,所以这就是实现的关键点。

  let alloyTouch;
   
  function initTouch(vertical, val) {
    let scrollDis = app.stage.width-max;
    alloyTouch = new AlloyTouch({
      touch:"body", //反馈触摸的dom
      vertical: vertical, //不必需,默认是true代表监听竖直方向touch
      min: -app.stage.width + max, //不必需,运动属性的最小值
      maxSpeed: 1,
      max: 0, //不必需,滚动属性的最大值
      bindSelf: false,
      initialValue: 0,
      change:function(value){  
        app.stage.position[val] = value;
        let progress = -value/scrollDis;
        progress = progress < 0 ? 0 : progress;
        progress = progress > 1 ? 1 : progress;
        timeline.seek(progress);      
      }
   })
  }

(5) 进行到第四步,一切的效果都是静态的,我们的元素还没有动起来。要实现随着用户的滑动播放对应的动画效果,这里需要用到一个库TimelineMax。这是管理动画播放进度条的库。直接new 一个主时间轴timeline。

  const timeline = new TimelineMax({  // 整个舞台的时间轴
    paused: true
  });

第四步骤和第五步骤合并起来的代码

  const w = document.body.clientWidth,
      h = document.body.clientHeight;
   
  const min = (w<h)?w:h;
  const max = (w>h)?w:h;
   
   
  const timeline = new TimelineMax({  // 整个舞台的时间轴
    paused: true
  });
   
  let alloyTouch;
   
  function initTouch(vertical, val) {
    let scrollDis = app.stage.width - max;
    alloyTouch = new AlloyTouch({
      touch:"body", //反馈触摸的dom
      vertical: vertical, //不必需,默认是true代表监听竖直方向touch
      min: -app.stage.width + max, //不必需,运动属性的最小值
      maxSpeed: 1,
      max: 0, //不必需,滚动属性的最大值
      bindSelf: false,
      initialValue: 0,
      change:function(value){  
        app.stage.position[val] = value;
        let progress = -value/scrollDis;
        progress = progress < 0 ? 0 : progress;
        progress = progress > 1 ? 1 : progress;
        timeline.seek(progress);      
      }
   })
  }

(6) 给精灵加上动画,这里用到一个库TweenMax,可以用来创建补间动画。我们只需定义精灵的起始状态,最终状态,它能轻松帮助我们进行状态的过渡。用TweenMax创建完动画,将动画加到时间轴timeline对应的位置。delay:0.1表示在动画长度的百分十处开始播放。

TweenMax常用的三个函数如下:

①TweenMax.to(target,duration,statusObj) ——目标target从当前状态到statusObj状态过渡

②TweenMax.from(target,duration,statusObj) ——目标target从statusObj状态到当前状态过渡

③TweenMax.fromTo(target,duration,statusObjFrom,statusObjTo)——目标target从statusObjFrom状态到statusObjTo状态过渡

duration表示过渡时长。如果duration=0.1则表示过渡时长占滚动总长的10%,即占时间轴的10%。

delay跟duration的计算规则:

  1. delay = 开始播放动画时的滚动距离 / 可滚动总长度

delay是动画开始的时间

  1. duration = (结束播放动画时的滚动距离 - 开始播放动画时的滚动距离) / 可滚动总长度

duration是动画持续的时间

 const animationsOptions = {  // 精灵动画集合
    windows:[{
      prop:'scale',  // 这里有个prop,有些人没有注意到这是什么
      delay:0.05,
      duration:0.3,
      to:{x:3,y:3,ease:Power0.easeNone// 在这里注意一下 ease:Power0.easeNone 是缓动函数
    },{
      delay:0.1,
      duration:0.1,
      to:{alpha:0}
    }],
    talk_1:[{
      delay:0.15,
      duration:0.1,
      from:{width:0,height:0,ease:Power0.easeNone}
    }]
  }
  function initAnimation(){
    // delay=0.1 表示滚动到10%开始播放动画
    // duration=0.1 表示运动时间占滚动的百分比
    for (let key in animationsOptions) {
      if (animationsOptions.hasOwnProperty(key)) {
        let obj = animationsOptions[key];
        for (let i = 0; i < obj.length; i++) {
          let act;
          let target;
          if (obj[i].prop) {
            target = sprites[key][obj[i].prop];
          } else {
            target = sprites[key];
          }
          if (obj[i].from & obj[i].to) {
            act = TweenMax.fromTo(target,obj[i].duration,obj[i].from,obj[i].to);
          } else if (obj[i].from) {
            act = TweenMax.from(target,obj[i].duration,obj[i].from);
          } else if (obj[i].to) {
            act = TweenMax.to(target,obj[i].duration,obj[i].to);
          }
          let tm = new TimelineMax({delay:obj[i].delay});
          tm.add(act,0);
          tm.play();
          timeline.add(tm,0);
        }
      }
    }
    // 特殊动画特殊处理
    let act = TweenMax.to(scenes.scene1,0.3,{x:2400});
    let tm = new TimelineMax({delay:0.25});
    tm.add(act,0);
    timeline.add(tm,0);
  }

tip!!!!!(猜猜上面的prop是干什么用的)

注意一下,TweenMax.from(target,duration,statusObj)等方法只识别target的属性。举个例子,精灵sprite的width从0变到100

TweenMax.to(sprite, 0.1, { width: 100}) // 即 sprite.width变为100

那么问题来了,要改变属性的属性呢?例如sprite.scale.x变为2,则target应该变为sprite.scale

TweenMax.to(sprite.scale, 0.1, { x: 2}) // 即 sprite.scale.x变为100

(7) 在第三步的时候,用户滑动的回调函数加上 timeline.seek(progress)就可以实现滑动到某个位置播放对应的动画

这里, 汇总一下每个库的作用

  1. PixiJS: 绘图,其中包括舞台、场景、普通精灵、动画精灵、平铺精灵、定时器等。

  2. TweenMax:制作过渡动画

  3. TimelineMax:管理整个舞台的动画播放进度

  4. AlloyTouch:实现滑动效果,监听用户滑动

PixiJS+TweenMax+TimelineMax+AlloyTouch就能实现一镜到底的套路,每个库发挥它自己应有的作用,相互配合