skip to Main Content

I have a scrolling view of posts. Each post has a corresponding user and I have a header that shows the user info of the current visible post. With Flutter this was simple, I just wrapped the post widget with a visibility detector. With React Native this is not very easy. I’ve tried onViewableItemsChanged but, since I am using a fuction not a class, that causes an error. I also tried some solutions that used onScroll and onMomentumScrollEnd but those all just stayed at index 0. How can I get the current index that is fully visible? If needed, I am fine with splitting up the pagination functions so I can just have a class with the UI and use onViewableItemsChanged but I don’t know how to do that because the handleLoadMore function is used in the UI.

export default function PostsListView() {
  const [users, setUsers] = useState<User[]>([]);
  const [posts, setPosts] = useState<Post[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(true);
  const [hasMore, setHasMore] = useState(true);

  const onScroll = useCallback((event: any) => {
    const slideSize = event.nativeEvent.layoutMeasurement.width;
    const index = event.nativeEvent.contentOffset.x / slideSize;
    const roundIndex = Math.round(index);
    console.log("roundIndex:", roundIndex);

    currentItem = roundIndex;
  }, []);

  useEffect(() => {
    async function fetchPosts() {
      setLoading(true);
      const { data, error } = await supabase
        .from("posts")
        .select("*")
        .order("date", { ascending: false })
        .range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
      if (error) {
        console.error(error);
        showAlert();
        setLoading(false);
        return;
      }

      const newPosts = data.map((post: any) => new Post(post));
      setPosts((prevPosts) => [...prevPosts, ...newPosts]);
      setLoading(false);
      setHasMore(data.length === PAGE_SIZE);
    }

    async function fetchUsers() {
      const { data, error } = await supabase.from("posts").select("*");
      if (error) {
        showAlert();
        console.error(error);
        return;
      }

      const newUsers = data.map((user: any) => new User(user));
      newUsers.forEach((user) => {
        const userPosts = posts.filter((post) => post.uid === user.uid);
        user.posts = [...user.posts, ...userPosts];
      });
      setUsers((prevUsers) => [...prevUsers, ...newUsers]);
    }

    fetchPosts();
    fetchUsers();
  }, [page]);

  const handleLoadMore = () => {
    if (!loading && hasMore) {
      setPage((prevPage) => prevPage + 1);
    }
  };

  const handleScroll = (event: any) => {
    const index = Math.floor(
      Math.floor(event.nativeEvent.contentOffset.x) /
        Math.floor(event.nativeEvent.layoutMeasurement.width)
    );

    currentItem = index;
  };

  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      {loading ? (
        <Text>Loading...</Text>
      ) : (
        <FlatList
          data={posts}
          horizontal={false}
          directionalLockEnabled={true}
          renderItem={({ item }) => (
            <View>
              <HomePost
                post={item}
                user={
                  users.filter(function (u) {
                    return u.uid == item.uid;
                  })[0]
                }
                index={posts.indexOf(item)}
                loading={loading}
              />
              <SizedBox vertical={5} />
            </View>
          )}
          keyExtractor={(item) => item.postId}
          onEndReached={handleLoadMore}
          onEndReachedThreshold={0.1}
          onScroll={onScroll}
          onMomentumScrollEnd={onScroll}
        />
      )}
    </View>
  );
}

4

Answers


  1. You could get the currently viewable items with onViewableItemsChanged where you should get your information.

    <FlatList
      data={posts}
      horizontal={false}
      directionalLockEnabled={true}
      // this should return an array with following infos
      // [{
      //   item: {key: "key-12"},
      //   key: "key-12",
      //   index: 11,
      //   isViewable: true
      // }]
      onViewableItemsChanged={({changed, viewableItems}) => console.log(changed, viewableItems)}
      ....
    
    Login or Signup to reply.
  2. If you provide a viewabilityConfig to the FlatList, you can use the onViewableItemsChanged event to learn which items are on screen. You just have to make sure that both the viewabilityConfig and onViewableItemsChanged values never change:

    import { useState, useEffect, useRef, useCallback } from 'react';
    import { Text, View, StyleSheet, FlatList, Image } from 'react-native';
    import Constants from 'expo-constants';
    
    // You can import from local files
    import AssetExample from './components/AssetExample';
    
    // or any pure javascript modules available in npm
    import { Card } from 'react-native-paper';
    const API_URL = 'https://random-data-api.com/api/v2/users?size=25';
    export default function App() {
      const [posts, setPosts] = useState([]);
      const [visibleItems, setVisibleItems] = useState([]);
      // wrapped in ref so that re-renders doesnt recreate it
      const viewabilityConfig = useRef({
        minimumViewTime: 100,
        itemVisiblePercentThreshold: '90%',
      }).current;
      // wrapped in useCallback so that re-renders doesnt recreate it
      const onViewableItemsChanged = useCallback(({ viewableItems }) => {
        setVisibleItems(viewableItems.map(({ item }) => item));
      }, []);
      useEffect(() => {
        fetch(API_URL)
          .then((data) => data.json())
          .then(setPosts);
      }, []);
    
      return (
        <View style={styles.container}>
          {visibleItems.length > 0 && (
            <Text>
              Currently visible:{' '}
              {visibleItems
                .map((item) => item.first_name + ' ' + item.last_name)
                .join(', ')}
            </Text>
          )}
          <View style={styles.flatlistContainer}>
            <FlatList
              data={posts}
              renderItem={(props) => <Item {...props} />}
              viewabilityConfig={viewabilityConfig}
              onViewableItemsChanged={onViewableItemsChanged}
            />
          </View>
        </View>
      );
    }
    
    const Item = ({ item }) => {
      return (
        <View style={styles.itemContainer}>
          <Text>
            {item.first_name} {item.last_name}
          </Text>
          <Image
            source={{ uri: item.avatar }}
            style={{ width: 100, height: 100 }}
          />
        </View>
      );
    };
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        justifyContent: 'center',
        paddingTop: Constants.statusBarHeight,
        backgroundColor: '#ecf0f1',
        padding: 8,
      },
      flatlistContainer: {
        width: '100%',
        height: 500,
        backgroundColor: 'lightblue',
      },
      itemContainer: {
        justifyContent: 'center',
        alignItems: 'center',
        margin: 10,
      },
    });
    
    

    Demo

    Login or Signup to reply.
  3. Take a look at the Intersection Observer API documentation which is an implementation you can use to detect when an element is visible on the screen or not.

    Here’s a very simple example where the green div is "observed". Whether it is visible or not is marked in state.

    Here’s a working sandbox

    package.json

    {
      "name": "react18-intersection-observer",
      "version": "1.0.0",
      "description": "Detect visible screen elemenet using intersection observer",
      "keywords": [
        "react",
        "starter"
      ],
      "main": "src/index.js",
      "dependencies": {
        "react": "18.2.0",
        "react-bootstrap": "^2.7.1",
        "react-dom": "18.2.0",
        "react-scripts": "^5.0.1"
      },
      "scripts": {
        "start": "react-scripts start",
        "build": "react-scripts build",
        "test": "react-scripts test --env=jsdom",
        "eject": "react-scripts eject"
      },
      "browserslist": [
        ">0.2%",
        "not dead",
        "not ie <= 11",
        "not op_mini all"
      ],
      "author": "Wesley LeMahieu"
    }
    

    index.js

    import { createRoot } from "react-dom/client";
    
    import App from "./App";
    
    const rootElement = document.getElementById("root");
    const root = createRoot(rootElement);
    
    root.render(<App />);
    

    app.js

    import { useEffect, useRef, useState } from "react";
    
    const App = () => {
      const observer = useRef(null);
      const observerRef = useRef(null);
      const [isVisible, setIsVisible] = useState(false);
    
      const observerCallback = async (e) => {
        if (e.length) {
          setIsVisible(e[0].isIntersecting);
        } else {
          setIsVisible(false);
        }
      };
    
      useEffect(() => {
        if (observerRef.current) {
          if (observer.current) {
            observer.current.disconnect();
            observer.current.observe(observerRef.current);
          } else {
            observer.current = new IntersectionObserver(observerCallback);
            observer.current.observe(observerRef.current);
          }
        }
        return () => observer.current.disconnect();
      }, []);
    
      useEffect(() => {
        if (isVisible) {
          alert("Green box visible");
        } else {
          alert("Grey box visible");
        }
        console.log(isVisible ? "GREEN BOX VISIBLE" : "GREEN BOX NOT VISIBLE");
      }, [isVisible]);
    
      return (
        <div style={{ display: "flex" }}>
          <div
            style={{
              backgroundColor: isVisible ? "green" : "red",
              width: "10%",
              height: "3000px",
            }}
          >
            Visible
          </div>
          <div style={{ width: "90%" }}>
            <div
              style={{ backgroundColor: "grey", opacity: 0.6, height: "1000px" }}
            >
              Other div
            </div>
            <div
              ref={observerRef}
              style={{ backgroundColor: "green", opacity: 0.6, height: "1000px" }}
            >
              Observed Div
            </div>
            <div
              style={{ backgroundColor: "grey", opacity: 0.6, height: "1000px" }}
            >
              Other div
            </div>
          </div>
        </div>
      );
    };
    
    export default App;
    
    Login or Signup to reply.
  4. I’ve gone through and created a supabase app that resembles your use case and the onViewableItemsChanged approach should work:

    import { useContext, useEffect, useState, useRef, useCallback } from 'react';
    import {
      View,
      StyleSheet,
      Dimensions,
      FlatList,
      ActivityIndicator,
    } from 'react-native';
    import { Text } from 'react-native-paper';
    
    import { SessionContext } from '../../Context';
    import { supabase } from '../../initSupabase';
    import PostItem from '../../components/PostItem';
    const { height } = Dimensions.get('screen');
    
    const totalPostsToGet = 2;
    
    type Post = {
      post: {
        text: string;
        media: {
          type: string;
          source: string;
        };
      };
      id: number;
      created_at: string;
      uid: string;
    };
    
    export default function PostScreen(props) {
      const { session } = useContext(SessionContext);
      const [posts, setPosts] = useState<Post[]>([]);
      const [page, setPage] = useState(1);
      const [isLoading, setIsLoading] = useState(false);
      const [hasMore, setHasMore] = useState(true);
      const [visibleItems, setVisibleItems] = useState([]);
      const viewabilityConfig = useRef({
        // minimumViewTime: 100,
        itemVisiblePercentThreshold: 50,
      }).current;
      // wrapped in useCallback so that re-renders doesnt recreate it
      const onViewableItemsChanged = useCallback(({ viewableItems }) => {
        setVisibleItems(viewableItems.map(({ item }) => item));
      }, []);
      const handleLoadMore = () => {
        if (!isLoading && hasMore) {
          setPage((prevPage) => prevPage + 1);
        }
      };
    
      useEffect(() => {
        if (!session) return;
        const fetchLastPost = async () => {
          const { data, error } = await supabase
            .from('posts')
            .select('*')
            .order('created_at')
            .range(0, 1);
          return data[0];
        };
        const fetchPosts = async () => {
          setIsLoading(true);
          const lastPost = await fetchLastPost();
          console.log('last post', lastPost);
          const rangeStart = (page - 1) * totalPostsToGet;
          const { data, error } = await supabase
            .from('posts')
            .select('*')
            .order('created_at', { ascending: false })
            .range(rangeStart, rangeStart + totalPostsToGet - 1);
          if (error) {
            console.log('error retrieving profile data', error);
          }
          if (data) {
            setPosts((prev) => prev.concat(data));
            // I couldnt figure out how PAGE_SIZE could be used to know that the last post was reached
            // so I just grab the  last post and look to see if its id is in current data
            const hasLastPost = Boolean(
              data.find((post) => post.id == lastPost.id)
            );
            setHasMore(!hasLastPost);
          }
          setIsLoading(false);
        };
        fetchPosts();
        // subscribe to database changes
        const subscription = supabase
          .channel(`Posts`)
          .on(
            'postgres_changes',
            {
              event: '*',
              schema: 'public',
              table: 'posts',
              // filter: `id=eq.${session.user.id}`,
            },
            (payload) => {
              setIsLoading(true);
              console.log('Post update');
              setPosts((prev) => {
                // either push new post or update existing one
                const postIndex = prev.findIndex(
                  (post) => post.id == payload.new.id
                );
                if (postIndex < 0) return [...prev, payload.new];
                else {
                  const newPosts = [...prev];
                  newPosts[postIndex] = payload.new;
                  return newPosts;
                }
              });
              setIsLoading(false);
            }
          )
          .subscribe();
        return () => {
          supabase.removeChannel(subscription);
        };
      }, [session, page]);
      return (
        <View style={styles.container}>
          <Text>Currently visible:</Text>
          <View style={{ padding: 5, margin: 5 }}>
            {visibleItems.map((item) => (
              <Text>{item.post.text.substring(0, 30)}</Text>
            ))}
          </View>
    
          <View style={styles.flatlistContainer}>
            {isLoading && <ActivityIndicator />}
            <FlatList
              data={posts}
              keyExtractor={(item: Post) => item.id}
              horizontal={false}
              directionalLockEnabled
              onEndReached={handleLoadMore}
              renderItem={(props) => <PostItem {...props} />}
              viewabilityConfig={viewabilityConfig}
              onViewableItemsChanged={onViewableItemsChanged}
            />
          </View>
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
      },
      flatlistContainer: {
        height: height * 0.45,
      },
    });
    

    Demo

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search