昨天手寫了拖曳,選項放到圖釘後,只是變淡(used)而已。但如果選項變多、名稱變長,選項列表會變成可以左右滑動;這時為了讓使用者優先看到還沒填寫的選項,希望能「把已放置的選項排到後面」。
如果直接更新資料順序,畫面看起來就是選項瞬間跳位,十分突兀。參考過去專案用的 vue-draggable-plus,拖曳時其他選項會滑開讓位,該套件的底層 SortableJS 就是用了類似 FLIP 的做法。
今天我們先來了解 FLIP 是什麼,之後沿用昨天的範例,分別用手寫 FLIP 與 Vue <TransitionGroup> 兩種方式,補上選項移動動畫。那麼我們開始吧!
排序、篩選、插入這類操作,改的是 DOM 的順序。DOM 一變,元素是直接出現在新位置,中間沒有任何過程。一開始想如何補上動畫時,直覺會是去動 top / left。但這會有兩個問題:
FLIP 是 First、Last、Invert、Play 的縮寫,來自 Paul Lewis 的文章 FLIP Your Animations。做法是:先讓版面直接變成最終狀態,再用 transform 補出中間的過程。
getBoundingClientRect() 記下元素現在的位置。dx、dy,用 transform: translate(dx, dy) 把元素「視覺上」放回舊位置(起點)。transform 並加上動畫,元素就會從舊位置滑向新位置(終點)。以今天的例子來說,就是:
First 拿鐵小站在第 1 格 ─ 記下位置
Last 改順序,它變成第 3 格 ─ 再量一次位置
Invert translate(-220px, 0) ─ 視覺上把它放回第 1 格(使用者還看不到變化)
Play translate → none ─ 觸發動畫,滑到第 3 格
對瀏覽器而言,元素「真正的位置」從一開始就已經在終點了,動畫只是用 transform 做視覺上的補間,不會再改變版面。動畫期間盡量只動 transform、opacity 這類不需要重算版面的屬性,比較容易維持流暢。
<TransitionGroup>Vue 有內建的元件處理列表動畫的 <TransitionGroup>,包含項目的新增、移除與移動。首先需要為列表每個項目設定唯一的 key,再到 <TransitionGroup> 指定要套用的 CSS 動畫。以今天會用到的移動為例,我們可以在元件上指定 class 的前綴,像是 name="option" 就等於 .option-move,或是用 moveClass 這個 prop 指定要套用的 CSS class,之後再到 CSS 寫 transform 的內容。
💡 React 也有對應的元件,叫做
<AnimatePresence>。
以 Vue 官方文件的範例為基礎,只留下移動的部分:
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item">
{{ item }}
</li>
</TransitionGroup>
.list-move {
transition: transform 0.5s ease;
}
注意事項:
*-move class 裡要有 transform 的 transition(寫 all 也可以)。Vue 會檢查這件事,沒有的話,就不做移動動畫。tag 指定。💡
<TransitionGroup>支援的 props、CSS class、JavaScript 鉤子,和<Transition>基本相同,細節可以參考官方文件。
今天要沿用 Day25 的範例,加上新的效果:當選項放到圖釘後,「已使用」的選項排到清單最後面,並且用動畫滑過去。
先加上 sortedOptions:未使用的在前,已使用的在後。模板的 v-for 改成跑 sortedOptions,其餘不變。
<template>
<div class="options">
<div
v-for="option in sortedOptions"
:key="option.id"
:ref="(el) => setOptionEl(option.id, el)"
class="option"
:class="{ used: isUsed(option.id) }"
@pointerdown="onDragStart($event, option)"
>
{{ option.name }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const optionEls = new Map() // { 選項 id: DOM 元素 }
const setOptionEl = (id, el) => {
if (el) optionEls.set(id, el)
else optionEls.delete(id)
}
// filter 會保留原本順序,所以結果是穩定的
const sortedOptions = computed(() => [
...options.filter((o) => !isUsed(o.id)),
...options.filter((o) => isUsed(o.id)),
])
</script>
目前還沒有任何動畫效果,把選項拖到圖釘上,放開的瞬間,被放置的選項就直接跳到最後面,其他選項也跟著向前補位。
把「會讓順序改變」的操作包進一個 withFlip(),依序做 First、改資料、Last、Invert、Play:
import { nextTick } from 'vue'
// 當下所有 DOMRect 的快照
const captureRects = () => {
const rects = new Map()
for (const [id, el] of optionEls) rects.set(id, el.getBoundingClientRect())
return rects
}
const withFlip = async (change) => {
// First:量「現在看到的位置」
const first = captureRects()
change() // 改資料
await nextTick() // 等 Vue 把 DOM 更新到新順序
for (const [id, el] of optionEls) {
const firstRect = first.get(id)
if (!firstRect) continue // 新出現的元素沒有舊位置,不用動畫
// Last
const last = el.getBoundingClientRect()
// Invert
const dx = firstRect.left - last.left
const dy = firstRect.top - last.top
if (dx === 0 && dy === 0) continue
// Play:從舊位置回到 none
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
{ duration: 250, easing: 'ease' },
)
}
}
然後把 placeOption() 裡改資料的部分,交給 withFlip():
const placeOption = (optionId, pinId) => {
withFlip(() => {
const next = { ...placements.value }
for (const [id, oid] of Object.entries(next)) {
if (oid === optionId) delete next[id]
}
next[pinId] = optionId
placements.value = next
})
}
一些要注意的點:
await nextTick() 才執行 Last 步驟。資料改了,Vue 不會立刻更新 DOM;要等 nextTick,才量得到新位置。el.animate(),直接設定兩個關鍵影格:從 translate(dx, dy) 到 none。就不需要自己寫 CSS transition,也不用管動畫結束後要清掉什麼。dx、dy 都是 0,不用建立動畫。如果使用者很快地連續放置,第二次的 First 會在上一輪動畫還沒結束時被量到。這時 getBoundingClientRect() 量到的是畫面上目前的位置(包含 transform),新動畫就會從使用者眼前看到的位置接續,不會跳回起點,所以 First 不用特別處理。
但 Last 需要的是「沒有動畫」的新位置,所以量之前,要先取消舊動畫:
const withFlip = async (change) => {
// First 不用改:量到的是畫面上目前的位置
const first = captureRects()
change()
await nextTick()
for (const [id, el] of optionEls) {
// Last 要「不含動畫」的位置,所以先取消舊動畫
el.getAnimations().forEach((a) => a.cancel())
// ...後面同上
}
}
<TransitionGroup>同樣的效果,換成 Vue 內建的做法,整體簡單許多。先把 v-for 外面的容器換成 <TransitionGroup>,再補一段 CSS:
<template>
<TransitionGroup tag="div" class="options" name="option">
<div
v-for="option in sortedOptions"
:key="option.id"
class="option"
:class="{ used: isUsed(option.id) }"
@pointerdown="onDragStart($event, option)"
>
{{ option.name }}
</div>
</TransitionGroup>
</template>
.option-move {
transition: transform 0.25s ease;
}
不需要 captureRects(),也不需要 withFlip(),placeOption() 維持原本直接改資料的寫法。FLIP 的四步 Vue 都在內部幫你做了,開發者只負責提供「怎麼動」的 CSS。
| 手寫 FLIP | <TransitionGroup> |
|
|---|---|---|
| 寫法 | 要自己量、算、播放 | 加 class 即可 |
| 適合 | 非列表的位置變化(例如元素搬到另一個容器) | 列表增減、重排 |
| 控制 | 可用 WAAPI 暫停、取消、串接 | 以 CSS 為主 |
今天的範例屬於「列表重排」,所以用 <TransitionGroup> 就足夠了。手寫 FLIP 主要是要了解它做了什麼,以及遇到列表以外的位置變化時,知道可以怎麼做。
💡 Day25 拖曳時「ghost 跟著 pointer 跑」不適合用 FLIP。因為那是即時跟隨 pointer 的位置,不是「從 A 補間到 B」。
.
.
.
Day26 Clear!倒數剩下 4 天! d(`・∀・)b
明天想來聊另一個互動效果:滾動進場,看看 IntersectionObserver 的實作,以及如何重構舊專案。