react-native-reanimated icon indicating copy to clipboard operation
react-native-reanimated copied to clipboard

Better error when passing easing from 'react-native' instead of 'reanimated'

Open Latropos opened this issue 2 years ago • 2 comments

Summary

Function withTiming should throw an error when easing is not a worklet (or a bound function run from UI thread). This is a common bug, because it happens when user is mixing imports from reanimated and animated.

Test plan

Before After
The actual names of all easing functions from react-native are all "fun"
image image
timing animation (example from the table) - code
import Animated, {
  useSharedValue,
  withTiming,
  useAnimatedStyle,
  // Easing, <- this should be the correct import
} from 'react-native-reanimated';
import { View, Button, StyleSheet, Easing } from 'react-native';
import React from 'react';

export default function AnimatedStyleUpdateExample() {
  const randomWidth = useSharedValue(10);

  const style = useAnimatedStyle(() => {
    return {
      width: withTiming(randomWidth.value, {
        easing: Easing.linear,
      }),
    };
  });

  return (
    <View style={styles.container}>
      <Animated.View style={[styles.box, style]} />
      <Button
        title="toggle"
        onPress={() => {
          randomWidth.value = Math.random() * 350;
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
  },
  box: {
    width: 100,
    height: 80,
    backgroundColor: 'black',
    margin: 30,
  },
});


Keyframe animation - code

import Animated, { Keyframe } from 'react-native-reanimated';
import { Button, Easing, View, StyleSheet } from 'react-native';
import React, { useState } from 'react';

export default function KeyframeAnimation() {
  const [show, setShow] = useState(false);
  const enteringAnimation = new Keyframe({
    from: {
      originX: 50,
      transform: [{ rotate: '45deg' }, { scale: 0.5 }],
    },
    30: {
      transform: [{ rotate: '-90deg' }, { scale: 2 }],
    },
    50: {
      originX: 70,
    },
    100: {
      originX: 0,
      transform: [{ rotate: '0deg' }, { scale: 1 }],
      easing: Easing.quad,
    },
  })
    .duration(2000)
    .withCallback((finished: boolean) => {
      'worklet';
      if (finished) {
        console.log('callback');
      }
    });
  const exitingAnimation = new Keyframe({
    0: {
      opacity: 1,
      originX: 0,
    },
    30: {
      originX: -50,
      easing: Easing.exp,
    },
    to: {
      opacity: 0,
      originX: 500,
    },
  }).duration(2000);
  return (
    <View style={styles.columnReverse}>
      <Button
        title="animate"
        onPress={() => {
          setShow((last) => !last);
        }}
      />
      <View style={styles.blueBoxContainer}>
        {show && (
          <Animated.View
            entering={enteringAnimation}
            exiting={exitingAnimation}
            style={styles.blueBox}
          />
        )}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  columnReverse: { flexDirection: 'column-reverse' },
  blueBoxContainer: {
    height: 400,
    alignItems: 'center',
    justifyContent: 'center',
  },
  blueBox: {
    height: 100,
    width: 200,
    backgroundColor: 'blue',
    alignItems: 'center',
    justifyContent: 'center',
  },
});
Curved transition animation - code
import Animated, {
  BounceOut,
  CurvedTransition,
  LightSpeedInRight,
} from 'react-native-reanimated';
import {
  Image,
  LayoutChangeEvent,
  Text,
  View,
  StyleSheet,
  Easing,
} from 'react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, TapGestureHandler } from 'react-native-gesture-handler';

const AnimatedImage = Animated.createAnimatedComponent(Image);
type Props = {
  columns: number;
  pokemons: number;
};
function getRandomColor() {
  const randomColor = Math.floor(Math.random() * 16777215).toString(16);
  return randomColor;
}
type PokemonData = {
  ratio: number;
  address: string;
  key: number;
  color: string;
};

export function WaterfallGrid({ columns = 3, pokemons = 100 }: Props) {
  const [poks, setPoks] = useState<Array<PokemonData>>([]);
  const [dims, setDims] = useState({ width: 0, height: 0 });
  const handleOnLayout = useCallback(
    (e: LayoutChangeEvent) => {
      const newLayout = e.nativeEvent.layout;
      if (
        dims.width !== +newLayout.width ||
        dims.height !== +newLayout.height
      ) {
        setDims({ width: newLayout.width, height: newLayout.height });
      }
    },
    [dims, setDims]
  );
  const margin = 10;
  const width = (dims.width - (columns + 1) * margin) / columns;
  useEffect(() => {
    if (dims.width === 0 || dims.height === 0) {
      return;
    }
    const poks: {
      ratio: number;
      address: string;
      key: number;
      color: string;
    }[] = [];

    for (let i = 0; i < pokemons; i++) {
      const ratio = 1 + Math.random();
      poks.push({
        ratio,
        address: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${i}.png`,
        key: i,
        color: `#${getRandomColor()}`,
      });
    }
    setPoks(poks);
  }, [dims, setPoks, pokemons]);
  const layoutTransition = CurvedTransition.delay(1000).easingX(Easing.linear);

  const [cardsMemo, height] = useMemo<[Array<JSX.Element>, number]>(() => {
    if (poks.length === 0) {
      return [[], 0];
    }
    const cardsResult: Array<JSX.Element> = [];
    const heights = new Array(columns).fill(0);
    for (const pok of poks) {
      const cur = Math.floor(Math.random() * (columns - 0.01));
      const pokHeight = width * pok.ratio;
      heights[cur] += pokHeight + margin / 2;
      cardsResult.push(
        <Animated.View
          entering={LightSpeedInRight.delay(cur * 200 * 2).springify()}
          exiting={BounceOut}
          layout={layoutTransition}
          key={pok.address}
          style={[
            {
              width: width,
              height: pokHeight,
              backgroundColor: pok.color,
              left: cur * width + (cur + 1) * margin,
              top: heights[cur] - pokHeight,
            },
            styles.pok,
          ]}>
          <TapGestureHandler
            onHandlerStateChange={() => {
              setPoks(poks.filter((it) => it.key !== pok.key));
            }}>
            <AnimatedImage
              layout={layoutTransition}
              source={{ uri: pok.address }}
              style={{ width: width, height: width }}
            />
          </TapGestureHandler>
        </Animated.View>
      );
    }
    return [cardsResult, Math.max(...heights) + margin / 2];
  }, [poks, columns, layoutTransition, width]);
  return (
    <View onLayout={handleOnLayout} style={styles.flexOne}>
      {cardsMemo.length === 0 && <Text> Loading </Text>}
      {cardsMemo.length !== 0 && (
        <ScrollView>
          <View style={{ height: height }}>{cardsMemo}</View>
        </ScrollView>
      )}
    </View>
  );
}
export default function WaterfallGridExample() {
  return (
    <View style={styles.flexOne}>
      <WaterfallGrid columns={3} pokemons={10} />
    </View>
  );
}

const styles = StyleSheet.create({
  flexOne: {
    flex: 1,
  },
  pok: {
    alignItems: 'center',
    justifyContent: 'center',
    position: 'absolute',
  },
});

Latropos avatar Feb 05 '24 11:02 Latropos

@Latropos Can you check if it works on Web?

tjzel avatar Feb 20 '24 16:02 tjzel

@tjzel I've tested, it works on web

Latropos avatar Feb 21 '24 10:02 Latropos