Svelte Animation 相關

tags: Svelte
category: Front-End
description: Svelte Animation 相關
created_at: 2023/04/17 16:00:00

cover image


回到 手把手開始寫 Svelte


前言

上一篇講完 Store 後,可以開始來記錄 animation 相關的東西(因為有些地方需要上一篇講到的 $),會有以下主題:

  • Transition
  • Motion:
    • Tweened
    • Spring
  • Animation

Vue 當中有 TransitionTransitionGroup 可以使用 Transition,而 React 則需要額外去安裝相關套件。

而補間動畫的部分則只有 Svelte 有提供,其他都需要去安裝額外的套件。


Motion

這個主要是來做值的補間的,上一篇的 Store 設定完之後值會立刻改變,假設希望可以在一段時間以某種規律去從 A 改成 B,就需要這個功能。

先來看看沒有這種套件的時候要怎麼做,之後才會知道有這種工具真的太棒了


tweened

自幹tweened功能

<script lang="ts">
    import { get, writable, type Writable } from 'svelte/store';

    const value = writable(0);
    function store_set(store: Writable<number>, value: number, duration = 1000) {
        const from = get(store);
        const to = value;

        // 線性成長函數
        const easing = (t: number) => t;

        // 計算新的值
        const interpolate = (from: number, to: number) => (t: number) => from + (to - from) * t;

        // 紀錄開始時間
        const start = performance.now();
        const tick = (now: number) => {
            // 跟 duration 算出已完成幾%
            const t = Math.min(1, (now - start) / duration);

            // 呼叫 interpolate 與 easing 計算出新的值並設定
            store.set(interpolate(from, to)(easing(t)));

            // t < 1 代表未完成,則繼續呼叫
            if (t < 1) {
                requestAnimationFrame(tick);
            }
        };
        requestAnimationFrame(tick);
    }
</script>

<div>
    <button
        on:click={() => {
            store_set(value, $value + 100);
        }}>+100</button
    >
    {$value}
</div>

這時可以戳看看按鈕,會看到畫面上 value 的值會在 1000 毫秒從 A 變成 A+100

嫌上面那段很複雜也沒關係,Svelte 有提供 tweened 函數,就是做上面那件事,也就是說下面這段是一樣的功能:

<script lang="ts">
    import { tweened } from 'svelte/motion';

    const value = tweened(0, {
        duration: 1000,
        easing: (t) => t,
        interpolate: (from, to) => (t) => {
            return from + (to - from) * t;
        }
    });
</script>

<div>
    <button
        on:click={() => {
            $value += 100;
        }}>+100</button
    >
    {$value}
</div>

實際上 tweened 還有提供一個 optionsdelay,可以設定 delay 多久之後再跑(但這個和 duration 一樣應該很好理解就不舉例了)

easing 是定義一個函數,Svelte有提供很多現成的可以直接用(範例),他主要就是描述從 0 ~ 1 的變化,也就是說你讓 t 乘以二,他過渡期間會跑到趨近於兩倍,但最後結束的時候會回到一倍,不相信的話自己把它改成 t * 2

interpolate 則是接收 easing 的值,對過渡期間的畫面上呈現的值做處理,但最後結束還是會回到你當初設定的 to 的值。

貼心提醒(?): 上面文字描述可能比較難懂,還是自己親自下去改改看會比較有感覺。


spring

上面的 tweened 補間比較像是設定一段時間然後從 A 透過函數計算到 B,而 spring 這個函數則是設定流暢度、彈性然後他幫你補間。

這邊的差異的話可能用一般值顯示不好呈現,所以順便簡單做一個區塊做動畫

<script lang="ts">
    import { spring } from 'svelte/motion';

    const value = spring(0, {
        stiffness: 0.1,
        damping: 0.5
    });
</script>

<div>
    <button
        on:click={() => {
            $value += 100;
        }}>+100</button
    >
    {$value}

    <hr />
    <div style="width: {$value}px; height: {$value}px; background: red;" />
</div>

這時去戳戳看按鈕,下面的 div 會被放大。

其中 spring 函數有三個選項(options):

  • stiffness: 硬度: 介於 0 ~ 1,越高越硬
  • damping: 彈力: 介於 0 ~ 1,越低越有彈性
  • precision: 精度,越低越精確,越高動畫也會越早結束

上面光看文字可能不太好想像,但可以想像就是在設定一個彈簧,然後稍微改參數去觀察他。

如果沒有要限制特定時間的補間,然後希望自然一些,可以使用 spring 幫你完成,如果你想設定更多細節(像是多久完成、補間函數之類),則可以使用 tweened


Transition

一個漸變,就想像是 CSS 提供的 transition 一樣,只是這可以做更細緻的操作。

基本使用

<script lang="ts">
    import { fade } from 'svelte/transition';
    let show = true;
</script>

<div>
    <button on:click={() => (show = !show)}>Toggle</button>
    {#if show}
        <div transition:fade>Show</div>
    {/if}
</div>

Svelte 內建的 transition 有提供這些,需要再去看,或是後面也會寫到可以客製化,所以這邊先以基本的 fadeblur 之類的當範例好了。

如果你只下 transition,那麼出現跟消失都會套用同一個模式,如果你要分開的話可以使用 inout,如下:

<script lang="ts">
    import { fade, blur } from 'svelte/transition';
    let show = true;
</script>

<div>
    <button on:click={() => (show = !show)}>Toggle</button>
    {#if show}
        <div in:fade out:blur>Show</div>
    {/if}
</div>

還可以帶入參數做細節的設定,例如設定 in 的時間,像下面這樣就會以 1000ms 來做 transitionfade

<div
    in:fade={{
        duration: 1000
    }}
    out:blur
>
    Show
</div>

自訂 transition

在自訂之前,要先知道他的函數定義:

transition = (node: HTMLElement, params: any, options: { direction: 'in' | 'out' | 'both' }) => {
    delay?: number,
    duration?: number,
    easing?: (t: number) => number,
    css?: (t: number, u: number) => string,
    tick?: (t: number, u: number) => void
}

傳入的部分是以下:

  • node: 基本上就是這個指令套在誰身上
  • params: 指令後面帶入的參數,例如上面 fade 的例子就是一個物件,裡面有 duration: 1000
  • options: 就是被套用在 inout、還是都有(both)

回傳的部分都是可選(optional)的:

  • delay: delay 多久再執行
  • duration: 要用多少時間去做 transition
  • easing: 這個 transition 使用的補間函數
  • css: 如何改變這個 nodeCSS
  • tick: 可以直接對 node 做操作

如果可以,盡可能使用 css,而不是 tick,除非你要的效果必須使用 javascript 才能實現。

直接來看個 css 的例子:

<script lang="ts">
    import * as easing from 'svelte/easing';

    let length = 0;
    function fn(node: HTMLElement, { duration }: { duration: number }) {
        return {
            duration,
            css: (t: number) => {
                const scaleY = easing.quadIn(t);
                const x = easing.bounceInOut(t);
                const y = easing.bounceIn(t);

                return `
                    transform: scaleY(${scaleY}) translateX(${100 - x * 100}px) translateY(${-100 + y * 100}px);
                    `;
            }
        };
    }
</script>

<button on:click={() => length++}>Add</button>

{#each { length } as _}
    <div in:fn={{ duration: 1000 }}>Hello World</div>
{/each}

<style>
    div {
        font-size: 48px;
    }
</style>

上例可以看到修改 css,他會傳入一個 t ,跟前面補間的範例一樣,那個 t 會是 0 ~ 1 之間的值,可以根據他再去做運算,而且在內部也能再透過內建的補間函數去做計算,再來回傳 CSS String 來用。

還請見諒比較沒有美感,一時想不到好看的漸變效果

再來看看 tick 的範例,使用 csstick 達到一樣的效果,但建議使用 css,才不會占用 javascript 的主線程(main thread)

function fn(node: HTMLElement, { duration }: { duration: number }) {
    return {
        duration,
        // css: (t: number) => `font-size: ${48 * easing.cubicOut(t)}px;`
        tick: (t: number) => {
            node.style.fontSize = `${48 * easing.cubicOut(t)}px`;
        }
    };
}

可以把上面的程式切換使用看看,效果是一樣的。

再舉一個只有 js 才能達到的例子:

    function typewriter(node: HTMLElement, { speed = 1 } = {}) {
        const valid = node.childNodes.length === 1 && node.childNodes[0].nodeType === Node.TEXT_NODE;

        if (!valid) {
            throw new Error(`This transition only works on elements with a single text node child`);
        }

        const text = node.textContent || '';
        const duration = text.length / (speed * 0.01);

        return {
            duration,
            tick: (t: number) => {
                const i = Math.trunc(text.length * t); // 僅取整數
                node.textContent = text.slice(0, i); // 設定為前 i 位
            }
        };
    }

這是一個官方提供的 typewriter 的範例,因為我一時也想不到什麼點子,所以我就把它翻譯成 TypeScript


TransitionEvents

這邊就相對簡單很多,就只是監聽事件,可以自己在做一些處理(如果需要的話)

<script lang="ts">
    import { fade } from 'svelte/transition';
    let length = 0;
</script>

<button on:click={() => length++}>Add</button>
<button on:click={() => length--}>Remove</button>

{#each { length } as _}
    <div
        transition:fade
        on:introstart={() => console.log('intro started')}
        on:outrostart={() => console.log('outro started')}
        on:introend={() => console.log('intro ended')}
        on:outroend={() => console.log('outro ended')}
    >
        Hello World
    </div>
{/each}

然後他也有提供事件變數,所以如果你需要也可以寫成 on:introstart={(e) => console.log('intro started', e.target)}


local 屬性

有時候可能不希望父層的關係觸發子元素的 transition,就需要使用 local 這個修飾子。

<script lang="ts">
    import { slide } from 'svelte/transition';
    let length = 0;
    let show = true;
</script>

<button on:click={() => (show = !show)}>Toggle</button>
<button on:click={() => length++}>Add</button>
<button on:click={() => length--}>Remove</button>

{#if show}
    {#each { length } as _}
        <div transition:slide>Hello World</div>
    {/each}
{/if}

如果單純這樣寫,外面的 show 被切換的時候裡面的那堆 div 還會有 transition的作用。

如果不希望這樣,只要加上 local 這個修飾子就可以。

<div transition:slide|local>Hello World</div>

Q: 你可能會想說為什麼要這麼做?

A: 因為父層可能有自己想要的 transition,如下範例:

<script lang="ts">
import { fade, slide } from 'svelte/transition';
// 略
</script>

<!-- 略 -->

{#if show}
    <div transition:fade>
        {#each { length } as _}
            <div transition:slide|local>Hello World</div>
        {/each}
    </div>
{/if}

key 區域

在講迴圈的時候有提到帶 key,當 key 不同,就會重新渲染,這邊可以利用 key 區塊的技巧,讓 transition 重新播放。

<script lang="ts">
    import { fly } from 'svelte/transition';
    let count = 0;
</script>

<button on:click={() => count++}>Increment</button>
<div>
    Count:
    {#key count}
        <div in:fly={{ y: -20 }}>{count}</div>
    {/key}
</div>

也可以設定 out,只是會很奇怪,如果有想到用途也可以加進去用。


Deferred transitions

到了 transition 當中最複雜的一個,這個的概念有點像用 key 去分組做對稱的 transition

使用 crossfade 這個函數,會得到兩個函數,分別是 sendreceive

send 會去找對應的 receive,反之亦然。

簡單的例子:

<script lang="ts">
    import { crossfade } from 'svelte/transition';
    import { quintOut } from 'svelte/easing';

    const [send, receive] = crossfade({
        duration: 1000,
        easing: quintOut
    });

    let show = true;
</script>

<button on:click={() => (show = !show)}>Toggle</button>

<div>
    {#if show}
        <h1 in:send={{ key: 1 }} out:receive={{ key: 1 }}>A</h1>
    {:else}
        <h1 in:send={{ key: 1 }} out:receive={{ key: 1 }}>B</h1>
    {/if}
</div>

<style>
    div {
        position: relative;
    }

    h1 {
        position: absolute;
        top: 0;
    }
</style>

一個對應可能沒什麼感覺,假設有兩個對應,如下:

<div>
    {#if show}
        <h1 in:send={{ key: 1 }} out:receive={{ key: 1 }}>A</h1>
    {:else}
        <h1 in:send={{ key: 2 }} out:receive={{ key: 2 }}>B</h1>
    {/if}

    {#if show}
        <h1 style="top: 60px" in:send={{ key: 2 }} out:receive={{ key: 2 }}>A2</h1>
    {:else}
        <h1 style="top: 60px" in:send={{ key: 1 }} out:receive={{ key: 1 }}>B2</h1>
    {/if}
</div>

這時去戳按鈕,就會發現是上下對調。

crossfade 還有一個 optionfallback 函數,這個函數當他沒有找到對應要做 transition 的元素就會觸發。

<script lang="ts">
    import { crossfade } from 'svelte/transition';
    import { quintOut } from 'svelte/easing';

    const [send, receive] = crossfade({
        duration: 1000,
        easing: quintOut,
        fallback(node, params, intro) {
            return {
                duration: 600,
                easing: quintOut,
                css: (t) => `opacity: ${t}; transform: translateY(${-20 + t * 20}px);`
            };
        }
    });

    let show = true;
</script>

<button on:click={() => (show = !show)}>Toggle</button>

<div>
    {#if show}
        <h1 in:send={{ key: 1 }} out:receive={{ key: 1 }}>A</h1>
    {:else}
        <h1 in:send={{ key: 2 }} out:receive={{ key: 2 }}>B</h1>
    {/if}

    {#if show}
        <h1 style="top: 60px" in:send={{ key: 2 }} out:receive={{ key: 2 }}>A2</h1>
    {/if}
</div>

<style>
    div {
        position: relative;
    }

    h1 {
        position: absolute;
        top: 0;
    }
</style>

上面的範例刻意讓 A 沒有對應的元素,那麼他就會觸發 fallback 函數,去做不同的 transition

fallback 有傳入三個參數可以使用,分別是: node: 與上方定義相同,就是綁在誰身上 params: 同上,例如上例帶入的 key: 1 intro: 當前執行的 fallback node 是不是 intro,這邊可以自己把他 console 出來觀察變化(我是覺得應該會滿少用到的?)

主要比較常用到的可能是新增或刪除的操作,剛好會導致另一方找不到對應的 key,而觸發 fallback 行為。


Animation

終於到了最後一個小結,這邊 SvelteAnimation 提供一個 flip 的指令來使用,主要是來做 transition 之間的補間。

FLIP 的全寫是: First, Last, Invert, Play,細節可以看這裡,但主要就是說

  • First: 紀錄動畫元素的初始狀態
  • Last: 讓元素移動到最終狀態並記錄狀態
  • Invert: 以前從上面知道初始以及最終狀態,接著可以用 transform 相關的屬性讓元素移動回初始狀態
  • Play: 開始跑動畫,從初始狀態跑到最終狀態

我知道文字很難懂,不過核心概念就是因為用 js 去做動畫是很昂貴的事情(可能會因為卡住主線程而掉幀之類),所以這邊的概念是用 js 去算,然後套入 css 去實際跑動畫。

而這邊 Svelte 提供的 animate:flip 主要是幫你補間迭代元素可能被刪除,等待被刪除元素的 out,結束後下面元素遞補上來的過程。

目前他也只能被放在 each 區域(有帶 key 的情形)才能使用。

簡單的範例

<script lang="ts">
    import { fade } from 'svelte/transition';

    let stack: number[] = [];
</script>

<h1>Stack</h1>
<div>
    <button on:click={() => (stack = [...stack, Math.random()])}>Push</button>
    <button on:click={() => (stack = stack.slice(1))}>Pop</button>
</div>

{#each stack as item (item)}
    <div transition:fade>{item}</div>
{/each}

先去戳看看 Pop,會發現下面遞補上來的元素是瞬間上來,再來可以加上 animate:flip 指令:

<script lang="ts">
    import { flip } from 'svelte/animate';

    // 略...
</script>

<!-- 略... -->
{#each stack as item (item)}
    <div transition:fade animate:flip>{item}</div>
{/each}

注意:那個 #each 區塊的 key 是必須有的,否則會編譯錯誤。

這時在去戳戳看 Pop,會發現變得很流暢。

flip 一樣有幾個 options 可以使用:

  • delay
  • duration
  • easing

這幾個都跟上面描述到的一樣用途,所以就不再贅述了。


總結

這一篇一口氣把 Svelte 動畫相關的東西補齊,內容其實滿多的,自己也整理了滿多時間(因為都想不太到很酷炫的範例,只好以簡單為主,未來回來看可以從這些再做延伸)

總之盡可能用 css 去完成動畫,除非真的有必要再去透過 js 處理,效能會比較好。




最後更新時間: 2023年04月17日.