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

[Android] Scrolling performance degradation in development builds

Open NyoriK opened this issue 10 months ago • 12 comments

Description

When using Reanimated's useAnimatedScrollHandler or useScrollViewOffset with scrollable components (FlatList/ScrollView), there's a significant performance degradation (stuttering) on Android, but only in development builds. The same code works smoothly in:

  • Expo Go on both Android and iOS
  • Development builds on iOS
  • Both simulator and physical Android devices in Expo Go
  • React Native's core Animated API (works smoothly in all builds)

This suggests the issue is specific to Android development builds with Reanimated, as the core Animated API implementation maintains smooth scrolling performance across all build types.

Steps to reproduce

Here's a complete reproduction with all necessary components:

Steps to reproduce:

  1. Create new Expo project:
npx create-expo-app@latest
  1. Install dependencies:
npx expo install expo-dev-client
// ScrollAnimationTest.tsx

export default function ScrollAnimationTest() {
    const scrollX = useSharedValue(0)

    const data = useMemo<ScrollDataType[]>(() => [
        { id: 1, color: '#462255' },
        ...
    ], [])

    const keyExtractor = useCallback((item: ScrollDataType) => item.id.toString(), []);

    const renderItem: ListRenderItem<ScrollDataType> = useCallback(({ item }) => {
        return <ScrollDataItem item={item} />
    }, [])

    const getItemLayout = useCallback((
        _data: ArrayLike<ScrollDataType> | null | undefined,
        index: number
    ) => ({
        length: SCREEN_WIDTH,
        offset: SCREEN_WIDTH * index,
        index,
    }), []);

    const scrollHandler = useAnimatedScrollHandler((event) => {
        scrollX.value = event.contentOffset.x;
    });

    return (
        <View style={styles.container}>
            <Animated.FlatList
                data={data}
                keyExtractor={keyExtractor}
                renderItem={renderItem}
                horizontal
                pagingEnabled
                getItemLayout={getItemLayout}
                contentContainerStyle={styles.listContent}
                onScroll={scrollHandler}
                scrollEventThrottle={16}
            />
            <ScrollIndicatorReanimated data={data} scrollX={scrollX} />
        </View>
    )
}

// ScrollIndicatorReanimated.tsx

function ScrollIndicatorReanimated({ data, scrollX }: ScrollIndicatorReanimatedProps) {
    const dots = useMemo(() => {
        return data.map((item, index) => (
            <ScrollDotReanimated
                key={item.id}
                index={index}
                scrollX={scrollX}
            />
        ))
    }, [data, scrollX])

    return (
        <View style={styles.container}>
            {dots}
        </View>
    )
}

export default memo(ScrollIndicatorReanimated)

// ScrollDotReanimated.tsx

const SIZE = 16;

const ScrollDotReanimated = memo(({ index, scrollX }: ScrollDotReanimatedProps) => {
    const inputRange = useMemo(() => [
        (index - 1) * SCREEN_WIDTH,
        index * SCREEN_WIDTH,
        (index + 1) * SCREEN_WIDTH,
    ], [index]);

    const animatedStyles = useAnimatedStyle(() => {
        return {
            opacity: interpolate(
                scrollX.value,
                inputRange,
                [0.5, 1, 0.5],
                Extrapolation.CLAMP
            ),
            transform: [{
                scale: interpolate(
                    scrollX.value,
                    inputRange,
                    [0.5, 1, 0.5],
                    Extrapolation.CLAMP
                ),
            }]
        };
    });

    return (
        <Animated.View
            style={[
                styles.dot,
                animatedStyles
            ]}
        />
    )
})

export default ScrollDotReanimated

Using Core Animated API (smooth on Android and Ios):

// ScrollAnimationTest.tsx

import { View, Animated as CoreAnimated, StyleSheet, ListRenderItem, Dimensions } from 'react-native'

export default function ScrollAnimationTest() {
    const scrollX = useMemo(() => new CoreAnimated.Value(0), []);

    const data = useMemo<ScrollDataType[]>(() => [
        { id: 1, color: '#462255' },
        ...
    ], [])

    const keyExtractor = useCallback((item: ScrollDataType) => item.id.toString(), []);

    const renderItem: ListRenderItem<ScrollDataType> = useCallback(({ item }) => {
        return <ScrollDataItem item={item} />
    }, [])

    const scrollHandler = useCallback(
        CoreAnimated.event(
            [{ nativeEvent: { contentOffset: { x: scrollX } } }],
            { useNativeDriver: true }
        ),
        [scrollX]
    );

    return (
        <View style={styles.container}>
            <CoreAnimated.FlatList
                data={data}
                keyExtractor={keyExtractor}
                renderItem={renderItem}
                horizontal
                pagingEnabled
                getItemLayout={getItemLayout}
                contentContainerStyle={styles.listContent}
                onScroll={scrollHandler}
                scrollEventThrottle={16}
            />
            <ScrollIndicatorCoreAnimated data={data} scrollX={scrollX} />
        </View>
    )
}


// ScrollIndicatorCoreAnimated.tsx

function ScrollIndicatorCoreAnimated({ data, scrollX }: ScrollIndicatorCoreAnimatedProps) {
    const dots = useMemo(() => {
        return data.map((item, index) => (
            <ScrollDotCoreAnimated
                key={item.id}
                index={index}
                scrollX={scrollX}
            />
        ))
    }, [data, scrollX])

    return (
        <View style={styles.container}>
            {dots}
        </View>
    )
}

export default memo(ScrollIndicatorCoreAnimated)

// ScrollDotCoreAnimated.tsx

const ScrollDotCoreAnimated = memo(({ index, scrollX }: ScrollDotCoreAnimatedType) => {
    const inputRange = useMemo(() => [
        (index - 1) * SCREEN_WIDTH,
        index * SCREEN_WIDTH,
        (index + 1) * SCREEN_WIDTH,
    ], [index]);

    const opacity = scrollX.interpolate({
        inputRange,
        outputRange: [0.5, 1, 0.5],
        extrapolate: 'clamp'
    });

    const scale = scrollX.interpolate({
        inputRange,
        outputRange: [0.5, 1, 0.5],
        extrapolate: 'clamp'
    });

    return (
        <Animated.View
            style={[
                styles.dot,
                {
                    opacity,
                    transform: [{ scale }]
                }
            ]}
        />
    )
})

export default ScrollDotCoreAnimated

Snack or a link to a repository

https://github.com/NyoriK/scroll-test-app

Reanimated version

3.16.1

React Native version

0.76.7

Platforms

Android

JavaScript runtime

Hermes

Workflow

Expo

Architecture

New Architecture

Build type

Expo Development build

Device

Real device

Device model

Redmi Note 10 pro

Acknowledgements

Yes

NyoriK avatar Feb 16 '25 14:02 NyoriK

I just encounter the same issue on Android and I switch to reanimatedV4 on beta version seem to be ok I am testing on new arch and. react native 0.77.1

Updated

I disabled new arch and it work fine now

chanphiromsok avatar Feb 19 '25 01:02 chanphiromsok

Hey guys!

We are aware of the issue. It affects only the New Architecture so that's why switching back to the Old Architecture results in better scrolling performance. We are currently working on a fix for this problem and I hope we figure something out in a near future.

MatiPl01 avatar Feb 20 '25 17:02 MatiPl01

I can confirm this behaviour happen only new arch (DEBUG AND RELASE)

chanphiromsok avatar Feb 21 '25 04:02 chanphiromsok

@MatiPl01 Thanks for the team's investigation on this. I want to add that what makes this particularly interesting is the performance difference between Expo Go and development builds. The same code runs smoothly in Expo Go on both Android and iOS, despite Expo Go typically having more overhead than development builds.

This behavior pattern (working in Expo Go but not in dev builds) might offer additional clues about where in the New Architecture integration the performance degradation is occurring. Could this suggest something specific about how Reanimated is being initialized or integrated in development builds vs Expo Go?

NyoriK avatar Feb 22 '25 12:02 NyoriK

@NyoriK

The same code runs smoothly in Expo Go on both Android and iOS, despite Expo Go typically having more overhead than development builds.

Interesting. I don't think there should be any difference in Expo Go builds. It is a bit strange to me. Maybe you ran Expo Go project with an Old Architecture? It shouldn't run faster on New Arch just because of using Expo Go.

MatiPl01 avatar Feb 25 '25 17:02 MatiPl01

@MatiPl01 Hello this mention might disturb you but I have try on new arch release on reanimated latest version it still the same

https://github.com/user-attachments/assets/d3e7984b-7120-4976-b6c9-69dbb97a2bc9

chanphiromsok avatar Apr 18 '25 04:04 chanphiromsok

@chanphiromsok

Yeah, I know. We haven't released a fix yet. We started rewriting the core logic in reanimated which we hope to improve performance (at least a bit) and fix some issues we are dealing with now. This will likely land in reanimated 4 in the next months, so you have to be patient and wait for the new version.

I can't promise how much faster reanimated (and, especially scrollTo) will become because we are in the very early stage of this rewrite but we will see once we manage to finish the new implementation.

MatiPl01 avatar Apr 18 '25 09:04 MatiPl01

@chanphiromsok

Yeah, I know. We haven't released a fix yet. We started rewriting the core logic in reanimated which we hope to improve performance (at least a bit) and fix some issues we are dealing with now. This will likely land in reanimated 4 in the next months, so you have to be patient and wait for the new version.

I can't promise how faster reanimated (and, especially scrollTo) will become because we are in the very early stage of this rewrite but we will see once we manage to finish the new implementation.

Thank you for response

chanphiromsok avatar Apr 18 '25 09:04 chanphiromsok

This is the main issue preventing me from updating to the new architecture now. I've tried in expo 52 and now 53. This can be easily reproduced in the react-conf-app using an animated header that animates with the flatlist scrolling. https://github.com/expo/react-conf-app. building a dev client and running on android is almost unusable. Even if it works better in production, there is still a clear difference between old/new arch.

pkfms avatar May 07 '25 21:05 pkfms

I have noticed if I use useAnimatedScrollHandler with onScroll event in a scrollable view, then my defined event fn will fire with every scrollable view in the whole application. I mean ALL scrollable view, even views without onScroll prop defined. Can it be the source of this problem?

Only on android.

My code looks like this

export const useScrollOffset = () => {
   const scrollOffset = useSharedValue(0);

   const scrollHandler = useAnimatedScrollHandler({
       onScroll: ({ contentOffset }) => {
      scrollOffset.value = contentOffset.y; //this will fire in every scrollable view, even without scrollHandler passed as onScroll prop
       },
     })

   return  { scrollHandler, scrollOffset };
};

New architecture enabled. "expo": "53.0.22", "react-native": "0.79.6", "react-native-reanimated": "3.17.4" and "3.19.2"

adammatis avatar Oct 08 '25 11:10 adammatis

I believe this issue can be fixed by upgrading to a nightly version of Reanimated (4.2.0-nightly-) and enabling the USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS static feature flag.

If you're experiencing performance regressions of animations after enabling the New Architecture, please make sure to check out this page we recently added in Reanimated docs:

In the context of this issue, I believe this link should be particularly useful:

tomekzaw avatar Oct 28 '25 11:10 tomekzaw

I believe this issue can be fixed by upgrading to a nightly version of Reanimated (4.2.0-nightly-) and enabling the USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS static feature flag.

If you're experiencing performance regressions of animations after enabling the New Architecture, please make sure to check out this page we recently added in Reanimated docs:

In the context of this issue, I believe this link should be particularly useful:

enabled preventShadowTreeCommitExhaustion is work for me

chanphiromsok avatar Nov 03 '25 01:11 chanphiromsok