iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Modern Web

從實務需求出發:前端視覺與 UI 互動開發實踐系列 第 26 篇

【Day 26】網頁互動:列表排序變動加上 FLIP 動畫

  • 分享至 

  • xImage
  •  

昨天手寫了拖曳,選項放到圖釘後,只是變淡(used)而已。但如果選項變多、名稱變長,選項列表會變成可以左右滑動;這時為了讓使用者優先看到還沒填寫的選項,希望能「把已放置的選項排到後面」。

如果直接更新資料順序,畫面看起來就是選項瞬間跳位,十分突兀。參考過去專案用的 vue-draggable-plus,拖曳時其他選項會滑開讓位,該套件的底層 SortableJS 就是用了類似 FLIP 的做法。

今天我們先來了解 FLIP 是什麼,之後沿用昨天的範例,分別用手寫 FLIP 與 Vue <TransitionGroup> 兩種方式,補上選項移動動畫。那麼我們開始吧!


一、FLIP 是什麼,解決了什麼問題

排序、篩選、插入這類操作,改的是 DOM 的順序。DOM 一變,元素是直接出現在新位置,中間沒有任何過程。一開始想如何補上動畫時,直覺會是去動 top / left。但這會有兩個問題:

  • 每一幀都要重新計算版面 Layout,旁邊的元素也可能被一起重算,容易因效能關係影響到網頁流暢度。(瀏覽器的渲染流程可以參考 Day20)
  • 排序改的是 DOM 順序,不是某個 CSS 數值,所以沒有屬性可以直接 transition。元素的新位置,是瀏覽器排版後算出來的結果。

解法方法

FLIP 是 First、Last、Invert、Play 的縮寫,來自 Paul Lewis 的文章 FLIP Your Animations。做法是:先讓版面直接變成最終狀態,再用 transform 補出中間的過程。

  1. First:用 getBoundingClientRect() 記下元素現在的位置。
  2. Last:改變 DOM,讓元素到達最終狀態,再量一次新位置。
  3. Invert:算出兩個位置的差 dx、dy,用 transform: translate(dx, dy) 把元素「視覺上」放回舊位置(起點)。
  4. Play:移除 transform 並加上動畫,元素就會從舊位置滑向新位置(終點)。

以今天的例子來說,就是:

First   拿鐵小站在第 1 格        ─ 記下位置
Last    改順序,它變成第 3 格     ─ 再量一次位置
Invert  translate(-220px, 0)    ─ 視覺上把它放回第 1 格(使用者還看不到變化)
Play    translate → none        ─ 觸發動畫,滑到第 3 格

對瀏覽器而言,元素「真正的位置」從一開始就已經在終點了,動畫只是用 transform 做視覺上的補間,不會再改變版面。動畫期間盡量只動 transform、opacity 這類不需要重算版面的屬性,比較容易維持流暢。


二、Vue 的解法:<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>

目前還沒有任何動畫效果,把選項拖到圖釘上,放開的瞬間,被放置的選項就直接跳到最後面,其他選項也跟著向前補位。

方法一:手寫 FLIP

把「會讓順序改變」的操作包進一個 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,才量得到新位置。
  • Play 用 Day23 介紹過的 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」。


完整範例

Codepen 連結

.
.
.

Day26 Clear!倒數剩下 4 天! d(`・∀・)b

明天想來聊另一個互動效果:滾動進場,看看 IntersectionObserver 的實作,以及如何重構舊專案。


參考資料


上一篇
【Day 25】網頁互動:用 pointer events 拆解拖曳填答
系列文
從實務需求出發:前端視覺與 UI 互動開發實踐 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言