react-nativescrollreact-native-flatlistbidirectional

React Native bidirectional FlatList, start reached event


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.


Solution

  • 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
    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}
            />;
        }
    }