nandorojo / moti

🐼 The React Native (+ Web) animation library, powered by Reanimated 3.
https://moti.fyi
MIT License
4.07k stars 130 forks source link

Dynamic `from` value doesn't update after state change in MotiView #356

Open caticodev opened 3 months ago

caticodev commented 3 months ago

Is there an existing issue for this?

Do you want this issue prioritized?

Current Behavior

The value used in the from property of the MotiView animation does not update dynamically after the corresponding state variable is updated. As a result, the initial value remains unchanged throughout the animation despite the state update.

Expected Behavior

The value in the from property should update to the new state value after the state change. The animation should then use this updated value for subsequent animations.

Steps To Reproduce

  1. Create a React Native component using moti.
  2. Initialize a state variable and set it to an initial value.
  3. Use the state variable in the from property of a MotiView animation.
  4. Add a handler that updates the state variable to a new value after the first animation completes.
  5. Observe that the value used in the from property does not change to the new state value after the state update.

Versions

- Moti: 0.29.0
- Reanimated: 3.10.1
- React Native: 0.74.2

Screenshots

No response

Reproduction

https://stackblitz.com/edit/nextjs-czkf4w?file=pages%2Findex.tsx

nandorojo commented 3 months ago

This is the entire purpose of the from prop. It’s the initial state. If you want to make reactive changes, you can pass them to them animate prop and they will update

nandorojo commented 3 months ago

I can see the case you want it for is for repeating animations. Perhaps you could instead try a sequence inside of animate rather than from?

Alternatively you could change the key…though I know this is unlikely to yield the behavior you want

caticodev commented 3 months ago

@nandorojo I see. I have tried the sequence animation, but I also need to know when the animation has ended to trigger the change of from value and I haven't figured out how to listen to sequence end. The onDidAnimate is triggered by every partial animation in the sequence and all the values returned by onDidAnimate are same for every step in the sequence.

<MotiView
      animate={{
        translateY: [from, 0, from]
      }}
      transition={{
        type: 'timing',
        duration: 1000,
        repeat: 1,
        repeatReverse: true
      }}
      onDidAnimate={(_a, _b, val, other) => {
        // how to know when the sequence ended?
      }}
      style={styles.shape}
    />
nandorojo commented 3 months ago

you can pass objects to the sequence values, each of which can receive its own callback

nandorojo commented 3 months ago

(i think…)

one argument of onDidAnimate should also include attemptedSequenceValue

caticodev commented 3 months ago

one argument of onDidAnimate should also include attemptedSequenceValue

I tried to check the attemptedSequenceValue on the combined onDidAnimate like this:

<MotiView
      animate={{
        translateY: [from, 0, from]
      }}
      transition={{
        type: 'timing',
        duration: 1000,
        repeat: 1,
        repeatReverse: true
      }}
      onDidAnimate={(prop, finished, value, events) => {
        console.log({ prop, finished, value, events })
      }}
      style={styles.shape}
    />

but onDidAnimate fires 3 times in this case and this is the log:

Screenshot 2024-08-05 at 17 13 12

since both first and third log have attempted sequence value 100, it's not possible (just from the log data themselves) to know when the sequence has ended

you can pass objects to the sequence values, each of which can receive its own callback

Thanks, didn't know that. This seems to work in the moti stackblitz template correctly and the onDidAnimate fires only once after the third step on the animation sequence is finished.

<MotiView
      animate={{
        translateY: [
          0,
          val,
          {
            value: 0,
            type: "timing",
            onDidAnimate: (finished, val, events) => {
              console.log({ finished, val, events });
              if (finished) setValue(-100);
            },
          },
        ],
      }}
      transition={{
        type: "timing",
        duration: 1000,
        repeat: 1,
        repeatReverse: true,
      }}
      style={styles.shape}
    />

However, when I'm trying the same code in expo onDidAnimate fires multiple times during the animation (it seems like it fires at the init and then after every step, so 4 times in total):

Here's the repo testing the same code in expo, doesn't seem to run in stackblitz correctly unfortunately

nandorojo commented 3 months ago

editing repeat animations is always a tricky thing, since the component has to retain state across renders. is it possible to set key={val} to satisfy your use case?

caticodev commented 3 months ago

Adding key={val} on MotiView doesn't seem to make a difference. The animation works correctly when written in reanimated directly.

nandorojo commented 3 months ago

Got it. I do wonder if perhaps reanimated is the right candidate for this one. Unless there’s a repro that works in reanimated and not moti

caticodev commented 3 months ago

There is a repro that works in reanimated and not in moti.

This works as expected in reanimated:

const [val, setValue] = useState(100);
const translateY = useSharedValue(0);

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ translateY: translateY.value }],
}));

useEffect(() => {
  translateY.value = withRepeat(
    withTiming(val, { duration: 1000 }),
    2,
    true,
    () => {
      runOnJS(setValue)(-100);
    }
  );
}, [val]);

return <Animated.View style={[styles.shape, animatedStyle]} />;

and this would be the same code in Moti (based on your suggestion to create a sequence and put onDidAnimate callback at the end of the sequence):

const [val, setValue] = useState(100);

return (
  <MotiView
    animate={{
      translateY: [
        0,
        val,
        {
          value: 0,
          type: "timing",
          onDidAnimate: () => {
            setValue(-100);
          },
        },
      ],
    }}
    transition={{
      type: "timing",
      duration: 1000,
    }}
    style={styles.shape}
  />

I believe it comes down to moti lacking more granular onAnimationEnd callbacks that are available in reanimated - in this case there's no "on repeat end" callback, that would tell me with certainty when the full animation is completed. And the "on sequence step end" callback doesn't seem to work properly in expo, since it fires multiple times instead of firing only once after the specific sequence step has ended.

I've updated the examples in the repo with both of these.

nandorojo commented 3 months ago

you’re correct, there’s no onRepeatEnd callback. that’s an interesting case I hadn’t come across. I wonder what the best API for this would be…

nandorojo commented 3 months ago

And the "on sequence step end" callback doesn't seem to work properly in expo, since it fires multiple times instead of firing only once after the specific sequence step has ended.

I assume reanimated has the same issue if you add a callback on the last item? assuming you used a sequence for the repro and not a basic withRepeat

caticodev commented 3 months ago

I assume reanimated has the same issue if you add a callback on the last item? assuming you used a sequence for the repro and not a basic withRepeat

Even when using sequence in reanimated and putting the callback at last item of the sequence, the code still works as expected and the callback fires as expected only after the last step of the sequence is completed.

 const [val, setValue] = useState(100);
  const translateY = useSharedValue(0);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: translateY.value }],
  }));

  useEffect(() => {
    translateY.value = withSequence(
      withTiming(val, { duration: 1000 }),
      withTiming(0, { duration: 1000 }, () => {
        runOnJS(setValue)(-100);
      })
    );
  }, [val]);

  return <Animated.View style={[styles.shape, animatedStyle]} />;

So reanimated doesn't have the same issue as moti.

nandorojo commented 3 months ago

for a true repro you can drop the shared value and directly set the style in useAnimatedStyle. this is what moti does

caticodev commented 3 months ago

for a true repro you can drop the shared value and directly set the style in useAnimatedStyle. this is what moti does

I'm assuming you mean like this:

const [val, setValue] = useState(100);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateY: withSequence(
          withTiming(0, { duration: 0 }),
          withTiming(val, { duration: 1000 }),
          withTiming(0, { duration: 1000 }, () => {
            runOnJS(setValue)(-100);
          })
        ),
      },
    ],
  }));

  return <Animated.View style={[styles.shape, animatedStyle]} />;

Still works correctly in reanimated.

nandorojo commented 3 months ago

got it, so it sounds like identified bug is that onDidAnimate for sequence items fires multiple times, but only on native

caticodev commented 3 months ago

correct, the bug happens only on expo I updated the repo with the lastest repros in case you need it

nandorojo commented 2 months ago

I think this is only for transforms, and I think it's because it may be firing for both transform as well as the nested value. Have to look into that more.