Search code examples
javascriptreactjsreact-nativescrollview

Prevent React Native Snap Scroll in Flatlist Header


I'm using a question I asked on reddit as a reference. In the thread I was asking how to implement a copy of Spotify's snap scroll. I won't go into detail here, but I want to ask a more technical question about the behavior of FlatList in react native.

What I want to achieve is to have a Flatlist that has a "magnetic scroll" or rather called a "snap scroll" but that has a "fluid"/"normal" scroll in the header (ListHeaderComponent). What I don't understand is why if for example I use the snapToInteravall property this is also applied to the header, honestly it doesn't seem like correct behavior to me. Does anyone have any ideas on possible alternative implementations?

I tried two possible approaches without success: https://snack.expo.dev/@niccolocase/f0c9c3 https://snack.expo.dev/@niccolocase/f0c9c3

EDIT: this is the execution of the implementation of @Niclas Göransson

import React, { useRef, useState, useEffect } from "react";
import { View, Text, FlatList, Dimensions } from "react-native";

const SCREEN_WIDTH = Dimensions.get("window").width;
const SCREEN_HEIGHT = Dimensions.get("window").height;
//const HEADER_HEIGHT = SCREEN_WIDTH * 4;
const ITEM_HEIGHT = SCREEN_HEIGHT;
const ITEM_MARGIN = 10;

const DATA = Array.from({ length: 20 }, (_, index) => ({
  id: index.toString(),
  title: `Item ${index + 1}`,
}));

const App = () => {
  const [HEADER_HEIGHT, setHEADER_HEIGHT] = useState(0);

  const renderItem = ({ item, index }) => (
    <View
      style={{
        height: ITEM_HEIGHT,
        marginBottom: ITEM_MARGIN,
        backgroundColor: "orange",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <Text>{item.title}</Text>
    </View>
  );

  const renderHeader = () => (
    <View
      style={{
        backgroundColor: "blue",
        justifyContent: "center",
        alignItems: "center",
      }}
      onLayout={(event) => {
        setHEADER_HEIGHT(event.nativeEvent.layout.height);
      }}
    >
      {new Array(200).fill(null).map((_, index) => (
        <Text key={index}>{index}</Text>
      ))}
    </View>
  );

  const snapOffsets = DATA.map((x, i) => {
    return i * ITEM_HEIGHT + i * ITEM_MARGIN + HEADER_HEIGHT;
  });

  const [decelerationRate, setDecelerationRate] = useState("normal");

  return (
    <FlatList
      data={DATA}
      renderItem={renderItem}
      keyExtractor={(item) => item.id.toString()}
      ListHeaderComponent={renderHeader}
      snapToStart={false}
      snapToEnd={false}
      snapToOffsets={snapOffsets}
      decelerationRate={decelerationRate}
      onScroll={(e) => {
        if (e.nativeEvent.contentOffset.y >= HEADER_HEIGHT) {
          setDecelerationRate(0);
        } else {
          setDecelerationRate("normal");
        }
      }}
    />
  );
};

export default App;

Solution

  • I think the secret lies with snapToOffsets. By disabling snapToStart and snapToEnd you can specify your own snappoints and thereby skip the header.

    This however is based on you knowing both header and list item height beforehand. Also seperator height if you use that.

    const WINDOW_HEIGHT = Dimensions.get('window').height
    
    const snapOffsets = flatlistData.map((x, i) => {
      return (((i * ITEM_HEIGHT) + (i * ITEM_SEPERATOR_HEIGHT)) + HEADER_HEIGHT)
    })
    
    <Flatlist
      data={flatlistData}
      snapToStart={false}
      snapToEnd={false}
      snapToOffsets={snapOffsets}
    />
    

    EDIT: Workaround to prevent scrolling past first item. Limits Flatlist height until scroll position is at first item after header.

    Note that this DOES NOT work if you have other views and such besides the flatlist due to using window height.

    const [tempListHeight, setTempListHeight] = useState(WINDOW_HEIGHT + HEADER_HEIGHT - StatusBar.currentHeight)
    
    const handleScroll = (e) => {
        const yScrolled = e.nativeEvent.contentOffset.y
    
        if (yScrolled >= HEADER_HEIGHT) {
          setTempListHeight((HEADER_HEIGHT - StatusBar.currentHeight) + ITEM_HEIGHT*data.length)
          setDecelerationRate(0)
        } else {
          setTempListHeight(WINDOW_HEIGHT + HEADER_HEIGHT - StatusBar.currentHeight)
          setDecelerationRate("normal")
        }
      }
    
    <Flatlist
      onScroll={(e) => handleScroll(e)}
      contentContainerStyle={{height: tempListHeight}}
    />