Skip to content

Conversation

@Latropos
Copy link
Contributor

@Latropos Latropos commented Feb 5, 2024

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 Latropos requested a review from tomekzaw February 5, 2024 11:43
@Latropos Latropos marked this pull request as ready for review February 5, 2024 12:42
@Latropos Latropos requested a review from piaskowyk February 5, 2024 14:59
Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also add a check if this is a development build? We shouldn't be checking this in production I think.

Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also add some test plan in the PR description (with before/after screenshots maybe?).

@Latropos Latropos requested a review from tjzel February 8, 2024 14:21
Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just make the comment more accurate and we are good to go!

Copy link
Member

@tomekzaw tomekzaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, left some questions in the comments.

Also, shouldn't we also perform the same check here:

If yes, it probably makes sense to move it to a utility function assertEasingIsWorklet and call it from all these places.

@tjzel
Copy link
Collaborator

tjzel commented Feb 20, 2024

@Latropos Can you check if it works on Web?

@Latropos
Copy link
Contributor Author

@tjzel I've tested, it works on web

@Latropos Latropos requested a review from tomekzaw March 5, 2024 11:33
@Latropos Latropos requested a review from tomekzaw March 8, 2024 13:14
Copy link
Member

@tomekzaw tomekzaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, let's just slightly improve the error message.

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.

4 participants