Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Spring and Tween classes #11519

Merged
merged 13 commits into from
Dec 6, 2024
Merged

feat: add Spring and Tween classes #11519

merged 13 commits into from
Dec 6, 2024

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented May 9, 2024

This adds a Spring class as an alternative to the existing spring store factory. The behaviour is essentially identical, but the API is slightly different:

-const coords = spring({ x: 50, y: 50 }, opts);
+const coords = new Spring({ x: 50, y: 50 }, opts);

// set new value immediately
-coords.set(value, { hard: true });
+coords.set(value, { instant: true });

// set new value but preserve momentum for 500 milliseconds (useful for 'throwing' interactions)
-coords.set(value, { soft: true });
+coords.set(value, { preserveMomentum: 500 });

instant and preserveMomentum are clearer than hard and soft, which seem like they're somehow symmetrical but are in fact unrelated.

To keep a spring in sync with some other value (such as a prop), use Spring.of:

let { number } = $props();

-const thing = spring();
-$: thing.set(number);
+const thing = Spring.of(() => number);

In this case it's still possible to do thing.set(value, opts), since you might (e.g.) need to use { instant: true }, and making it 'readonly' in the function case could be overly restrictive.

spring.set will return a promise that resolves when spring.current reaches spring.target. If you don't need the promise, and don't need to pass instant or preserveMomentum options, you can manipulate spring.target directly. (This does mean we have two ways of doing things, but we need to expose spring.target anyway and it would be weird if it was readonly.)

A nice thing about using classes: you can do const spring = new Spring(...) instead of always having to come up with a more descriptive name.

TODO

  • add a Tween class to go with Spring
  • docs
  • tests? (though there are very few spring tests to adapt — not totally sure what tests would look like here)

Before submitting the PR, please make sure you do the following

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

Copy link

changeset-bot bot commented May 9, 2024

🦋 Changeset detected

Latest commit: 6acaddc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@abdel-17
Copy link

abdel-17 commented May 9, 2024

As an alternative to providing an initial value, you can provide a function, making it easy to keep a spring in sync with some other value:

Correct me if I'm wrong, but the value of the spring would get out of sync with the external state if you try to set it directly, no?

let { number } = $props();

const thing = new Spring(() => number);

thing.set(5); // oops now we’re out of sync

I think a value change callback could be added to avoid such cases.

let { number = $bindable() } = $props();

const thing = new Spring(() => number, {
  onChange: (v) => (number = v)
});

thing.set(5); // still in sync

@Rich-Harris
Copy link
Member Author

the value of the spring would get out of sync with the external state if you try to set it directly, no?

Yes, and the solution is 'don't do that'. In general you wouldn't need to set a store with a function input, but it's a useful escape hatch if you need to do something like this:

let progress = $state(0);
let spring = new Spring(() => progress);

function increment() {
  if (progress === 10) {
    spring.set(0, { instant: true });
    progress = 0;
  } else {
    progress += 1;
  }
}

It would be strange to have an onChange callback that updated to the target value rather than the current value.

@abdel-17
Copy link

abdel-17 commented May 9, 2024

Yeah fair. I just worry this might cause weird bugs because you expect the values to always be in sync.

@PuruVJ
Copy link
Collaborator

PuruVJ commented May 9, 2024

Possible to sneak in this as well? #9141 (comment)

@jeremy-deutsch
Copy link
Contributor

jeremy-deutsch commented May 10, 2024

Because of the function case, it feels like it might be better to have some kind of skipAnimation()/cancelAnimation()/jump() method instead of the { immediate: true } option? That way you can avoid set() altogether.

Like:

<script>
  let value = $state(0);

  const spring = new Spring(() => value);

  function resetSpring() {
    value = 0;
    // switch directly to the current value without animating
    spring.skipAnimation();
  }
</script>

This works even if you're using set():

<script>
  const spring = new Spring(0);

  function resetSpring() {
    spring.set(0);
    // switch directly to the current value without animating
    spring.skipAnimation();
  }
</script>

I like that this way you no longer have to know what the final spring position is in order to skip the animation. That seems really nice for the function case.

I also think a preserveMomentum(seconds) method could work similarly.

@grischaerbe
Copy link

I'd love to have preserveMomentum in milliseconds. Apart from that, I like the new API 👍

@ottomated
Copy link
Contributor

I'd love to see this in 5.0 and would like to work on it, @Rich-Harris do you have any input on the proposed APIs or should I just work on merge conflicts and the Tween class?

@dummdidumm dummdidumm mentioned this pull request Nov 10, 2024
@Rich-Harris Rich-Harris self-assigned this Nov 11, 2024
@newsve
Copy link

newsve commented Nov 25, 2024

In this context: My app heavily relies on many sophisticated spring animations and Sveltte 4 custom stores (based on writable, etc.). I actually created kind of an rendering engine based on Svelte's building blocks.

I believe that this new spring class adapts all subtleties from the previous API/implementation, e.g. hard and soft props, stiffness and dampening, still I am a bit afraid that I can't replicate all the motions, also there would be some significant refactoring effort, basically rewriting the whole thing. I am still refactoring hundreds of legacy .svelte files to Svelte 5. BTW, Svelte 5 was really drop-in for me and the legacy mode is a excellent way to nudge the community to a migration without breaking changes! 🙂

@Rich-Harris do you already know if you gonna remove (1) the prior spring API and (2) and the Svelte 4 stores, writable etc. in Svelte 6 or in any version after 6? Just to be prepared to plan our future, looking fwd to your reply!

Copy link
Contributor

github-actions bot commented Dec 2, 2024

Playground

pnpm add https://pkg.pr.new/svelte@11519

@Rich-Harris
Copy link
Member Author

preview: https://svelte-dev-git-preview-svelte-11519-svelte.vercel.app/

this is an automated message

@Rich-Harris
Copy link
Member Author

A few changes:

  • Instead of new Spring(value) and new Spring(fn), it's now new Spring(value) and Spring.of(fn). I think the clearer separation is better — we can now say that you can create a new Spring(...) anywhere (including in a shared module) but can only call Spring.of(...) inside an effect root (i.e. during component initialisation)
  • spring.target is exposed. It would be very inconvenient if you always had to go through spring.set(...). This does break the 'only have one way of doing things' rule but I think it's worth it
  • preserveMomentum is now in milliseconds
  • I added a Tween class that behaves the same way

One problem: svelte/motion already exports Spring and Tweened interfaces. I can't think of a better name for the Spring class and don't think we should add another module, so in this PR I've renamed the existing interfaces to SpringStore and TweenedStore to avoid confusion. This is a breaking change, and there are a handful of uses in the wild, so I won't be surprised if someone vetoes this. I would be eager to hear better ideas though.

Also, this PR deprecates the existing spring and tweened stores. I think that's the right move, but if people feel strongly we can delay this. @newsve to answer your question, these stores will continue to exist for a good while yet — they will eventually be removed, but not until Svelte 5 has been out for at least a year or so.

@Rich-Harris Rich-Harris marked this pull request as ready for review December 3, 2024 02:13
@Rich-Harris Rich-Harris marked this pull request as draft December 3, 2024 18:49
@Rich-Harris Rich-Harris changed the title feat: add Spring class feat: add Spring and Tween classes Dec 4, 2024
@abdel-17
Copy link

abdel-17 commented Dec 5, 2024

I think the function case should expose an onChange option to avoid the values getting out of sync.

const spring = Spring.of(() => value, {
  onChange(v) {
    value = v;
  }
});

This would get called when the spring's value reaches its target to avoid calling the callback repeatedly.

EDIT: I just noticed I had already made a similar comment above. I still think it's not a bad idea to have this though.

@Rich-Harris
Copy link
Member Author

What for? You can just track spring.current in an effect

@Rich-Harris Rich-Harris marked this pull request as ready for review December 6, 2024 14:12
@Rich-Harris Rich-Harris merged commit 80ffcc3 into main Dec 6, 2024
11 checks passed
@Rich-Harris Rich-Harris deleted the new-spring branch December 6, 2024 14:15
@github-actions github-actions bot mentioned this pull request Dec 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants