實現(xiàn)抖音兩個旋轉(zhuǎn)小球的loading技巧實例
探索小圓球加載效果實現(xiàn)原理
抖音的小圓球加載效果相信大家都見識過,也對其中的實現(xiàn)原理應(yīng)該有一定的好奇心吧,下面就讓我?guī)Т蠹襾硖剿饕幌滦A球加載效果的實現(xiàn)原理吧。
要實現(xiàn)兩個小圓球,我們可以思考兩種方案的實現(xiàn),第一種就是css方案,畫兩個小圓球,然后使用css動畫來實現(xiàn),而第二種則是使用canvas方案。我們首先來嘗試第一種方案,首先肯定是要畫出兩個小圓球,這不就是相當(dāng)于畫兩個圓嘛,所以使用寬高加圓角屬性即可實現(xiàn)。
html代碼如下
<div class="rotate-ball"> <div class="small-ball small-left-ball"></div> <div class="small-ball small-right-ball"></div> </div>
首先是一個旋轉(zhuǎn)的容器元素,接著就是左右兩個小圓球,我的思路也很簡單,既然兩個小球是互相旋轉(zhuǎn)的,那也就是說我給它們的父元素旋轉(zhuǎn)不就達(dá)到了兩個小球互相旋轉(zhuǎn)的效果嗎?
樣式代碼
.small-ball { width: 11px; height: 11px; border-radius: 50%; } .small-ball.small-left-ball { background-color: #e94359; } .small-ball.small-right-ball { background-color: #74f0ed; } .rotate-ball { width: 22px; animation: rotate 5s ease-in infinite forwards; transition: 2s; display: flex; align-items: center; justify-content: space-between; } @keyframes rotate { 0% { transform: rotateY(0deg); } 100% { transform: rotateY(360deg); } }
樣式代碼很簡單,就是設(shè)置兩個小圓球的寬高和圓角,然后分別設(shè)置不同的背景色,然后給父元素添加旋轉(zhuǎn)動畫,這看起來似乎很容易就實現(xiàn)了
嗯大功告成,等等,這個效果差的太遠(yuǎn)了吧,沒那么簡單,好吧很顯然這個方案不太合適,讓我們換一種方式來實現(xiàn),也就是第二種方案canvas方案。
使用canvas方案實現(xiàn)我們主要分為兩個步驟,第一步即使用canvas畫出兩個小圓球,第二步則是讓兩個小圓球進(jìn)行翻轉(zhuǎn),也就是添加翻轉(zhuǎn)動畫。
首先第一步當(dāng)然是畫小圓球,每個小圓球我們都可以看作是一個類,我們把它叫做ball,好的,接下來我們來看代碼如下:
class Ball { // 這里寫核心代碼 }
既然小圓球是一個類,那么我們小圓球就會有屬性,思考一下,我們會有哪些屬性呢?總結(jié)如下:
- 圓心X坐標(biāo)
- 圓心Y坐標(biāo)
- 半徑
- 開始角度
- 結(jié)束角度
- 順時針,逆時針方向指定
- 是否描邊
- 是否填充
- 縮放X比例
- 縮放Y比例
首先小圓球有一個圓心坐標(biāo),既x和y坐標(biāo),其次還有半徑,然后旋轉(zhuǎn)的角度會有開始和結(jié)束,并且還會有旋轉(zhuǎn)的方向,然后就是畫小圓球是否有描邊,是否能夠填充,最后就是縮放比例(主要用于小球運(yùn)動時,我們根據(jù)實際效果可以看到小球旋轉(zhuǎn)的時候明顯有縮放效果,所以這里需要一個縮放比例的屬性)。
分析了屬性之后,很顯然我們第一步要做的就是初始化這些屬性,代碼如下:
class Ball { x: number; y: number; r: number; startAngle: number; endAngle: number; anticlockwise: boolean; stroke: boolean; fill: boolean; scaleX: number; scaleY: number; lineWidth: number; fillStyle: string | CanvasGradient | CanvasPattern; strokeStyle: string | CanvasGradient | CanvasPattern; constructor(o: AnyObj) { this.x = 0; // 圓心X坐標(biāo) this.y = 0; // 圓心Y坐標(biāo) this.r = 0; // 半徑 this.startAngle = 0; // 開始角度 this.endAngle = 0; // 結(jié)束角度 this.anticlockwise = false; // 順時針,逆時針方向指定 this.stroke = false; // 是否描邊 this.fill = false; // 是否填充 this.scaleX = 1; // 縮放X比例 this.scaleY = 1; // 縮放Y比例 this.init(o); } init(o: AnyObj): void { Object.keys(o).forEach(k => (this[k] = o[k])); } }
初始化屬性之后我們要干什么?那當(dāng)然是渲染小圓球啦,寫一個render方法就可以了。
class Ball { // 以上代碼以省略 render(){ // 渲染小圓球代碼 } }
canvas畫圓的步驟
如何畫小圓球?也就是canvas畫圓的步驟,最核心的就是canvas的arc方法,總的說來,我們主要分為設(shè)置原點(diǎn)坐標(biāo),設(shè)置縮放,調(diào)用arc方法畫圓,設(shè)置線寬,填充顏色,以及描邊這幾步,然后我們返回小圓球?qū)嵗蚨a如下:
class Ball { // 以上代碼已省略 render(ctx: CanvasRenderingContext2D | null): Ball | void { if (!ctx) { return; } ctx.save(); ctx.beginPath(); ctx.translate(this.x, this.y); // 設(shè)置原點(diǎn)的位置 ctx.scale(this.scaleX, this.scaleY); // 設(shè)定縮放 ctx.arc(0, 0, this.r, this.startAngle, this.endAngle); // 畫圓 if (this.lineWidth) { // 線寬 ctx.lineWidth = this.lineWidth; } if (this.fill) { // 是否填充 this.fillStyle ? (ctx.fillStyle = this.fillStyle) : null; ctx.fill(); } if (this.stroke) { // 是否描邊 this.strokeStyle ? (ctx.strokeStyle = this.strokeStyle) : null; ctx.stroke(); } ctx.restore(); return this; } }
如此一來,我們畫小圓球這一步就算是完成了,接下來我們要做的就是讓小圓球動起來。要讓小圓球動起來,那么就需要用到定時器,然而我這里并沒有使用setInterval函數(shù),這是為什么呢?
如果我們把定時器每次執(zhí)行一次看作是一個任務(wù),那么setInterval就相當(dāng)于是按照一定的時間間隔來執(zhí)行任務(wù),而一個任務(wù)的開始時間和結(jié)束時間我們是無法保證它們之間的時間間隔的,也就是說有時候我們的循環(huán)定時任務(wù)會被跳過,而setTimeout因為是在條件滿足的時候會自動停止,所以我們可以使用setTimeout來避免這個問題,因此,接下來我要說的就是我們會使用setTimeout來模擬實現(xiàn)setInterval函數(shù)。
那么如何實現(xiàn)呢?
我們把每次setTimeout執(zhí)行也看作是一個任務(wù),然后我們通過一個對象來存儲每一次執(zhí)行的任務(wù),這樣我們每次執(zhí)行的任務(wù)都可以通過在對象當(dāng)中找到,因此,我們要清除任務(wù)同樣也可以從對象當(dāng)中取出任務(wù)來清除。
也就是說,我們存儲每一個setTimeout任務(wù)的延遲id,這個函數(shù)返回一個數(shù)值型的延遲id,我們把這個值記錄到對象當(dāng)中,方便后面從對象當(dāng)中取出然后清除任務(wù)。
實現(xiàn)這個函數(shù)主要分成兩部分,第一部分當(dāng)然還是模擬實現(xiàn)執(zhí)行定時任務(wù),第二部分就是模擬實現(xiàn)一個清除定時任務(wù)的函數(shù),即clearInterval函數(shù)的模擬實現(xiàn)。
模擬實現(xiàn)定時任務(wù)我們可以使用遞歸來實現(xiàn),這個應(yīng)該還是比較好理解,這里我們還有一點(diǎn),那就是存儲在對象當(dāng)中的延遲id,我們需要一個屬性名,對象不就是一種含有屬性名屬性值的鍵值對數(shù)據(jù)類型嗎?在這里屬姓名我們可以使用Symbol類型,為什么使用這個數(shù)據(jù)類型?因為這個數(shù)據(jù)類型確保了唯一性。
最后還有一點(diǎn),那就是如果要寫ts類型,那么定時器任務(wù)的回調(diào)函數(shù)應(yīng)該是任意類型的函數(shù),因此這里需要編寫類型代碼。如下:
type AnyFunction = (...args: any) => any;
模擬函數(shù)代碼
通過以上分析,我們的模擬函數(shù)代碼就很好理解了,代碼如下:
export const defineSetInterval = (): { setInterval: (fn: AnyFunction, time: number) => symbol; clearInterval: (k: symbol) => void; } => { const timeWorker = {}; const key = Symbol(); const defineInterval = (handler: AnyFunction, interval: number) => { const executor = (fn: AnyFunction, time: number) => { timeWorker[key] = setTimeout(() => { fn(); executor(fn, time); }, time); }; executor(handler, interval); return key; }; const defineClearInterval = (k: symbol):void => { if (k in timeWorker) { clearTimeout(timeWorker[k] as number); delete timeWorker[k]; } }; return { setInterval: defineInterval, clearInterval: defineClearInterval }; };
以下是該函數(shù)的使用示例代碼:
const { setInterval, clearInterval } = defineSetInterval(); const timeId = setInterval(() => alert('hello,world!'), 1000); // 取消定時器// clearInterval(timeId);
讓我們繼續(xù)下一步,下一步我們當(dāng)然是創(chuàng)建這兩個小圓球,然后暴露出一個start方法和一個clear方法,顧名思義,就是在這個函數(shù)當(dāng)中我們創(chuàng)建小圓球,然后默認(rèn)不執(zhí)行動畫,將執(zhí)行動畫的邏輯包裝在start方法中,而之所以留下一個clear方法,那就是如果需要實現(xiàn)暫停效果,也就是清除定時器了,那么我們就需要調(diào)用clear方法清除定時器,暫停執(zhí)行動畫,如果需要重新執(zhí)行動畫,那么我們也就重新調(diào)用start方法即可。因此這個函數(shù)的結(jié)構(gòu)我們可以定義如下:
export interface CreateBallReturnType { clear: () => void; start: (time?: number) => void; } export interface AnyObj { [prop: string]: unknown } export const createBall = ( el: HTMLElement | string, leftBallConfig?: AnyObj, rightBallConfig?: AnyObj ): CreateBallReturnType => { // 這里寫核心代碼 }
這個函數(shù)有3個參數(shù),第一個參數(shù)是一個dom元素,也就是說,我們需要將兩個小圓球渲染到canvas元素上,再將這個canvas元素添加到一個容器元素當(dāng)中,這個el參數(shù)就是代表傳入一個容器元素中,如果不傳,那么我們默認(rèn)就添加到body元素中,第二個和第三個參數(shù)分別是兩個小圓球的配置屬性對象,其實這里我們直接采用默認(rèn)的就好,不需要傳入這兩個參數(shù),因此這兩個參數(shù)是可選的,雖然這里定義的是任意對象,但實際上根據(jù)前面小圓球類含有哪些屬性的分析結(jié)果來看,這兩個參數(shù)很明顯傳入的就是初始化的那些屬性,如果有特別需求,可以傳入這些屬性進(jìn)行更改。
計算縮放比例的公式
在實現(xiàn)該函數(shù)的核心之前,我們這里會涉及到一個計算縮放比例的公式,代碼如下:
export const computedScale = (val: number, dir: number, dis: number): number => (val * 1000 + dir * (dis * 1000)) / 1000;
這里就不多分析這個公式的原理了,只要記住它是一個公式就可以了。
接下來我們看該函數(shù)的核心實現(xiàn),我們主要也還是分成2個部分,第一個部分渲染兩個小圓球并添加到容器元素中,定義動畫函數(shù),并封裝到start函數(shù)當(dāng)中,然后暴露出start和clear函數(shù)。這里需要注意的一點(diǎn),那就是小圓球的寬高以及canvas元素的寬高不會太大,然后小圓球移動有個邊界,因此x坐標(biāo)和y坐標(biāo)有個最小值和最大值,我們定義成一個一維數(shù)組即可。
接下來,我們按照相應(yīng)的分析步驟去實現(xiàn)每一步驟的代碼就可以了,每一步在代碼當(dāng)中也有所注明,所以我們只需要看完整代碼即可。
export const createBall = ( el: HTMLElement | string, leftBallConfig?: AnyObj, rightBallConfig?: AnyObj ): CreateBallReturnType => { const container = (typeof el === 'string' ? document.querySelector(el) : el) || document.body; const canvas = document.createElement('canvas'); canvas.width = 34; canvas.height = 20; container.appendChild(canvas); const w = canvas.width; const h = canvas.height; const ctx = canvas.getContext('2d'); const xArr = [10, 22]; const yArr = [10, 10]; const leftBall = new Ball({ x: xArr[0], y: yArr[0], r: 6, startAngle: 0, endAngle: 2 * Math.PI, fill: true, fillStyle: '#E94359', lineWidth: 1.2, ...leftBallConfig }).render(ctx); const rightBall = new Ball({ x: xArr[1], y: yArr[1], r: 6, startAngle: 0, endAngle: 2 * Math.PI, fill: true, fillStyle: '#74F0ED', lineWidth: 1.2, ...rightBallConfig }).render(ctx); const a = 1.04; // 加速度 let dir = 1; // 方向 let dis = 1; // X軸移動初始值 const move = (): void => { if (!ctx || !leftBall || !rightBall) { return; } // 清理畫布 ctx.clearRect(0, 0, w, h); // 通過加速度計算移動值 dis *= a; // 更改x軸位置 leftBall.x += dir * dis; rightBall.x -= dir * dis; // 計算縮放比例 leftBall.scaleX = computedScale(-dir, 0.005, leftBall.scaleX); leftBall.scaleY = computedScale(-dir, 0.005, leftBall.scaleY); rightBall.scaleX = computedScale(dir, 0.005, rightBall.scaleX); rightBall.scaleY = computedScale(dir, 0.005, rightBall.scaleY); // 到達(dá)指定位置后 if (leftBall.x >= 22 || rightBall.x >= 22 || leftBall.x <= 10 || rightBall.x <= 10) { // 設(shè)定縮放比例 leftBall.scaleX = rightBall.scaleX; leftBall.scaleY = rightBall.scaleY; rightBall.scaleX = leftBall.scaleX; rightBall.scaleY = leftBall.scaleY; // 還原X軸移動初始值 dis = 1; // 變更移動方向 dir = -dir; } // 繪制 if (dir > 0) { // 方向不一樣時,小球的繪制順序要交換,移模擬旋轉(zhuǎn) rightBall.render(ctx); leftBall.render(ctx); } else { leftBall.render(ctx); rightBall.render(ctx); } }; let timer: symbol; const { setInterval: setHandler, clearInterval: clearHandler } = defineSetInterval(); const start = (time = 50): void => { timer = setHandler(move, time); }; return { start, clear: (): void => { if (timer) { clearHandler(timer); } } }; };
可以看到我們先是創(chuàng)建canvas元素,設(shè)置寬高,然后創(chuàng)建兩個小圓球添加到canvas元素當(dāng)中,再然后我們定義一個move方法,也就是小圓球的翻轉(zhuǎn)動畫的實現(xiàn),難點(diǎn)可能就主要是翻轉(zhuǎn)動畫的實現(xiàn)原理。
翻轉(zhuǎn)動畫的實現(xiàn)原理
如此一來,我們?nèi)绻菍慾s/ts代碼,使用起來就很簡單,直接調(diào)用方法即可,如:
createBall(); // 如果需要指定特定的容器元素,那么傳入一個dom元素,例如 document.querySelector('#app') // 又或者傳入一個字符串也可以,既'#app' // 也就是createBall('#app');
接下來我們再封裝一下,將這個函數(shù)用到react框架中,做成一個組件,很簡單,我們利用ref對象來存儲dom元素,然后使用useEffect函數(shù)監(jiān)聽這個dom元素是否存在,然后存在就調(diào)用該方法。代碼如下:
import React, { createRef, CSSProperties, ReactElement, useEffect } from 'react'; import { createBall } from './ball'; import '../style/loading.scss'; export interface LoadingProps extends AnyObj { style?: CSSProperties; } const Loading = (props: LoadingProps = {}): ReactElement | null => { const loadingRef = createRef<HTMLDivElement>(); useEffect(() => { // 這里多一個children判斷是因為如果該元素已經(jīng)被渲染過,我們就不需要添加到容器元素中了 if (loadingRef.current && !loadingRef.current.children.length) { const ball = createBall(loadingRef.current); ball.start(); } }, [loadingRef]); return <div ref={loadingRef} className="loading" {...props} />; }; export default Loading;
這里涉及到了一點(diǎn)樣式,樣式隨便自己寫:
@import './extend.scss'; .loading { position: absolute; left: 0; top: 0; @extend .perfect, .flex-center; }
extend.scss代碼如下:
.flex-content-center { display: flex; justify-content: center; } .flex-align-center { display: flex; align-items: center; } .flex-center { @extend .flex-content-center, .flex-align-center; } .perfect { width: percentage(1); height: percentage(1); }
如此一來,我們的抖音旋轉(zhuǎn)小圓球效果就實現(xiàn)了,更多關(guān)于抖音兩個旋轉(zhuǎn)小球loading的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
JavaScript實現(xiàn)的冒泡排序法及統(tǒng)計相鄰數(shù)交換次數(shù)示例
這篇文章主要介紹了JavaScript實現(xiàn)的冒泡排序法及統(tǒng)計相鄰數(shù)交換次數(shù),結(jié)合實例形式分析了javascript冒泡排序的實現(xiàn)技巧及針對交換次數(shù)的統(tǒng)計方法,便于更直觀的了解冒泡排序算法,需要的朋友可以參考下2017-04-04JS中BOM相關(guān)知識點(diǎn)總結(jié)(必看篇)
下面小編就為大家?guī)硪黄狫S中BOM相關(guān)知識點(diǎn)總結(jié)(必看篇)。小編覺得挺不錯的,希望對大家有所幫助。一起跟隨小編過來看看吧,祝大家游戲愉快哦2016-11-11關(guān)閉瀏覽器輸入框自動補(bǔ)齊 兼容IE,FF,Chrome等主流瀏覽器
這篇文章主要介紹了關(guān)閉瀏覽器輸入框自動補(bǔ)齊 兼容IE,FF,Chrome等主流瀏覽器,需要的朋友可以參考下。希望對大家有所幫助2014-02-02JavaScript控制輸入框中只能輸入中文、數(shù)字和英文的方法【基于正則實現(xiàn)】
這篇文章主要介紹了JavaScript控制輸入框中只能輸入中文、數(shù)字和英文的方法,基于正則驗證實現(xiàn)字符輸入限制功能,具有一定參考借鑒價值,需要的朋友可以參考下2017-03-03JavaScript指定字段排序方法sortFun函數(shù)
這篇文章主要介紹了JavaScript指定字段排序方法sortFun函數(shù),數(shù)組的排序方法是sort,但是它并不支持根據(jù)指定的字段進(jìn)行排序,而sortFun可以根據(jù)指定的字段進(jìn)行排序,需要的朋友可以參考下2023-05-05