React-native-reanimated: Layout animations performing bad on Android

Created on 2 Dec 2020  路  11Comments  路  Source: software-mansion/react-native-reanimated

Description

I've created a transition which extends the width property of a search bar to smoothly push buttons next to the search bar away. The goal is to make a unified search bar which gets used across horizontal tabs, where the user can use swipe gestures to navigate between.

I tried to only use transform translations instead of the width, but that would've not been so easily possible because the Search Bar actually resizes and gets wider. I'd have to recalculate borderRadius proportional to the scale if I'm going to animate scaleX etc.

So animating width looks fine on iOS, even on lower end devices (iPhone 6).

Here's a demo:

iOS

ezgif com-video-to-gif

Android

ezgif com-video-to-gif-2

Of course you can't really see it in a 25 FPS GIF, but on a real device it looks pretty stuttery on Android, even on newer flagship models.

Steps To Reproduce

  1. Create animation that adjusts a layout property (e.g. width) in the flexbox system which moves something out of the way (e.g. the buttons to the right)
  2. Run that "animation"/transition on drag, so interpolated from translateX in my case
  3. Compare iOS vs Android performance

Expected behavior

I expect the width transition to run somewhat smoothly

Actual behavior

It runs at very low FPS and has weird stuttering effects which looks like it's jumping around.

Snack or minimal code example


Animated Style

  const searchBarStyle = useAnimatedStyle(() => {
    const index = translateX.value / -SCREEN_WIDTH;
    const width = interpolate(
      index,
      [0, 1, 2],
      [
        // those are just sample values, assuming that one button has 20px width
        180, // 1 button
        200, // 0 buttons
        160, // 2 buttons
      ]
    );

    return { width };
  }, []);


JSX View

      <Reanimated.View style={[styles.searchBar, searchBarStyle]}>
        <HeaderSearchBarContainer style={styles.fill} />
      </Reanimated.View>


      {visibleButtons === "chats" && (
        <>
          <InboxButton
            style={styles.button}
          />
          <NewChatButton
            style={styles.button}
          />
        </>
      )}
      {visibleButtons === "camera" && (
        <AddEventButton
          style={styles.button}
        />
      )}

Note: I am actually unmounting the buttons (see visibleButtons state) depending on the Swiper's position. That's done with a useAnimatedReaction and only triggers state updates once per page switching, so that can't be the source of the problem.

Package versions

  • React: 16.13.1
  • React Native: 0.63.3
  • React Native Reanimated: 2.0.0-rc.0
  • NodeJS: v14.15.1
馃彔 Reanimated2 馃悶 Bug

All 11 comments

Issue validator - update # 1

Hello!
Congratulations! Your issue passed the validator! Thank you!

@mrousavy I see in notes that you use useAnimatedReaction. Can it be related to infinite evaluation of useAnimatedReaction?

I have a bug that useAnimatedReaction infinitely is called while I was expected it will be called only if shared value is updated.
https://github.com/breeffy/react-native-bottom-sheet in example/src/screens/DynamicExamples.tsx shows that clearly.

If I watch animatedPosition using useDerivedValue I see it is finite:

useDerivedValue(() => {
  console.log(
    `animatedPosition: ${animatedPosition.value}, animatedPositionIndex: ${animatedPositionIndex.value}`
  );
  return animatedPosition.value;
}, []);

but useAnimatedReaction is called infinitely:

    useAnimatedReaction(
      () => (_animatedPosition !== undefined ? animatedPosition.value : null),
      (value: number | null) => {
        if (value !== null) {
          _animatedPosition!.value = value;
        }
      },
      []
    );

_animatedPosition is a shared value passed to component as a property.

@likern I believe that's happening because you're modifying the value you're reading. Essentially your animated reaction re-executes when any of the dependencies change, and the dependencies are _animatedPosition and animatedPosition. Once the reaction is re-evaluated, you assign _animatedPosition, causing it to re-evaluate the reaction again, and so on.
Either way, useAnimatedReaction seems like it's missing a shallow-equality check for the dependencies in the animated reaction.

But in my case this is not the problem, the problem is that animating the width property performs pretty bad. I know it doesn't have the same performance as some transform property (e.g.: translate, scale), but it shouldn't be this laggy.

Issue validator - update # 1

Hello!
Congratulations! Your issue passed the validator! Thank you!

@likern I see yet another problem with the code you pasted. @mrousavy is right about the infinite loop but also I found that ternary in the first worklet a bit weird:

() => (_animatedPosition !== undefined ? animatedPosition.value : null),

How come _animatedPosition could be undefined here? That would indicate there was a conditional hook call, that shouldn't occur.

@mrousavy As for the issue itself: I wasn't able to reproduce this. I'm pasting my code below. No dropped frames/FPS problems, nothing. Everything works smoothly. It's hard to say what could be the cause of this problem with such limited code you provided.


my code

import Animated, {
  useSharedValue,
  withTiming,
  useAnimatedStyle,
  useAnimatedGestureHandler,
  interpolate,
} from 'react-native-reanimated';
import { View, Button, StyleSheet } from 'react-native';
import React from 'react';
import { PanGestureHandler } from 'react-native-gesture-handler';

export default function App() {
  const width = useSharedValue(1);

  const uas = useAnimatedStyle(() => {
    const x = interpolate(width.value, [1, 2], [100, 300]);

    console.log('here', x);

    return {
      width: x,
    };
  });

  const handler = useAnimatedGestureHandler({
    onEnd: (e, ctx) => {
      let target = width.value;
      if (e.velocityX > 200) {
        target = 2;
      } else if (e.velocityX < -200) {
        target = 1;
      }
      if (width.value !== target) width.value = withTiming(target);
    },
  });

  return (
    <View>
      {/* }
      <Button
        title="move"
        onPress={() => {
          width.value = width.value === 100 ? 350 : 100;
        }}
      />
      { */}
      <View style={{ flex: 1, flexDirection: 'row', width: 200 }}>
        <PanGestureHandler onGestureEvent={handler}>
          <Animated.View
            style={[
              styles.item,
              {
                backgroundColor: 'powderblue',
              },
              uas,
            ]}
          />
        </PanGestureHandler>
        <View style={[styles.item, { backgroundColor: 'skyblue' }]} />
        <View style={[styles.item, { backgroundColor: 'steelblue' }]} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  item: {
    width: 50,
    height: 100,
    margin: 5,
  },
});

@gorhom @karol-bisztyga Thank you.

This is s bug in v3 bottomsheet.

@mrousavy any update on this? Could you please come up with a reproducible example for this one?

@karol-bisztyga sorry, really busy with personal stuff atm. I'll create a repro for this as soon as I can, hopefully this week!

@mrousavy sure, no problem, I understand, thanks!

i think #1610 related to this too

Doesn't seem like it is, but I'll check once it's resolved and there will be a repro.

Was this page helpful?
0 / 5 - 0 ratings