FLIP,一種高端優雅但簡單易用的前端動畫思維
有一種能夠快速實現復雜動畫交互的動畫思維 FLIP,為了介紹這個動畫思維,我準備了三個案例。
一、FLIP
FLIP 是四個單詞的首字母,First、Last、Invert、Play,這四個單詞給我們提供了完成動畫的具體思路。
First 表示元素初始時的具體信息,在 html 環境中,這個事情是比較容易就能做到的,我們可以利用 getBoundingClientRect 或者 getComputedStyle 來拿到元素的初始信息。
Last 表示元素結束時的位置信息。此時我們可以直接改變元素的位置,把元素放到新的節點上去。這樣我們就可以直接使用同樣的方式拿到結束時的元素具體信息。
Invert 表示倒置。雖然元素到了結束時的節點位置,但是視覺上我們并沒有看到,此時要設計讓元素動畫從 First 通過動畫的方式變換到 Last,剛好我們又記錄了動畫的開始和結束信息,因此我們可以利用自己熟悉的動畫方式來完成 Invert。
Play 表示動畫開始執行。在代碼上通常 Invert 表示傳參,Play 表示具體的動畫執行。
接下來我們使用三個案例來進一步學習這個動畫思想。
二、案例一:元素 X 軸位置隨機變化
案例效果如圖所示。
案例的 html 結構如下:
<div id="container">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
</div>
<button id="sort">隨機排序</button>
先獲取兩個關鍵 DOM 對象。
const container = document.getElementById('container')
const sortBtn = document.getElementById('sort')
First,記錄元素初始位置信息。此時我們把開始的 X 位置信息保存在子節點對象上,我們也可以單獨另起一個數組來保存所有子節點的具體信息。
// 記錄開始位置信息
function record(container) {
const all = [...container.children]
all.forEach((item, i) => {
const rect = item.getBoundingClientRect()
item.startX = rect.left
})
}
Last,直接改變元素的節點位置。因為改變之后,元素在新的節點上,那么我們這里就可以單獨快捷獲取元素改變之后的位置信息,所以可以封裝一個方法,只改變元素的節點位置信息,而在需要的時候獲取 Last 即可。
當然也可以單獨在這一步把屬性位置信息保存起來。
function change() {
const all = [...container.children]
const len = all.length
all.forEach((item, i) => {
const newIndex = Math.floor(Math.random() * len)
if (newIndex !== i) {
const nextDOM = item.nextElementSibling
container.insertBefore(item, all[newIndex])
container.insertBefore(all[newIndex], nextDOM)
}
})
}
Invert 和 play 在代碼實現上往往會耦合在一起,Invert 表示參數傳入,play 表示動畫執行。因此我們可以最后再定義一個方法 play
表示動畫的執行。
function play(container) {
const all = [...container.children]
const len = all.length
all.forEach((item, i) => {
const rect = item.getBoundingClientRect()
const currentX = rect.left
item.animate([
{ transform: `translateX(${item.startX - currentX}px)` },
{ transform: 'translateX(0px)' }
], {duration: 600})
})
}
這里我使用了一個 DOM 元素自帶的 animate 方法,來完成動畫的實現,該方法目前還是一個實驗性的 api,在 2022 年提出,目前最新版的 chrome 瀏覽器已經支持。
該動畫接口使用起來也比較簡單,跟 keyframes 類似。
animate(keyframes, options)
keyframes 表示關鍵幀數組,options 表示動畫持續時間,或者包含多個時間屬性,用于配置動畫函數或者 iterations、delay 等常見屬性,與 css 的動畫屬性基本保持一致。
你也可以自己封裝一個類似的方法,或者使用成熟的第三方工具庫,能達到類似效果的方式也比較多。
然后在點擊按鈕時,執行即可。
sortBtn.onclick = () => {
record(container)
change()
play(container)
}
三、案例二:多屬性變化
案例效果展示如圖:
元素多屬性動畫并不會增加多少實現復雜度,只是多記錄幾個元素而已。這個案例包含了 x/y/backgroundColor 三個屬性。
First,記錄初始信息。
// 記錄開始位置信息
function record(container) {
const all = [...container.children]
all.forEach((item, i) => {
const rect = item.getBoundingClientRect()
item.startX = rect.left
item.startY = rect.top
item.bgColor = getComputedStyle(item)['backgroundColor']
})
}
Last,直接改變元素節點位置。
因為改變節點位置之后,能夠輕易獲取到元素新的位置的具體屬性,所以這一步可以稱之為 Last。
function change() {
const all = [...container.children]
const len = all.length
all.forEach((item, i) => {
const newIndex = Math.floor(Math.random() * len)
if (newIndex !== i) {
const nextDOM = item.nextElementSibling
container.insertBefore(item, all[newIndex])
container.insertBefore(all[newIndex], nextDOM)
}
})
}
Invert and Play。
function play(container) {
const all = [...container.children]
const len = all.length
all.forEach((item, i) => {
const rect = item.getBoundingClientRect()
const currentX = rect.left
const currentY = rect.top
const bgColor = getComputedStyle(item, false)["backgroundColor"]
item.animate([
{ transform: `translate(${item.startX - currentX}px, ${item.startY - currentY}px)`, backgroundColor: item.bgColor },
{ transform: 'translate(0px, 0px)', backgroundColor: bgColor }
], {duration: 600})
})
}
最后,點擊執行。
sortBtn.onclick = () => {
record(container)
change()
play(container)
}
四、案例三:共享元素動畫
上面那兩個案例,在實踐中基本上沒什么用,主要用于輔助學習。因此大家可能對于高級感和優雅感的體會不是那么深刻。
第三個案例則以在實踐中,在前端很少有項目能夠做到的共享元素動畫,來為大家介紹這種動畫思想方案的厲害之處。
共享元素動畫在前端是一個很少被提及的概念,但是在客戶端的開發中,卻已經運用非常廣泛。
對于前端而言,這代表了未來頁面交互的主要發展方向。例如在小紅書的 web 端已經實現了該功能。
在 FLIP 的指導思想下,該功能實現起來也并不復雜。
First,記錄元素的初始信息。
const all = [...list.children]
// 記錄開始位置信息
all.forEach((item, i) => {
const rect = item.getBoundingClientRect()
item.startX = rect.left
item.startY = rect.top
item.width = rect.width
item.height = rect.height
})
當我們點擊元素時,此時有兩個元素位置信息在發生變化,一個是背景彈窗。他的變化比較簡單,就是透明度的變化,因此我們不用記錄他的信息。另外一個就是共享的元素 item,此時我們記錄了四個信息:startX、startY、width、height。
Last,點擊元素之后,出現彈窗。此時我們把相關的兩個節點插入到正確的位置上即可。
function change(element) {
current = element.cloneNode(true)
modal = document.createElement('div')
modal.id = 'modal'
modal.appendChild(current)
document.body.appendChild(modal)
}
Invert and Play. 也是比較簡單,就是獲取新節點的位置,然后設置動畫即可。
function play(preItem) {
modal.animate([
{backgroundColor: `rgba(0, 0, 0, 0)`},
{backgroundColor: `rgba(0, 0, 0, ${0.3})`}
], {duration: 600})
const rect = current.getBoundingClientRect()
const currentX = rect.left
const currentY = rect.top
const width = rect.width
const height = rect.height
const x = preItem.startX - currentX - (width - preItem.width) / 2
const y = preItem.startY - currentY - (height - preItem.height) / 2
console.log(x, y)
current.animate([
{
transform: `translate(${x}px, ${y}px)`,
width: `${preItem.width}px`,
height: `${preItem.height}px`
},
{
transform: 'translate(0px, 0px)',
height: `${height}px`,
width: `${width}px`
}
], {duration: 600})
}
最后給每個元素添加點擊事件。
all.forEach((item, i) => {
item.onclick = (event) => {
change(event.target)
play(event.target)
}
})
彈窗上也需要新增一個點擊事件,用于執行彈窗消失的動畫。
modal.onclick = () => {
const ani = modal.animate([
{backgroundColor: `rgba(0, 0, 0, ${0.3})`},
{backgroundColor: `rgba(0, 0, 0, 0)`}
], {duration: 600})
const rect = current.getBoundingClientRect()
const currentX = rect.left
const currentY = rect.top
const width = rect.width
const height = rect.height
const x = element.startX - currentX - (width - element.width) / 2
const y = element.startY - currentY - 100
current.animate([
{
transform: 'translate(0px, 0px)',
height: `${height}px`,
width: `${width}px`
},
{
transform: `translate(${x}px, ${y}px)`,
width: `${element.width}px`,
height: `${element.height}px`
},
], {duration: 600})
console.log(x, y)
ani.onfinish = () => {
modal.remove()
}
}
并在運動結束之后,刪除彈窗節點。
ani.onfinish = () => {
modal.remove()
}
一個共享元素動畫,就這么簡單的實現了。
五、共享元素動畫擴展思考
如果我們要結合路由切換轉場來實現共享元素動畫,其實實現原理也是一樣的,非常的簡單,我們只需要在路由切換時,把共享元素的初始位置信息記錄下來并作為參數傳遞給下一個頁面即可。
也就是說,我們只需要把這里的兩個點擊事件,結合路由事件和參數傳遞,就能做到跟小紅書一樣的共享元素路由轉場效果。
不過至于如何封裝讓代碼更加簡潔,本文就不再擴展啦,交給大家自己思考。