淺談滾動驅動動畫實現原理

前言

若是你平時對蘋果的網頁感興趣,你會發現他們不少產品的首發都採用了滾動式驅動技術來控制網站動效,舉兩個例子:css

Apple arcadereact

Apple big surgit

關於這塊技術,今天想在這裏和你們簡單討論一下,其背後的實現原理github

並寫出來一個能夠跑的小demoweb

筆者小貼士

  1. 時間和時刻概念不同,請先行了解時刻的意思
  2. 會用到 react-hook 相關知識
  3. 本文僅在個人博客和掘金+知乎發佈,未通過個人贊成,謝絕一切商用和無腦全文複製粘貼

分析

我認爲一個合格的滾動驅動動畫框架,最最最起碼,須要包含如下幾個要素:macos

  1. 具備動畫的功能,可以解決怎麼讓div或者canvas繪製的圖層動起來的問題
  2. 可以控制動畫的 播放/倒放/暫停
  3. Timeline 時間軸功能,每一個動畫都獨立擁有一個時間軸,只有到了對應的時間,纔會執行動畫
  4. Scroll-driven 滾動驅動,也可以容許用戶的鼠標/觸摸板等輸入設備滾動,來驅動多條時間軸上的動畫的播放/倒放/暫停

那麼咱們接下來就逐條實現canvas

動畫

咱們渲染技術咱們能夠選 css 動畫或canvas ,這裏咱們爲了方便理解仍是以 css 做爲基礎能力(蘋果用的是webgl)微信

爲了實現 上述分析的 1 和 2markdown

咱們須要瞭解一個名詞,Tween-function(補間函數) 注:也有翻譯成Easing-function 緩動函數的app

Tween-function(補間函數)

先聊最核心的,怎麼讓div動起來,也就是補間動畫的實現,咱們先來看個例子:

scroll_animation_display1.png

這個問題其實很簡單,套個公式就能算:

// _c 是最終位移值 200px, b 是初始值 100px, 
// t 是當前時刻,第四秒的話就是4, d 是動畫的總長,5秒的話就是5
function linear(t, b, _c, d) {
  let c = _c - b;
  return c * t / d + b;
}

// 得出第四秒的時候 div 的位移應該是 180px
複製代碼

那麼基於這個栗子,咱們就能夠立刻寫出一個簡單動畫的代碼(能夠直接丟到Console裏面跑跑看):

function linear(t, b, _c, d) {
  let c = _c - b;
  return c * t / d + b;
}

// let box = document.getElementById('#box');

let perTime = 1000 / 60 // 1000 毫秒內渲染 60幀, 那麼每間隔 16.6毫秒渲染一幀
let currentTime = 0 // 初始時刻爲 0秒
let duration = 5000 // 總時長是 5秒

let defaultTarget = currentValue = 100 // 初始值爲100px, 當前值也是100px
let target = 200; // 目標值爲200px

(function _render () {
  currentTime += perTime; // 每渲染一次,時長加一點
  currentValue = linear(currentTime, defaultTarget, target, duration);
  console.log(currentValue, target, currentTime)
  // 若是每次計算的結果丟給一個 叫 box 的div ,那div是否是就動起來了?
  // box.style.left = `${currentValue}px`;

  // 只要沒有達到200px 就一直跑渲染函數
  if (currentValue < target) {
    _render();
  }
})();
複製代碼

咱們把上面提到的 linear 這種函數,稱爲補間函數 Tween function 或者 Easing functions ,經過這些函數,它們的函數圖像縱軸是X取值[-1, 1] 可是多數都是[0, 1], 橫軸是單位時刻t [0, 1],

X 是補間量,好比上面那個栗子也就是div從 100px 到 200px 的位移動畫 那麼當 X 爲 0.8,時就是 100px + 0.8 * (200px - 100px) = 180px, 這就是補間量的含義 時間是單位時間,這裏區間是[0, 1]很好理解 0 就是開始 1 結束

有了補間函數,咱們就能夠作到:

  1. 根據某一個時刻算出當前的補間量,或者根據補間量算出當前處於哪一時刻。
  2. 根據提供的時刻點,可以計算出某一時刻的值,好比說輸入第四秒,可以獲得div運動到了180px, 這樣咱們就可以隨便的控制div播放/倒放/從某一個時間點開始播放
  3. 經過控制速率,來控制總體的動畫效果,好比說 咱們看 easeInOutQuad, 這個補間函數它的斜率就是先緩後陡再緩, 對應過來動畫的效果就是,開頭慢慢開始,中間播的快,結尾慢慢結束

scroll_animation_ease.png

業界有一個專門介紹補間函數的網站 easings.net/ 有興趣的同窗能夠看看

上面的栗子咱們提到的只是控制位移,實際上咱們能夠經過這個補間函數控制不少東西, 好比下面的僞代碼:

// 控制 div的縮放旋轉
easeInOutSine(currentTime, defaultScale, targetScale, duration);
easeInOutSine(currentTime, defaultRotate , targetRotate, duration);

// 控制 div的顏色變化, 顯示隱藏
let R = linear(currentTime, defaultRedChannel, targetRedChannel, duration);
let G = linear(currentTime, defaultGreenChannel, targetGreenChannel, duration);
let B = linear(currentTime, defaultBlueChannel, targetBlueChannel, duration);
let A = linear(currentTime, defaultOpacity, targetOpacity, duration);

// 控制 3d 景深
let A = easeInOutQuad(currentTime, defaultPerspective, targetPerspective, duration);
複製代碼

看到這裏,你們確定理解了:只要是數值上的變化,最後實際上均可以抽象成一個補間函數來實現。

上面提到的補間是最基本的勻速,可是在現實的需求中,咱們還會須要知足各類天花亂墜的需求如: 先慢後快,先快後慢,彈性運動,鐘擺運動,甚至自定義貝塞爾運動曲線,這些難免會須要不少數學計算的知識。

這些知識很難,沒有一點數學和物理功底根本看不懂,可是不要緊,開源社區已經幫咱們作了這個事情,這裏推薦一個開源庫:tween-functions

這個其實是很是很是的基礎依賴庫了,它很是輕、源碼也很簡單就是很純的數學+物理的封裝,並無任何的業務邏輯,這個庫也是一些剛剛接觸動畫方面的同窗來上手學習一個比較好的切入點,因此你能夠基於 tween-functions 來封裝任何你想作的事(不侷限於動畫,任何須要描述數據上變化的事情)。

Timeline(時間軸)

時間軸的概念,不少地方都有,最簡單的就是B站播放器的時間軸,這是最貼近咱們生活的一個例子, 企業也有時間軸,好比 微軟的 Office timeline 工具,就給咱們展示了一個跨國企業年度計劃的時間軸,時間軸便於咱們去整體的協調各個時間線上的事務,固然這裏的須要處理的事務就單指動畫

scroll_aniamtion_timeline_demo.png

js 動畫領域確定也有時間軸,只是咱們平時不會把這個軸畫出來,咱們今天要作的就是實現一個簡單的時間軸

爲了方便理解,咱們把上面的例子拿過來, 這裏咱們改一下需求:

當 0 - 2秒 的時候, 讓 div 位移,從100px 到 200px, 動畫時長是兩秒
當 1 - 2秒 的時候 , 讓 div 透明度變化,從 opacity 從 1 變到 0.5, 動畫時長是1秒
複製代碼

這裏咱們實際上就是設置了兩條獨立的時間軸,他們在 0-2秒 和 1-2秒 間會處理各自的動畫事情,可是總的來看是動畫一體的

咱們用一個 animationData 對象來記錄時間軸的動畫信息,改造一下代碼(下面代碼能夠直接複製丟到 Console 裏面跑):

function linear(t, b, _c, d) {
  var c = _c - b;
  return c * t / d + b;
}

let perTime = 1000 / 60 // 1000 毫秒內渲染 60幀,那麼每間隔 16.6毫秒渲染一幀
let animationData = [
  { key: 'left', defaultValue: 100, currentValue: 100, target: 200, duration: 2000, startTime: 0 },
  { key: 'opacity', defaultValue: 1, currentValue: 1, target: 0.5, duration: 1000, startTime: 1000 },
];

// startTime 開始的時刻,從第x秒開始,單位是毫秒
let currentTime = 0; // 當前時刻
let endTime = 0;

animationData.forEach(item => {
  const { duration, startTime } = item;
  endTime = Math.max(duration + startTime, endTime); // 計算出在什麼時刻須要結束
});

(function _render () {
  currentTime += perTime;
  // 每渲染一次,進度條加一點,
  // 若是是時間軸倒放那麼 currentTime = Math.min(currentTime - perTime, 0) 就行
  
  animationData.forEach(item => {
    const { startTime, duration, defaultValue, target, currentValue } = item;

    // 當時間線到達開始時間後,執行該時間線動畫
    // 當到達時間結束的時候,中止動畫
    if (currentTime >= startTime && currentTime <= startTime + duration) {
     let calcValue = linear(currentTime - startTime, defaultValue, target, duration);
     item.currentValue = calcValue;
    }
  });
  console.log('div 位移:', animationData[0].currentValue);
  console.log('div 透明度變化:', animationData[1].currentValue);
  console.log('當前時刻爲:', currentTime);
  console.log('\n');
  
  // 這裏把計算的結果賦值給 div
  // box.style.left = animationData[0].currentValue
  // box.style.opacity = animationData[1].currentValue
  
  // 只要時間沒有結束,就繼續執行, 若是是倒放,就讓currentTime > 0 便可
  if (currentTime <= endTime) {
    _render();
  }
})();
複製代碼

這是一個正向的時間軸,它只能從開始到結束,不能從結束回放到開始,那麼咱們怎麼控制時間軸隨意的時間旅行呢?

關鍵仍是控制當前時刻 currentTime 和 渲染條件

currentTime += perTime; // 正向播放是加一幀,
currentTime -= Math.min(currentTime - perTime, 0) // 那麼逆向減一幀不就好了?
複製代碼
if (currentTime <= endTime) { // 正向播放是當前時刻小於結束時刻
  _render();
}

if (currentTime > 0) { // 逆向則讓其大於開始時刻
  _render();
}
複製代碼

這樣一個簡陋的時間軸控制div動畫的 播放/倒放 功能就實現了(實戰中還會加億點細節)。

筆者小貼士

接下來我會寫一些僞代碼,可是最後會給一個最終的demo代碼出來 接下來的內容可能會有點抽象難動,但願你們能讀懂~

ScrollDriven(滾動驅動)

所謂滾動驅動,實際上就是經過監聽用戶的 鼠標滾輪/觸摸板 的滾動來控制時間軸的 播放/倒放

對應到時間軸中就是控制 currentTime 的增減

因此首先,咱們須要在頁面上定一個滾動區,當用戶滾動的時候,獲取這個滾動區的scrollTop, 而後映射到時間軸上的時間區間,那麼就實現了簡單的經過滾動驅動來控制時間軸,時間軸控制css動畫的播放/暫停/倒放

這裏咱們用全局的body做爲滾動區間

我這裏先丟一下映射函數,以及scrollTop兼容函數(本身看源碼理解):

// s 是 區間[a1, a2] 的值
// 返回 s map 映射到 [b1, b2] 後的值
function map (s, a1, a2, b1, b2) {
  return ((s - a1) / (a2 - a1)) * (b2 - b1) + b1
}
複製代碼
const isWindow = obj => {
  const toString = Object.prototype.toString.call(obj);
  return toString === '[object global]' || toString === '[object Window]' || toString === '[object DOMWindow]';
};

const scrollTop = (ele, target) => {
  const isWin = isWindow(ele);
  const y =
    window.pageYOffset !== undefined ?
      window.pageYOffset :
      (document.documentElement || document.body.parentNode || document.body).scrollTop;

  if (typeof target === 'number' && !isNaN(target)) {
    if (isWin) {
      document.documentElement.scrollTop = target;
      document.body.scrollTop = target;
    } else {
      ele.scrollTop = target;
    }
  }

  return isWin ? y : ele.scrollTop;
};
複製代碼

而後簡簡單單map一下:

currentTime = Math.max(map(scrollTop(window), 0, document.body.scrollHeight - window.innerHeight, 0, endTime), 0); // 提醒,這段代碼在 _render 函數裏面
複製代碼

這樣咱們就作到了,將滾動的scrollTop映射到了時間軸上

而後還須要再根據用戶滾動的方向,來控制條件渲染,這這麼作呢? 首先咱們再封裝一個工具函數scrollGate,它可以容許咱們判斷用戶的滾動方向:

function scrollGate(callback) {
  let before = 0;

  return function () {
    const current = scrollTop(window);
    const delta = current - before;

    if (delta >= 0) {
      callback && callback('down');
    } else {
      callback && callback('up');
    }

    before = current;
  };
}
複製代碼

而後監聽滾動事件:

function _scrollHandler (dir) {
  if (dir === 'down') {
    reverse = false;
    _render(); // 正向渲染,執行上文的計算+映射邏輯

  } 

  if (dir === 'up') {
    reverse = true;
    _render(); // 反向渲染,執行上文的計算+映射邏輯
  }
}

useLayoutEffect(() => {
  const scrollHandler = scrollGate(_scrollHandler);
  window.addEventListener('scroll', scrollHandler);
  return () => {
    window.removeEventListener('scroll', scrollHandler);
  }
}, []);
複製代碼

搞定,這樣咱們就作到了簡單滾動驅動!

後續

前面咱們瞭解到

  1. 什麼是滾動式驅動動畫,一個簡單的滾動式驅動動畫,由那些功能組成
  2. 滾動式驅動動畫的各個模塊的功能實現
  3. 什麼是補間動畫

如今咱們基於前面所學的,寫一個正兒八經能跑起來的demo把!

Demo 實現

別問我爲啥不建個倉庫,主要仍是 太lan忙 ~

這裏的toFixed扒拉了 @jljsj33 的代碼, 他(們)寫的 ant-motion 很贊!

export function toFixed(num, length) {
  const _rnd = length ? Math.pow(10, length) : 100000;
  const n = num | 0;
  const dec = num - n;
  let fixed = num;
  if (dec) {
    const r = ((dec * _rnd + (num < 0 ? -0.5 : 0.5) | 0) / _rnd);
    const t = r | 0;
    const str = r.toString();
    const decStr = str.split('.')[1] || '';
    fixed = `${num < 0 && !(n + t) ? '-' : ''}${n + t}.${decStr}`;
  }
  return parseFloat(fixed);
}
複製代碼
// Home.scss

body {
  height: 5000px;
  overflow-y: scroll;
}

.container {
  text-align: center;
}

.box {
  width: 200px;
  height: 200px;
  background: pink;
  position: fixed;
  top: 300px;
  left: 100px;
  opacity: .5;
}


// home.jsx
import React, { useLayoutEffect, useRef } from 'react';
import { scrollTop, scrollGate, toFixed, map } from './utils/index';
import './Home.scss';

export default function Home() {
const componentReference = useRef({
  animationData: [
    { key: 'left', defaultValue: 100, currentValue: 100, target: 800, duration: 2000, startTime: 0 },
    { key: 'opacity', defaultValue: .5, currentValue: .5, target: 1, duration: 1000, startTime: 1000 },
    { key: 'rotateX', defaultValue: 0, currentValue: 1, target: 270, duration: 1000, startTime: 2000 },
    { key: 'rotateY', defaultValue: 0, currentValue: 1, target: 180, duration: 1000, startTime: 2000 }
  ],
  status: 'start',
  currentTime: 0,
  endTime: 0,
  perTime: 1000 / 60,
  reverse: false,
  target: null
});

const cRef = componentReference.current;

function _linear (t, b, _c, d) {
  var c = _c - b;
  return c * t / d + b;
}

function _render () {
  if (cRef.status === 'running') {
    cRef.currentTime = Math.max(map(scrollTop(window), 0, document.body.scrollHeight - window.innerHeight, 0, cRef.endTime), 0);
  }

  if (cRef.status === 'start') {
    cRef.currentTime = 0;
  }

  if (cRef.status === 'ended') {
    cRef.currentTime = cRef.endTime;
  }

  cRef.animationData.forEach(item => {
    const { startTime, duration, defaultValue, target } = item;
    if (cRef.currentTime >= startTime && cRef.currentTime <= startTime + duration) {
      let calcValue = _linear(cRef.currentTime - startTime, defaultValue, target, duration);
      item.currentValue = toFixed(calcValue);
    }
    
    if (cRef.currentTime < startTime) {
      item.currentValue = defaultValue;
    }
  });

  cRef.target.style.left = cRef.animationData[0].currentValue + 'px';
  cRef.target.style.opacity = cRef.animationData[1].currentValue;
  cRef.target.style.transform = `rotateX(${cRef.animationData[2].currentValue}deg) rotateY(${cRef.animationData[3].currentValue}deg)`;
}

function _scrollHandler (dir) {
  if (dir === 'down') {
    cRef.reverse = false;
    if (cRef.currentTime < cRef.endTime) {
      cRef.status = 'running';
    } else {
      cRef.status = 'ended'
    }
  } 

  if (dir === 'up') {
    cRef.reverse = true;
    if (cRef.currentTime > 0) {
      cRef.status = 'running';
    } else {
      cRef.status = 'start';
    }
  }

  window.requestAnimationFrame(_render);
}

useLayoutEffect(() => {
  const myDiv = document.querySelector('#box');
  cRef.target = myDiv;
  cRef.animationData.forEach(item => {
    const { duration, startTime } = item;
    cRef.endTime = Math.max(duration + startTime, cRef.endTime);
  });

  const scrollHandler = scrollGate(_scrollHandler)

  window.addEventListener('scroll', scrollHandler);
  return () => {
    window.removeEventListener('scroll', scrollHandler);
  }
}, []);

return (
  <div className="container"> <div id="box" className="box" /> </div>
);
複製代碼

完結,撒花撒花~ 寫的很差,跪求輕噴

廣告

快來加入咱們吧~ 社招的朋友能夠微信私聊我(微信號:L_star07),咱們溝通對接,1對1貼心服務

校招或實習的朋友加我微信(微信號:L_star07)。

相關文章
相關標籤/搜索