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>
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>
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 };
}