2

How can you implement a start reached event for React Native's FlatList component?

FlatList already provides an onEndReached event. Setting the inverted prop to true will trigger such event when the list reaches the top, but you will now be left without any event firing at the bottom.

I am posting this as an already answered question in the hope that it will be useful for the community. See my answer (possibly, others) below.

cristian
  • 866
  • 3
  • 8
  • 26

2 Answers2

2

Solution 1

As mentioned in the question:

FlatList already provides an onEndReached event. Setting the inverted prop to true will trigger such event when the list reaches the top.

If you don't need both top and bottom events, this is the easiest solution to implement.

Solution 2

I have implemented a custom component which provides an onStartReached event, and functions in a similar fashion as the onEndReached event. You can find the code below.
If you think this is useful, glad to help :)

But before you copy-paste the code, please read the following:

  1. As tested for my use-case, works on both iOS and Android
  2. Only works for vertical lists
  3. Follows a similar event signature and configuration as onEndReached and onEndReachedThreshold Please note that the event info contains a distanceFromStart field, as opposed to distanceFromEnd.
  4. The component works by tapping into the onScroll event, and evaluating when a "top reached" condition is met.
    If you provide an onScroll event handler, the scroll event is forwarded to it.
    scrollEventThrottle, by default, is set to 60 FPS (1000/60 = 16.66 ms), but you can override it through props.
  5. Keeps top visible item in position after data change
  • REQUIRES getItemLayout
    scrollToIndex is called for such feature
    Please note that this will interrupt any momentum scrolling
    If items render in under 1 FPS, it works seamlessly while dragging (no jumpy scrolling)
  • The first componentDidUpdate trigger that follows after an onStartReached event, will check for data prop change.
    If there is one, the previous and current list lengths are used to evaluate the index of the top item to scroll to (current - previous).
    To prevent spamming the onStartReached event, no scroll will occur if:
    • the calculated index is 0, or negative (when the update results in less items than before)
    • onStartReached does not result in an immediate data prop change
  1. The component does not evaluate the "top reached" condition on horizontal={true} lists.
  2. It might be possible to implement the same solution for a ScrollView based component. I did not try this. Detecting the "top reached" condition should work the same. To keep the previous scroll position in-place (similar to point 5 above) could be done through scrollToOffset.
  3. NOT tested with RefreshControl and pull-to-refresh functionality
  4. NOT TypeScript ready. I don't use TypeScript, and I didn't spend time on this. The default arguments may help you, though.
import React from "react";
import { FlatList } from "react-native";



// Typing without TypeScript
const LAYOUT_EVENT = {
    nativeEvent: {
        layout: { width: 0, height: 0, x: 0, y: 0 },
    },
    target: 0
};

const SCROLL_EVENT = {
    nativeEvent: {
        contentInset: { bottom: 0, left: 0, right: 0, top: 0 },
        contentOffset: { x: 0, y: 0 },
        contentSize: { height: 0, width: 0 },
        layoutMeasurement: { height: 0, width: 0 },
        zoomScale: 1
    }
};



// onStartReached
const START_REACHED_EVENT = { distanceFromStart: 0 };
const SCROLL_DIRECTION = {
    NONE: 0,
    TOP: -1,
    BOTTOM: 1
};



export default class BidirectionalFlatList extends React.PureComponent {
    constructor(props) {
        super(props);

        this.ref = this.props.__ref || React.createRef();

        this.onLayout = this.onLayout.bind(this);
        this.onScroll = this.onScroll.bind(this);
        this.onResponderEnd = this.onResponderEnd.bind(this);
        this.onStartReached = this.onStartReached.bind(this);
        
        this.previousDistanceFromStart = 0;
        this.allowMoreEvents = true;
        this.shouldScrollAfterOnStartReached = false;

        if (typeof props.getItemLayout !== "function") {
            console.warn("BidirectionalFlatList: getItemLayout was not specified. The list will not be able to scroll to the previously visible item at the top.");
        }
    }

    componentDidUpdate(prevProps, prevState) {
        const { data } = this.props;

        if ((data !== prevProps.data) && (this.shouldScrollAfterOnStartReached === true)) {
            const indexToScrollTo = data.length - prevProps.data.length;

            if (indexToScrollTo > 0) {
                this.ref.current?.scrollToIndex({
                    animated: false,
                    index: indexToScrollTo,
                    viewPosition: 0.0,
                    viewOffset: 0
                });
            }
        }

        this.shouldScrollAfterOnStartReached = false;
    }

    onStartReached(info = START_REACHED_EVENT) {
        if (typeof this.props.onStartReached === "function") {
            this.allowMoreEvents = false;
            this.shouldScrollAfterOnStartReached = true;

            this.props.onStartReached(info);
        }
    }

    onScroll(scrollEvent = SCROLL_EVENT) {
        if (typeof this.props.onScroll === "function") {
            this.props.onScroll(scrollEvent);
        }
        
        // Prevent evaluating this event when the list is horizontal
        if (this.props.horizontal === true) { return; }

        const { nativeEvent: { contentOffset: { y: distanceFromStart } } } = scrollEvent;

        const hasReachedScrollThreshold = (distanceFromStart <= this.scrollThresholdToReach);
        const scrollDirection = ((distanceFromStart - this.previousDistanceFromStart) < 0)
            ? SCROLL_DIRECTION.TOP
            : SCROLL_DIRECTION.BOTTOM;
        
        this.previousDistanceFromStart = distanceFromStart;
        
        if (
            (this.allowMoreEvents === true) &&
            (hasReachedScrollThreshold === true) &&
            (scrollDirection === SCROLL_DIRECTION.TOP)
        ) {
            this.onStartReached({ distanceFromStart });
        }
    }

    onResponderEnd() {
        this.allowMoreEvents = true;

        if (typeof this.props.onResponderEnd === "function") {
            this.props.onResponderEnd();
        }
    }

    onLayout(layoutEvent = LAYOUT_EVENT) {
        const { onStartReachedThreshold = 0.0, onLayout } = this.props;

        if (typeof onLayout === "function") {
            onLayout(layoutEvent);
        }

        this.scrollThresholdToReach = layoutEvent.nativeEvent.layout.height * onStartReachedThreshold;
    }

    render() {
        const {
            __ref = this.ref,
            onLayout = (event = LAYOUT_EVENT) => { },
            onStartReached = (event = START_REACHED_EVENT) => { },
            onStartReachedThreshold = 0.0,
            scrollEventThrottle = 1000 / 60,
            ...FlatListProps
        } = this.props;

        return <FlatList
            ref={__ref}
            {...FlatListProps}
            onLayout={this.onLayout}
            onScroll={this.onScroll}
            scrollEventThrottle={scrollEventThrottle}
            onResponderEnd={this.onResponderEnd}
        />;
    }
}

Example

import React from "react";
import { StyleSheet, Text, View } from "react-native";
import BidirectionalFlatList from "./BidirectionalFlatList";



const COUNT = 10;
const ITEM_LENGTH = 40;

const styles = StyleSheet.create({
    list: { flex: 1 },
    listContentContainer: { flexGrow: 1 },
    item: {
        flexDirection: "row",
        alignItems: "center",
        width: "100%",
        height: ITEM_LENGTH
    }
});

function getItemLayout(data = [], index = 0) {
    return { length: ITEM_LENGTH, offset: ITEM_LENGTH * index, index };
}

function keyExtractor(item = 0, index = 0) {
    return `year_${item}`;
}

function Item({ item = 0, index = 0, separators }) {
    return <View style={styles.item}>
        <Text>{item}</Text>
    </View>;
}

class BidirectionalFlatListExample extends React.PureComponent {
    constructor(props) {
        super(props);

        this.count = COUNT;
        this.endYear = (new Date()).getFullYear();
        this.canLoadMoreYears = true;
        this.onStartReached = this.onStartReached.bind(this);
        this.onEndReached = this.onEndReached.bind(this);
        this.updateYearsList = this.updateYearsList.bind(this);

        const years = (new Array(this.count).fill(0))
            .map((item, index) => (this.endYear - index))
            .reverse();

        this.state = { years };
    }

    onStartReached({ distanceFromStart = 0 }) {
        if (this.canLoadMoreYears === false) { return; }

        this.count += COUNT;
        this.updateYearsList();
    }

    onEndReached({ distanceFromEnd = 0 }) {
        this.endYear += COUNT;
        this.count += COUNT;
        
        this.updateYearsList();
    }

    updateYearsList() {
        this.canLoadMoreYears = false;
        const years = (new Array(this.count).fill(0))
            .map((item, index) => (this.endYear - index))
            .reverse();
        
        this.setState({ years }, () => {
            setTimeout(() => { this.canLoadMoreYears = true; }, 500);
        });
    }

    render() {
        return <BidirectionalFlatList
            style={styles.list}
            contentContainerStyle={styles.listContentContainer}

            data={this.state.years}
            renderItem={Item}
            keyExtractor={keyExtractor}
            getItemLayout={getItemLayout}

            onStartReached={this.onStartReached}
            onStartReachedThreshold={0.2}

            onEndReached={this.onEndReached}
            onEndReachedThreshold={0.2}
        />;
    }
}
cristian
  • 866
  • 3
  • 8
  • 26
0

Let's say we want to build a horizontal Week view, and we need to scroll in both directions. Scrolling to the left is relatively easy because FlatList in react native has an onEndReached event. That means that when the end is reached this triggers this event and we can add the next week to our list of dates which is stored in a state:

setDateList([...dateList, nextWeek]),

The problem is when we must scroll to the left and show the past weeks. First, FlatList doesn't have an onStartReached event. But even if it was such an event how could we add a new week to the start of the list of data and expect to scroll to the left? IDK if I'm clear but to me makes no sense cause we will need at this point to rerender our FlatList and set the new data.

Anyway, there are plenty of solutions. Mine is like this:

We will use date-fns library for dealing with dates: npm install date-fns --save

First, we will create a list of three weeks [last_week, current_week, next_week]:

const d = eachWeekOfInterval(
{
    start: subDays(new Date(), 14),
    end: addDays(new Date(), 14),
},
{
    weekStartsOn: 1,
}).reduce((acc: Date[][], cur) => {

    const allDays = eachDayOfInterval({
    start: cur,
    end: addDays(cur, 6),
});

    acc.push(allDays);
    return ACC;
}, []);

then we will set this list as data to our FlatList

const [canMomentum, setCanMomentum] = useState(false);
const [dateList, setDateList] = useState<Date[][]>(d);

const ref = useRef<FlatList | null>(null);

const onMomentumScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
  if (canMomentum) {
      const index = Math.round(
        event.nativeEvent.contentOffset.x / Layout.window.width
      );

  if (index === 0) {

      const firstDayOfInterval = dateList[0][0];

      const lastDayOfPastWeek = subDays(firstDayOfInterval, 1);

      const firstDayOfPastWeek = startOfWeek(lastDayOfPastWeek, {
          weekStartsOn: 1,
      });

      const pastWeek = setWeekInterval(firstDayOfPastWeek, lastDayOfPastWeek);

      setDateList([pastWeek, ...dateList]);

      ref.current?.scrollToIndex({ animated: false, index: 1 });
  } else if (index === dateList.length - 1) {
      const lastWeekOfInterval = dateList[dateList.length - 1];
      const lastDayOfInterval =
      lastWeekOfInterval[lastWeekOfInterval.length - 1];

      const firstDayOFFutureWeek = addDays(lastDayOfInterval, 1);
      const lastDayOfFutureWeek = endOfWeek(firstDayOFFutureWeek, {
          weekStartsOn: 1,
      });

      const futureWeek = setWeekInterval(
          firstDayOFFutureWeek,
          lastDayOfFutureWeek
      );

      setDateList([...dateList, futureWeek]);
    }
   }
  setCanMomentum(false);
};

const setWeekInterval = (start: Date, end: Date) => {
    return eachDayOfInterval({
        start,
        end,
    });
};

<FlatList
    ref={ref}
    showsHorizontalScrollIndicator={false}
    pagingEnabled
    horizontal
    onScroll={(e) => {
      setCanMomentum(true);
    }}
    initialScrollIndex={1}
    onMomentumScrollEnd={onMomentumScrollEnd}
    data={dateList}
    keyExtractor={(_item: any, index: any) => index}
    renderItem={({ item, index }: { item: any; index: number }) => (
      <TestItem key={index} {...{ item }} />
    )}
  />

Setting initialScrollIndex={1} will initially show the current week in the FlatList

The line: ref.current?.scrollToIndex({ animated: false, index: 1 }); is the key. Once we scroll to the start of the list programmatically we tell the list to scroll to index 1, then add past weeks to the list setDateList([pastWeek, ...dateList]);. In this way, we can scroll bidirectional. The only problem I have noticed is that when scrolling in the past there is a little blink.

Don't forget the ListItem should be wrapped inside a memo to avoid bad performance

interface Props {
    item: Date[];
}

const TestItem: React.FC<Props> = ({ item }) => {
    return (
    <View
        style={{
            width: Layout.window.width,
            alignItems: "center",
            alignSelf: "stretch",
            paddingVertical: 16,
           flexDirection: "row",
        }}
    >
    {item.map((item, index) => (
        <View
             key={index}
             style={{ alignItems: "center", width: Layout.window.width / 7 
    }}>
        <Subtitle1>{format(item, "dd")}</Subtitle1>
        <Subtitle1>{format(item, "EEE")}</Subtitle1>
        <Subtitle1>{format(item, "MMM")}</Subtitle1>
     </View>
     ))}
  </View>
 );
};

export default memo(TestItem);

Hope it helps

Erjon
  • 923
  • 2
  • 7
  • 32
  • Thank you for your solution @Erjon, this helped me a lot. The only thing I don't understand is what that `canMomentum` flag does? I left it out in my adaptation of your solution and all works fine. – sjbuysse Jul 07 '23 at 15:09
  • 1
    the method onMomentumScrollEnd happens to run multiple times, if it does so you will not get the desired result instead you will jump many weeks forward or backward, the canMomentum flag avoid the onMomentumScrollEnd method to run multiple times – Erjon Aug 03 '23 at 09:52