sveltesvelte-5

How to use Tween in actions?


Svelte 5 deprecated tweened and replaced it with Tween.

tweened was a store, so in an action I could subscribe to the value and react to it that way (e.g. turn an offset into a line of CSS like transform: translate(${Math.round(x)}px, ${Math.round(y)}px)).

However, Tween does not seem to be reactive on its own, and all of the examples I can find involve throwing it into a Svelte component with e.g. bind:, which will not work inside of an action.

I am aware that actions have been replaced with attachments, but I do not see how that would help me solve the core issue of reacting to the value of Tween.current

How to react to the current value of a Tween directly, i.e. not using bind: or any other Svelte mechanism, just JavaScript?

Here is some code you can test with in a fresh Svelte 5 project:

const { abs, sqrt, round } = Math;
import { tweened } from "svelte/motion";
import { cubicInOut } from "svelte/easing";
import type { Unsubscriber } from "svelte/store";
type vector = [number, number];
type ArrowKey = 'up' | 'left' | 'right' | 'down';
type nullable<T> = T | null;

export const gesturesAction = (
    node: HTMLElement,
    opts: {
        selfOnly?: boolean,
        longPressDelay?: number,
        minSwipeDistance?: number,
        drag?: boolean | 'x' | 'y' | 'xy', // true is free drag, xy locks you to x and y axis
        minDragDistance?: number,
        snapDuration?: number,
    } = {}
) => {

    // fill missing opts properties with defaults
    const SELF_ONLY = opts.selfOnly || false; // if true, event will not trigger on child nodes.
    const LONG_PRESS_DELAY = opts.longPressDelay || 1000;
    const MIN_SWIPE_DISTANCE = opts.minSwipeDistance || 40;
    const DRAG = opts.drag || false;
    const MIN_DRAG_DISTANCE = opts.minDragDistance || 40;
    const SNAP_DURATION = opts.snapDuration || 200; // after pointerup, how long to snap back

    // warn if drag enabled but node has a position that is not draggable
    const cssPosition = window.getComputedStyle(node).position;
    if (!['absolute', 'fixed'].includes(cssPosition)) {
        console.warn(`
        Dragging enabled on element whose css position is ${cssPosition}.
        Change to either absolute or fixed for dragging to work properly.
        Element id: ${node.id}
        Parent id: ${node.parentElement?.id}
        `)
    }

    // wrapper for dispatching custom events, 'detail' optional (see CustomEvent docs)
    const dispatch = <T = unknown>(name: string, detail?: T) => node.dispatchEvent(new CustomEvent(name, { detail }));

    // adds event listeners, returns a cleanup function
    const listen = (type: keyof HTMLElementEventMap, handler: (e: PointerEvent) => unknown) => {
        const fn = SELF_ONLY ? (e: PointerEvent) => { if (e.target === node) { handler(e); } } : handler;
        node.addEventListener(type, fn as EventListener);
        return () => node.removeEventListener(type, fn as EventListener);
    };

    // measure movement
    let start: nullable<vector>;
    let dPos: vector;
    let dEnd: nullable<vector>;
    let direction: nullable<ArrowKey>;
    let distance: number;
    let animationFrameRequested = false; // prevent multiple calls per frame
    let cDragAxis: nullable<'x' | 'y'> = null; // in 'xy' case, stick to one axis per drag
    let tSub: Unsubscriber; // subscription for resetting position after drag
    const onAnimationFrame = (e: PointerEvent) => {
        if (!start) { return; }
        
        dPos = [e.clientX - start[0], e.clientY - start[1]];
        let [dx, dy] = dPos;
        
        if (DRAG) {

            const [adx, ady] = [abs(dx), abs(dy)];

            // ignore both tiny movements and purely diagonal movement
            if ((adx + ady < 4) || adx === ady) { return; }

            direction = (adx > ady) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'up' : 'down');

            // if not free drag, eliminate undesired movement
            if (typeof DRAG === 'string') {
                if (!cDragAxis) {
                    cDragAxis = DRAG === 'xy' ? (adx > ady ? 'x' : 'y') : DRAG;
                }
    
                if (cDragAxis === 'x') { dy = 0; }
                else { dx = 0; }
            }
            
            // actually drag the element
            node.style.transform = `translate(${round(dx)}px, ${round(dy)}px)`;
            distance = sqrt((dx ** 2) + (dy ** 2));
            if (!MIN_DRAG_DISTANCE || distance >= MIN_DRAG_DISTANCE) {
                const detail = { direction, distance, dx, dy };
                dispatch('drag', detail);
                dispatch(`drag${direction}`, detail);
            }
        }
    };
    const getPos = (e: PointerEvent): vector => [e.clientX, e.clientY];
    const toRemove: (() => void)[] = [
        // NOTE do NOT get ride of touchmove! touch-action: none was not enough.
        // NOTE leave preventDefault too! Lost like three hours on this.
        listen('touchmove', e => e.preventDefault()),
        listen('pointerdown', e => {
            start = getPos(e);
            dPos = [0, 0];
            dEnd = null;
        }),
        listen('pointermove', e => {
            if (!animationFrameRequested) {
                animationFrameRequested = true;
                requestAnimationFrame(() => {
                    onAnimationFrame(e);
                    animationFrameRequested = false;
                });
            }
        }),
        listen('pointerup', e => {
            dEnd = dPos;
            if (DRAG) {
                if (!MIN_DRAG_DISTANCE || distance >= MIN_DRAG_DISTANCE) {
                    const [dx, dy] = dPos;
                    dispatch('dragend', { direction, distance, dx, dy })
                }

                // gradually reset node position
                const tween = tweened(
                    cDragAxis === 'x' ? [dPos[0], 0] : [0, dPos[1]],
                    { duration: SNAP_DURATION, easing: cubicInOut }
                );
                tSub = tween.subscribe(([tx,ty]) => {
                    node.style.transform = `translate(${round(tx)}px, ${round(ty)}px)`;
                });
                tween.set([0,0]);
            }
            // NOTE do not zero distance here, it will fuck with swiping.
            start = cDragAxis = direction = null;
            dPos = [0,0];
        }),
        () => tSub?.()
    ];

    // long press
    // NOTE for touch devices, may have to disable context menu on long press, or at least delay it
    let lpTimer: ReturnType<typeof setTimeout>;
    toRemove.push(
        listen('pointerdown', () => lpTimer = setTimeout(() => dispatch('longpress'))),
        listen('pointerup', () => clearTimeout(lpTimer))
    );

    // swipe, swipeleft, swiperight, swipeup, swipedown
    let swipe: 'swipeleft' | 'swiperight' | 'swipeup' | 'swipedown';
    toRemove.push(listen('pointerup', e => {
        if (distance > MIN_SWIPE_DISTANCE && dEnd) {
            if (abs(dEnd[0]) > abs(dEnd[1])) { // if swipe more horizontal than vertical
                swipe = dEnd[0] > 0 ? 'swiperight' : 'swipeleft';
            } else { swipe = dEnd[1] > 0 ? 'swipedown' : 'swipeup'; }
            dispatch(swipe);
            dispatch('swipe', { direction: swipe.slice(5) as ArrowKey });
        }
    }));

    return {
        destroy: () => toRemove.forEach(remove => remove())
    }
};
<script module lang="ts">
    import { gesturesAction } from '$lib/drag.action.svelte';
</script>

<span use:gesturesAction={{ drag: true, minDragDistance: 40 }}>Drag me!</span>

<style>
    span {
        position: absolute;
        text-align: center;
        padding: 4px 4px;
        font-size: 24px;
        background-color: blueviolet;
    }
</style>

Solution

  • Tween uses signal-based reactivity which only works in the context of effects/derived values. Anything in the template as well as the code in an attachment executes in an effect.

    This is not the case for actions. So if you want to continue using an action, you need to manually add an $effect there.

    Example:

    <script>
        import { Tween } from 'svelte/motion';
    
        const value = new Tween(0);
    
        function moveAttachment(node) {
            node.style.translate = value.current * 200 + 'px';
        }
    
        function move(node) {
            $effect(() => {
                node.style.translate = value.current * 200 + 'px';
            });
        }
    </script>
    
    <button onclick={() => value.target = value.target == 0 ? 1 : 0}>
        Move
    </button> <br>
    
    <div class="box" {@attach moveAttachment}>A</div> <br>
    <div class="box" use:move>B</div>
    

    Playground

    If you cannot use $effect, there would also be the toStore utility function that can convert signals to stores.

    import { Tween } from 'svelte/motion';
    import { toStore } from 'svelte/store';
    
    const value = new Tween(0);
    const valueStore = toStore(() => value.current);
    
    function move(node) {
        // (if this is in a plain JS file)
        const unsub = valueStore.subscribe(v =>
            node.style.translate = v * 200 + 'px'
        );
    
        return { destroy: unsub };
    }
    

    Playground