Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: stop ongoing momentum when calling scrollTo (ANDROID) #5286

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

Bowlerr
Copy link

@Bowlerr Bowlerr commented Oct 20, 2023

Summary

This PR addresses an issue where the scroll momentum in ReactScrollView and ReactHorizontalScrollView was not being interrupted when a new scroll action was initiated using ScrollTo. This often resulted in janky user experiences, especially when quick consecutive scrolls were performed.

By introducing a small scroll action before the actual scroll, we can effectively stop any ongoing momentum, ensuring smoother transitions between scroll actions.

Test plan

To test this change:

Clone the repository and check out the branch with the changes.
Navigate to a screen in your React Native app that uses either ReactScrollView or ReactHorizontalScrollView.
Perform a fling action to initiate scroll momentum.
Before the momentum stops, try to programmatically scroll the view using the scrollTo method.
Observe that the previous momentum is stopped and the view scrolls to the desired position smoothly.

Expected behavior:
The scroll view should immediately interrupt its current momentum and smoothly scroll to the specified position.

Before:
Before YT link

After:
After YT link

Example Source Code:

import React, { memo } from 'react';
import { Dimensions, Platform, type ListRenderItem } from 'react-native';
import Animated, {
    useAnimatedScrollHandler,
    useAnimatedRef,
    useSharedValue,
    scrollTo,
    Layout,
    useAnimatedReaction,
    interpolate,
    Extrapolation,
    Easing,
} from 'react-native-reanimated';

import type { FlatList } from 'react-native-gesture-handler';

const { width } = Dimensions.get('screen');
/**
 * Props for the FlatList component.
 */
type FlatListProps<T> = {
    keyExtractor: (item: T, index: number) => string;
    renderItem: ListRenderItem<any> | null | undefined;
};

/**
 * Props for the DualFlatListScroll component.
 */
type DualFlatListScrollProps<T> = {
    /**
     * Data for the top and bottom FlatLists.
     */

    topTrackData: T[];
    bottomTrackData: T[];

    /**
     * Component to wrap each FlatList.
     */
    trackWrapper: React.ComponentType<{
        children?: React.ReactNode;
    }>;
} & FlatListProps<T>;

/**
 * A component that displays two FlatLists and keeps them scrolled at the same position.
 */
const DualFlatListScroll = ({
    topTrackData,
    bottomTrackData,
    trackWrapper: TrackWrapper = React.Fragment as React.ComponentType<{
        children?: React.ReactNode;
    }>,
    keyExtractor,
    renderItem,
}: DualFlatListScrollProps<any>) => {
    // Refs for the FlatList components
    const topTrackRef = useAnimatedRef<FlatList>();
    const bottomTrackRef = useAnimatedRef<FlatList>();
    const scrollTopMax = useSharedValue(0);
    const scrollBottomMax = useSharedValue(0);

    const scrollTop = useSharedValue(0);
    const scrollBottom = useSharedValue(0);
    const isScrollingTop = useSharedValue(false);
    const isScrollingBottom = useSharedValue(false);

    const scrollTopHandler = useAnimatedScrollHandler({
        onScroll: (event) => {
            // Update scroll position

            scrollTop.value = event.contentOffset.x;
        },
        onBeginDrag: () => {
            if (Platform.OS === 'android') isScrollingBottom.value = false;
            isScrollingTop.value = true;
        },
        onMomentumEnd: () => {
            if (Platform.OS !== 'android') isScrollingTop.value = false;
        },
    });
    const scrollBottomHandler = useAnimatedScrollHandler({
        onScroll: (event) => {
            // Update scroll position
            scrollBottom.value = event.contentOffset.x;
        },
        onBeginDrag: () => {
            if (Platform.OS === 'android') isScrollingTop.value = false;

            isScrollingBottom.value = true;
        },
        onMomentumEnd: () => {
            if (Platform.OS !== 'android') isScrollingBottom.value = false;
        },
    });

    useAnimatedReaction(
        () => scrollTop.value,
        (currentValue, previousValue) => {
            if (currentValue !== previousValue && isScrollingTop.value) {
                const maxOffset = scrollBottomMax.value - width;
                const maxTopOffset = scrollTopMax.value - width;
                const xPos = interpolate(
                    scrollTop.value,
                    [0, maxTopOffset],
                    [0, maxOffset],
                    {
                        extrapolateRight: Extrapolation.CLAMP,
                    }
                );

                scrollTo(bottomTrackRef, xPos, 0, false);
            }
        }
    );
    useAnimatedReaction(
        () => scrollBottom.value,
        (currentValue, previousValue) => {
            if (currentValue !== previousValue && isScrollingBottom.value) {
                const maxOffset = scrollBottomMax.value - width;
                const maxTopOffset = scrollTopMax.value - width;
                const xPos = interpolate(
                    scrollBottom.value,
                    [0, maxOffset],
                    [0, maxTopOffset],
                    {
                        extrapolateRight: Extrapolation.CLAMP,
                    }
                );

                scrollTo(topTrackRef, xPos, 0, false);
            }
        }
    );

    return (
        <>
            <TrackWrapper>
                <Animated.FlatList
                    onContentSizeChange={(contentWidth) => {
                        scrollTopMax.value = contentWidth;
                    }}
                    ref={topTrackRef}
                    data={topTrackData}
                    keyExtractor={keyExtractor}
                    renderItem={renderItem}
                    scrollEventThrottle={16}
                    onScroll={scrollTopHandler}
                    horizontal
                    onMomentumScrollEnd={() => {
                        consoleLogger('----ENDDDDDD');
                    }}
                    itemLayoutAnimation={Layout.easing(Easing.elastic(0.2))}
                    showsVerticalScrollIndicator={false}
                    showsHorizontalScrollIndicator={false}
                    initialNumToRender={8}
                />
            </TrackWrapper>
           
            <TrackWrapper>
                <Animated.FlatList
                    onContentSizeChange={(contentWidth) => {
                        scrollBottomMax.value = contentWidth;
                    }}
                    ref={bottomTrackRef}
                    data={bottomTrackData}
                    keyExtractor={keyExtractor}
                    renderItem={renderItem}
                    onMomentumScrollEnd={() => {
                        consoleLogger('----ENDDDDDD');
                    }}
                    scrollEventThrottle={16}
                    itemLayoutAnimation={Layout.easing(Easing.elastic(0.2))}
                    horizontal
                    onScroll={scrollBottomHandler}
                    showsVerticalScrollIndicator={false}
                    showsHorizontalScrollIndicator={false}
                    initialNumToRender={8}
                />
            </TrackWrapper>
        </>
    );
};

const MemoizedDualFlatListScroll = memo(DualFlatListScroll);

export default MemoizedDualFlatListScroll;

import React, { useCallback } from 'react';
import { Text, View, type ListRenderItemInfo } from 'react-native';
import Animated from 'react-native-reanimated';

import MemoizedDualFlatListScroll from '../../../../components/DuelFlatList';

const data1 = [
    'test',
    'test1',
    'test2',
    'test3',
    'test4',
    'test5',
    'test6',
    'test7',
    'test8',
    'test9',
    'test10',
    'test11',
    'test12',
    'test13',
    'test14',
    'test15',
    'test16',
    'test17',
    'test18',
    'test19',
];

const data2 = [
    'testing',
    'testing1',
    'testing2',
    'testing3',
    'testing4',
    'testing5',
    'testing6',
    'testing7',
    'testing8',
    'testing9',
    'testing10',
    'testing11',
    'testing12',
    'testing13',
    'testing14',
    'testing15',
    'testing16',
    'testing17',
    'testing18',
    'testing19',
];
// Your data for the bottom list

const App = () => {
    const renderItem = useCallback(
        ({ item, index, separators }: ListRenderItemInfo<any>) => (
            <Animated.View style={{ padding: 25 }}>
                <Text>{item}</Text>
            </Animated.View>
        ),
        []
    );

    return (
        <View style={{ flex: 1, paddingTop: '50%' }}>
            <MemoizedDualFlatListScroll
                topTrackData={data1}
                bottomTrackData={data2}
                keyExtractor={(item, index) => `${item.id}`}
                renderItem={renderItem}
                trackWrapper={undefined}
            />
        </View>
    );
};

export default App;

@Bowlerr Bowlerr changed the title Fix: stop ongoing momentum when calling scrollTo Fix: stop ongoing momentum when calling scrollTo (ANDROID) Oct 21, 2023
@tomekzaw tomekzaw self-assigned this Oct 22, 2023
@henrymoulton
Copy link

@Bowlerr I noticed this solution is Android only, I've got an iOS issue that might be separate, but just wanted to confirm that you've only seen this on Android?

@Bowlerr
Copy link
Author

Bowlerr commented Oct 25, 2023

@henrymoulton Yeah I've only seen this issue on Android. No issue on iOS to our knowledge.

@henrymoulton
Copy link

Is it necessary for FlatList too?

Trying to pin down the issue I'm having with multiple calls to scrollTo to a FlatList

@Bowlerr
Copy link
Author

Bowlerr commented Oct 25, 2023

@henrymoulton in my video example, I'm using two flatlists so I'm assuming anything on Android that uses scrollTo. Haven't done extensive testing myself.

@tomekzaw
Copy link
Member

Hey @Bowlerr, thanks for submitting this PR. Prior to merging, we would like to test out the PR. Could you please share the source code of the example?

@Bowlerr
Copy link
Author

Bowlerr commented Oct 26, 2023

@Bowlerr I noticed this solution is Android only, I've got an iOS issue that might be separate, but just wanted to confirm that you've only seen this on Android?

So if you look at my example code, it may help you solve your issue @henrymoulton

@Bowlerr
Copy link
Author

Bowlerr commented Oct 26, 2023

@tomekzaw Added some Example code :) let me know if there is any issues

@tomekzaw
Copy link
Member

tomekzaw commented Oct 26, 2023

@Bowlerr Thanks for the source code. I was able to reproduce the issue on the example you have provided. When I scroll one ScrollView and it gains momentum and then scroll the second scroll view, the first ScrollView is not affected until the momentum ends. The code in this PR indeed seems to fix this problem. I appreciate your effort but at this point I'm not 100% convinced if this is the best possible solution. In particular:

  1. Calling .smoothScrollBy(1, 1) looks like a clever trick and interrupting momentum is just one side effect of it. Perhaps it would be better to call .abortAnimation() directly. Good news is that ReactScrollView exposes this method but unfortunately ReactHorizontalScrollView doesn't (edit: actually it was added in Fix scrollview momentum not stopping on scrollTo/scrollToEnd for horizontal scrollviews facebook/react-native#39529 but will be available only since 0.73)
  2. According to suggested change, we would call .smoothScrollBy(1, 1) also in case of animated scroll where we already call .smoothScrollBy(x, y). It looks like this could be redundant in this scenario.
  3. We use view.post() method to enqueue calling .smoothScrollTo(x, y) (only in case of animated scroll), this was introduced by @Latropos in Fix smooth scroll android #4238 to fix another issue. Do you think it's safe to call .smoothScrollTo(1, 1) without .post? Maybe we should move .scrollTo(x, y) into .post as well?

I will try to get answers to these questions on my own so we can polish this PR but obviously your help would be appreciated.

edit: actually I think this problem should be fixed by facebook/react-native#39529 (for horizontal scroll view)

@Bowlerr
Copy link
Author

Bowlerr commented Oct 27, 2023

@tomekzaw Thanks for the feedback!

I introduced two new methods,

  • handleScroll to manage the scrolling process
  • interruptScrollMomentum to halt any ongoing scroll momentum.
  1. Unsure if I went overboard with this but to take advantage of .abortAnimation while keeping the code compatible with older versions of React Native, I used reflection to check if the method exists. If not, I interrupt using .smoothScrollBy(0, 0). (Although this will likely become redundant in 0.73 with the facebook/react-native#39529 fix).
    In my example, I'm calling ScrollTo frequently in a short span, and I wasn't certain about how performance-heavy reflection is, so I added caching for the method to ensure it only runs once.

  2. You're absolutely correct in that assumption. I addressed it in handleScroll.

  3. Thank you for pointing that out. I agree. I've now wrapped handleScroll in a .post to maintain consistency and ensure that all scroll operations occur in the expected order.

Please let me know what you think and if there are any further adjustments needed.

@henrymoulton
Copy link

Hey @Bowlerr I'm thinking of patch-packaging facebook/react-native#39529 into my codebase for now

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.

3 participants