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