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

Add new higher order animation - withClamp #5239

Merged
merged 28 commits into from
Nov 15, 2023
Merged

Add new higher order animation - withClamp #5239

merged 28 commits into from
Nov 15, 2023

Conversation

Latropos
Copy link
Contributor

@Latropos Latropos commented Oct 16, 2023

Summary

Add new higher order function withClamp that allows to limit range of movement. Usable especially with spring animation.

Allows to pass number or string for both of the clamps.

Screen.Recording.2023-11-14.at.17.43.27.mov

Test plan

Testing code
'use strict';
import { defineAnimation, getReduceMotionForAnimation } from './util';
import type {
  Animation,
  AnimationCallback,
  AnimatableValue,
  Timestamp,
} from '../commonTypes';
import type {
  SpringConfig,
  SpringAnimation,
  InnerSpringAnimation,
  SpringConfigInner,
  DefaultSpringConfig,
} from './springUtils';
import {
  initialCalculations,
  calculateNewMassToMatchDuration,
  underDampedSpringCalculations,
  criticallyDampedSpringCalculations,
  isAnimationTerminatingCalculation,
  scaleZetaToMatchClamps,
  isConfigValid,
} from './springUtils';

// TODO TYPESCRIPT This is a temporary type to get rid of .d.ts file.
type withSpringType = <T extends AnimatableValue>(
  toValue: T,
  userConfig?: SpringConfig,
  callback?: AnimationCallback
) => T;

export const withSpring = ((
  toValue: AnimatableValue,
  userConfig?: SpringConfig,
  callback?: AnimationCallback
): Animation<SpringAnimation> => {
  'worklet';

  return defineAnimation<SpringAnimation>(toValue, () => {
    'worklet';
    const defaultConfig: DefaultSpringConfig = {
      damping: 10,
      mass: 1,
      stiffness: 100,
      overshootClamping: false,
      restDisplacementThreshold: 0.01,
      restSpeedThreshold: 2,
      velocity: 0,
      duration: 2000,
      dampingRatio: 0.5,
      reduceMotion: undefined,
      clamp: undefined,
    } as const;

    const config: DefaultSpringConfig & SpringConfigInner = {
      ...defaultConfig,
      ...userConfig,
      useDuration: !!(userConfig?.duration || userConfig?.dampingRatio),
      skipAnimation: false,
    };

    config.skipAnimation = !isConfigValid(config);

    if (config.duration === 0) {
      config.skipAnimation = true;
    }

    function springOnFrame(
      animation: InnerSpringAnimation,
      now: Timestamp
    ): boolean {
      const { toValue, startTimestamp, current } = animation;

      const timeFromStart = now - startTimestamp;

      if (config.useDuration && timeFromStart >= config.duration) {
        animation.current = toValue;
        // clear lastTimestamp to avoid using stale value by the next spring animation that starts after this one
        animation.lastTimestamp = 0;
        return true;
      }

      if (config.skipAnimation) {
        // If we use duration we want to wait the provided time before stopping
        if (config.useDuration) return false;
        else {
          animation.current = toValue;
          animation.lastTimestamp = 0;
          return true;
        }
      }
      const { lastTimestamp, velocity } = animation;

      const deltaTime = Math.min(now - lastTimestamp, 64);
      animation.lastTimestamp = now;

      const t = deltaTime / 1000;
      const v0 = -velocity;
      const x0 = toValue - current;

      const { zeta, omega0, omega1 } = animation;

      const { position: newPosition, velocity: newVelocity } =
        zeta < 1
          ? underDampedSpringCalculations(animation, {
              zeta,
              v0,
              x0,
              omega0,
              omega1,
              t,
            })
          : criticallyDampedSpringCalculations(animation, {
              v0,
              x0,
              omega0,
              t,
            });

      animation.current = newPosition;
      animation.velocity = newVelocity;

      const { isOvershooting, isVelocity, isDisplacement } =
        isAnimationTerminatingCalculation(animation, config);

      const springIsNotInMove =
        isOvershooting || (isVelocity && isDisplacement);

      if (!config.useDuration && springIsNotInMove) {
        animation.velocity = 0;
        animation.current = toValue;
        // clear lastTimestamp to avoid using stale value by the next spring animation that starts after this one
        animation.lastTimestamp = 0;
        return true;
      }

      return false;
    }

    function isTriggeredTwice(
      previousAnimation: SpringAnimation | undefined,
      animation: SpringAnimation
    ) {
      return (
        previousAnimation?.lastTimestamp &&
        previousAnimation?.startTimestamp &&
        previousAnimation?.toValue === animation.toValue &&
        previousAnimation?.duration === animation.duration &&
        previousAnimation?.dampingRatio === animation.dampingRatio
      );
    }

    function onStart(
      animation: SpringAnimation,
      value: number,
      now: Timestamp,
      previousAnimation: SpringAnimation | undefined
    ): void {
      animation.current = value;
      animation.startValue = value;

      let mass = config.mass;
      const triggeredTwice = isTriggeredTwice(previousAnimation, animation);

      const duration = config.duration;

      const x0 = triggeredTwice
        ? // If animation is triggered twice we want to continue the previous animation
          // form the previous starting point
          previousAnimation?.startValue
        : Number(animation.toValue) - value;

      if (previousAnimation) {
        animation.velocity =
          (triggeredTwice
            ? previousAnimation?.velocity
            : previousAnimation?.velocity + config.velocity) || 0;
      } else {
        animation.velocity = config.velocity || 0;
      }

      if (triggeredTwice) {
        animation.zeta = previousAnimation?.zeta || 0;
        animation.omega0 = previousAnimation?.omega0 || 0;
        animation.omega1 = previousAnimation?.omega1 || 0;
      } else {
        if (config.useDuration) {
          const actualDuration = triggeredTwice
            ? // If animation is triggered twice we want to continue the previous animation
              // so we need to include the time that already elapsed
              duration -
              ((previousAnimation?.lastTimestamp || 0) -
                (previousAnimation?.startTimestamp || 0))
            : duration;

          config.duration = actualDuration;
          mass = calculateNewMassToMatchDuration(
            x0 as number,
            config,
            animation.velocity
          );
        }

        const { zeta, omega0, omega1 } = initialCalculations(mass, config);
        animation.zeta = zeta;
        animation.omega0 = omega0;
        animation.omega1 = omega1;

        if (config.useDuration && config.clamp !== undefined) {
          animation.zeta = scaleZetaToMatchClamps(animation, config.clamp);
        }
      }

      animation.lastTimestamp = previousAnimation?.lastTimestamp || now;

      animation.startTimestamp = triggeredTwice
        ? previousAnimation?.startTimestamp || now
        : now;
    }

    return {
      onFrame: springOnFrame,
      onStart,
      toValue,
      velocity: config.velocity || 0,
      current: toValue,
      startValue: 0,
      callback,
      lastTimestamp: 0,
      startTimestamp: 0,
      zeta: 0,
      omega0: 0,
      omega1: 0,
      reduceMotion: getReduceMotionForAnimation(config.reduceMotion),
    } as SpringAnimation;
  });
}) as withSpringType;

@Latropos Latropos changed the title Add new high order animation - withClamp Add new higher order animation - withClamp Oct 16, 2023
@Latropos Latropos marked this pull request as ready for review October 16, 2023 13:11
.eslintrc.js Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Show resolved Hide resolved
.eslintrc.js Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
@piaskowyk
Copy link
Member

Could you confirm that it works for different type of animation value?

  • standard numbers
  • colors - as HEX, as string rgb(), hsv()
  • matrixes
  • postfix values - deg, px

What will happen if someone use this syntax:

  • withClamp(withSequence(...))
  • withClamp(withDelay(...))

@Latropos Latropos marked this pull request as draft October 18, 2023 13:48
@Latropos
Copy link
Contributor Author

@piaskowyk I've just realised that our spring doesn't work with withRepeat very well.

  1. In the first recording the first animation has EMPTY config, and the second has NO config. So they should have exactly the same behaviour. However in reality, once we trigger animation again the upper stops and immediately goes to the value it has just receives, and the bottom ones starts animation towards the new value.
Screen.Recording.2023-10-18.at.15.51.18.mov
  1. Spring withDuration doesn't work with withRepeat. It is assumed that new duration should match the total time of repeated animation, so when you want your animation to repeat forever you get bad results. 😢

@Latropos
Copy link
Contributor Author

Latropos commented Oct 30, 2023

@piaskowyk I've just tested and:

  • withClamp(withSequence(...)) works ✅
  • withClamp(withDelay(...)) works ✅

With clamp doesn't work, if inner animation returns something other than number. I've explained why I think we shouldn't implement such a feature for other types.

  • number ✅ - this is simple, we compare numbers by value
  • colors ❌ - how to say if colour has exceeded some predefined value? Is blue bigger than red?
  • matrixes ❌ - nope, matrix contains too much values, we can't easily compare matrices.
  • postfix values - deg, px ❌ - not implemented as well. Because we would have to answer more questions, for example imagine sb provides lower clamp in radians and upper one in degrees. Should we handle it? Or, imagine sb provides one of two size values in px and other in em, and animated value is "%". When animating postfix values we have only one value, so we remove just postfix, animate the value as a number and append the postfix back. 🤷

@Latropos Latropos marked this pull request as ready for review October 30, 2023 15:17
app/src/examples/WithClampExample.tsx Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
src/reanimated2/animation/clamp.ts Outdated Show resolved Hide resolved
Co-authored-by: Krzysztof Piaskowy <krzysztof.piaskowy@swmansion.com>
@Latropos Latropos added the Needs review Issue is ready to be reviewed by a maintainer label Nov 3, 2023
Example/ios/Podfile.lock Outdated Show resolved Hide resolved
src/reanimated2/animation/util.ts Outdated Show resolved Hide resolved
@Latropos Latropos removed the Needs review Issue is ready to be reviewed by a maintainer label Nov 13, 2023
@Latropos Latropos marked this pull request as draft November 14, 2023 16:00
@Latropos Latropos marked this pull request as ready for review November 15, 2023 09:25
@Latropos Latropos added this pull request to the merge queue Nov 15, 2023
Merged via the queue into main with commit 6de5db7 Nov 15, 2023
7 checks passed
@Latropos Latropos deleted the acynk/add-clamp branch November 15, 2023 16:52
Latropos added a commit that referenced this pull request Nov 24, 2023
<!-- Thanks for submitting a pull request! We appreciate you spending
the time to work on these changes. Please follow the template so that
the reviewers can easily understand what the code changes affect. -->

## Summary
<!-- Explain the motivation for this PR. Include "Fixes #<number>" if
applicable. -->

Add new higher order function withClamp that allows to limit range of
movement. Usable especially with `spring` animation.

Allows to pass number or string for both of the clamps.


https://github.com/software-mansion/react-native-reanimated/assets/56199675/26641e51-7bb2-468d-adf9-a1f686e67824


## Test plan

<!-- Provide a minimal but complete code snippet that can be used to
test out this change along with instructions how to run it and a
description of the expected behavior. -->

<details><summary>Testing code </summary>

```javascript
'use strict';
import { defineAnimation, getReduceMotionForAnimation } from './util';
import type {
  Animation,
  AnimationCallback,
  AnimatableValue,
  Timestamp,
} from '../commonTypes';
import type {
  SpringConfig,
  SpringAnimation,
  InnerSpringAnimation,
  SpringConfigInner,
  DefaultSpringConfig,
} from './springUtils';
import {
  initialCalculations,
  calculateNewMassToMatchDuration,
  underDampedSpringCalculations,
  criticallyDampedSpringCalculations,
  isAnimationTerminatingCalculation,
  scaleZetaToMatchClamps,
  isConfigValid,
} from './springUtils';

// TODO TYPESCRIPT This is a temporary type to get rid of .d.ts file.
type withSpringType = <T extends AnimatableValue>(
  toValue: T,
  userConfig?: SpringConfig,
  callback?: AnimationCallback
) => T;

export const withSpring = ((
  toValue: AnimatableValue,
  userConfig?: SpringConfig,
  callback?: AnimationCallback
): Animation<SpringAnimation> => {
  'worklet';

  return defineAnimation<SpringAnimation>(toValue, () => {
    'worklet';
    const defaultConfig: DefaultSpringConfig = {
      damping: 10,
      mass: 1,
      stiffness: 100,
      overshootClamping: false,
      restDisplacementThreshold: 0.01,
      restSpeedThreshold: 2,
      velocity: 0,
      duration: 2000,
      dampingRatio: 0.5,
      reduceMotion: undefined,
      clamp: undefined,
    } as const;

    const config: DefaultSpringConfig & SpringConfigInner = {
      ...defaultConfig,
      ...userConfig,
      useDuration: !!(userConfig?.duration || userConfig?.dampingRatio),
      skipAnimation: false,
    };

    config.skipAnimation = !isConfigValid(config);

    if (config.duration === 0) {
      config.skipAnimation = true;
    }

    function springOnFrame(
      animation: InnerSpringAnimation,
      now: Timestamp
    ): boolean {
      const { toValue, startTimestamp, current } = animation;

      const timeFromStart = now - startTimestamp;

      if (config.useDuration && timeFromStart >= config.duration) {
        animation.current = toValue;
        // clear lastTimestamp to avoid using stale value by the next spring animation that starts after this one
        animation.lastTimestamp = 0;
        return true;
      }

      if (config.skipAnimation) {
        // If we use duration we want to wait the provided time before stopping
        if (config.useDuration) return false;
        else {
          animation.current = toValue;
          animation.lastTimestamp = 0;
          return true;
        }
      }
      const { lastTimestamp, velocity } = animation;

      const deltaTime = Math.min(now - lastTimestamp, 64);
      animation.lastTimestamp = now;

      const t = deltaTime / 1000;
      const v0 = -velocity;
      const x0 = toValue - current;

      const { zeta, omega0, omega1 } = animation;

      const { position: newPosition, velocity: newVelocity } =
        zeta < 1
          ? underDampedSpringCalculations(animation, {
              zeta,
              v0,
              x0,
              omega0,
              omega1,
              t,
            })
          : criticallyDampedSpringCalculations(animation, {
              v0,
              x0,
              omega0,
              t,
            });

      animation.current = newPosition;
      animation.velocity = newVelocity;

      const { isOvershooting, isVelocity, isDisplacement } =
        isAnimationTerminatingCalculation(animation, config);

      const springIsNotInMove =
        isOvershooting || (isVelocity && isDisplacement);

      if (!config.useDuration && springIsNotInMove) {
        animation.velocity = 0;
        animation.current = toValue;
        // clear lastTimestamp to avoid using stale value by the next spring animation that starts after this one
        animation.lastTimestamp = 0;
        return true;
      }

      return false;
    }

    function isTriggeredTwice(
      previousAnimation: SpringAnimation | undefined,
      animation: SpringAnimation
    ) {
      return (
        previousAnimation?.lastTimestamp &&
        previousAnimation?.startTimestamp &&
        previousAnimation?.toValue === animation.toValue &&
        previousAnimation?.duration === animation.duration &&
        previousAnimation?.dampingRatio === animation.dampingRatio
      );
    }

    function onStart(
      animation: SpringAnimation,
      value: number,
      now: Timestamp,
      previousAnimation: SpringAnimation | undefined
    ): void {
      animation.current = value;
      animation.startValue = value;

      let mass = config.mass;
      const triggeredTwice = isTriggeredTwice(previousAnimation, animation);

      const duration = config.duration;

      const x0 = triggeredTwice
        ? // If animation is triggered twice we want to continue the previous animation
          // form the previous starting point
          previousAnimation?.startValue
        : Number(animation.toValue) - value;

      if (previousAnimation) {
        animation.velocity =
          (triggeredTwice
            ? previousAnimation?.velocity
            : previousAnimation?.velocity + config.velocity) || 0;
      } else {
        animation.velocity = config.velocity || 0;
      }

      if (triggeredTwice) {
        animation.zeta = previousAnimation?.zeta || 0;
        animation.omega0 = previousAnimation?.omega0 || 0;
        animation.omega1 = previousAnimation?.omega1 || 0;
      } else {
        if (config.useDuration) {
          const actualDuration = triggeredTwice
            ? // If animation is triggered twice we want to continue the previous animation
              // so we need to include the time that already elapsed
              duration -
              ((previousAnimation?.lastTimestamp || 0) -
                (previousAnimation?.startTimestamp || 0))
            : duration;

          config.duration = actualDuration;
          mass = calculateNewMassToMatchDuration(
            x0 as number,
            config,
            animation.velocity
          );
        }

        const { zeta, omega0, omega1 } = initialCalculations(mass, config);
        animation.zeta = zeta;
        animation.omega0 = omega0;
        animation.omega1 = omega1;

        if (config.useDuration && config.clamp !== undefined) {
          animation.zeta = scaleZetaToMatchClamps(animation, config.clamp);
        }
      }

      animation.lastTimestamp = previousAnimation?.lastTimestamp || now;

      animation.startTimestamp = triggeredTwice
        ? previousAnimation?.startTimestamp || now
        : now;
    }

    return {
      onFrame: springOnFrame,
      onStart,
      toValue,
      velocity: config.velocity || 0,
      current: toValue,
      startValue: 0,
      callback,
      lastTimestamp: 0,
      startTimestamp: 0,
      zeta: 0,
      omega0: 0,
      omega1: 0,
      reduceMotion: getReduceMotionForAnimation(config.reduceMotion),
    } as SpringAnimation;
  });
}) as withSpringType;

```
</summary>

---------

Co-authored-by: Aleksandra Cynk <aleksandra.cynk@swmansion.com>
Co-authored-by: Aleksandra Cynk <aleksandracynk@Aleksandras-MacBook-Pro-3.local>
Co-authored-by: Krzysztof Piaskowy <krzysztof.piaskowy@swmansion.com>
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.

None yet

5 participants