StealThis .dev
组件 简单

React Native Pull to Refresh List

A FlatList with pull-to-refresh functionality, custom refresh indicator, loading states, and empty state placeholder for React Native.

react-native typescript
目标: React Native

Expo Snack

代码

import React, { useState, useCallback } from "react";
import {
  View,
  Text,
  FlatList,
  RefreshControl,
  ActivityIndicator,
  StyleSheet,
  ListRenderItem,
} from "react-native";

// ---------- PullToRefreshList Component ----------

interface PullToRefreshListProps<T> {
  data: T[];
  renderItem: ListRenderItem<T>;
  onRefresh: () => void;
  refreshing: boolean;
  emptyMessage?: string;
  ListHeaderComponent?: React.ReactElement;
  loading?: boolean;
  keyExtractor?: (item: T, index: number) => string;
}

function PullToRefreshList<T>({
  data,
  renderItem,
  onRefresh,
  refreshing,
  emptyMessage = "No items to display",
  ListHeaderComponent,
  loading = false,
  keyExtractor,
}: PullToRefreshListProps<T>) {
  const renderEmpty = () => {
    if (loading) return null;
    return (
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyIcon}>📋</Text>
        <Text style={styles.emptyText}>{emptyMessage}</Text>
      </View>
    );
  };

  const renderFooter = () => {
    if (!loading) return null;
    return (
      <View style={styles.footerContainer}>
        <ActivityIndicator size="small" color="#60a5fa" />
        <Text style={styles.footerText}>Loading more...</Text>
      </View>
    );
  };

  const renderSeparator = () => <View style={styles.separator} />;

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      style={styles.list}
      contentContainerStyle={data.length === 0 ? styles.emptyList : undefined}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={onRefresh}
          tintColor="#60a5fa"
          titleColor="#94a3b8"
          colors={["#60a5fa", "#818cf8"]}
          progressBackgroundColor="#1e293b"
        />
      }
      ListHeaderComponent={ListHeaderComponent}
      ListEmptyComponent={renderEmpty}
      ListFooterComponent={renderFooter}
      ItemSeparatorComponent={renderSeparator}
    />
  );
}

// ---------- Demo App ----------

interface Contact {
  id: string;
  name: string;
  email: string;
}

const CONTACTS: Contact[] = [
  { id: "1", name: "Alice Johnson", email: "[email protected]" },
  { id: "2", name: "Bob Martinez", email: "[email protected]" },
  { id: "3", name: "Carol Chen", email: "[email protected]" },
  { id: "4", name: "David Kim", email: "[email protected]" },
  { id: "5", name: "Eva Rossi", email: "[email protected]" },
  { id: "6", name: "Frank Nguyen", email: "[email protected]" },
  { id: "7", name: "Grace Patel", email: "[email protected]" },
  { id: "8", name: "Henry Larsson", email: "[email protected]" },
  { id: "9", name: "Irene Tanaka", email: "[email protected]" },
  { id: "10", name: "Jack O'Brien", email: "[email protected]" },
  { id: "11", name: "Karen Schmidt", email: "[email protected]" },
  { id: "12", name: "Leo Fernandez", email: "[email protected]" },
  { id: "13", name: "Mia Andersson", email: "[email protected]" },
  { id: "14", name: "Noah Williams", email: "[email protected]" },
  { id: "15", name: "Olivia Brown", email: "[email protected]" },
];

function shuffle<T>(array: T[]): T[] {
  const copy = [...array];
  for (let i = copy.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [copy[i], copy[j]] = [copy[j], copy[i]];
  }
  return copy;
}

function getInitials(name: string): string {
  return name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .toUpperCase()
    .slice(0, 2);
}

const AVATAR_COLORS = [
  "#ef4444",
  "#f97316",
  "#eab308",
  "#22c55e",
  "#06b6d4",
  "#3b82f6",
  "#8b5cf6",
  "#ec4899",
  "#14b8a6",
  "#f43f5e",
  "#a855f7",
  "#6366f1",
  "#0ea5e9",
  "#84cc16",
  "#d946ef",
];

const renderContact: ListRenderItem<Contact> = ({ item, index }) => {
  const color = AVATAR_COLORS[index % AVATAR_COLORS.length];
  return (
    <View style={styles.contactRow}>
      <View style={[styles.avatar, { backgroundColor: color }]}>
        <Text style={styles.avatarText}>{getInitials(item.name)}</Text>
      </View>
      <View style={styles.contactInfo}>
        <Text style={styles.contactName}>{item.name}</Text>
        <Text style={styles.contactEmail}>{item.email}</Text>
      </View>
    </View>
  );
};

export default function App() {
  const [contacts, setContacts] = useState<Contact[]>(CONTACTS);
  const [refreshing, setRefreshing] = useState(false);

  const handleRefresh = useCallback(() => {
    setRefreshing(true);
    setTimeout(() => {
      setContacts((prev) => shuffle(prev));
      setRefreshing(false);
    }, 1500);
  }, []);

  return (
    <View style={styles.container}>
      <PullToRefreshList
        data={contacts}
        renderItem={renderContact}
        onRefresh={handleRefresh}
        refreshing={refreshing}
        keyExtractor={(item) => item.id}
        emptyMessage="No contacts found"
        ListHeaderComponent={
          <View style={styles.header}>
            <Text style={styles.headerTitle}>Contacts</Text>
            <Text style={styles.headerSubtitle}>Pull down to refresh ({contacts.length})</Text>
          </View>
        }
      />
    </View>
  );
}

// ---------- Styles ----------

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0f172a",
  },
  list: {
    flex: 1,
  },
  emptyList: {
    flexGrow: 1,
    justifyContent: "center",
    alignItems: "center",
  },
  emptyContainer: {
    alignItems: "center",
    paddingVertical: 48,
  },
  emptyIcon: {
    fontSize: 48,
    marginBottom: 12,
  },
  emptyText: {
    color: "#64748b",
    fontSize: 16,
  },
  footerContainer: {
    flexDirection: "row",
    justifyContent: "center",
    alignItems: "center",
    paddingVertical: 16,
    gap: 8,
  },
  footerText: {
    color: "#94a3b8",
    fontSize: 13,
  },
  separator: {
    height: 1,
    backgroundColor: "#1e293b",
    marginLeft: 72,
  },
  header: {
    paddingTop: 60,
    paddingBottom: 16,
    paddingHorizontal: 20,
    borderBottomWidth: 1,
    borderBottomColor: "#1e293b",
  },
  headerTitle: {
    fontSize: 28,
    fontWeight: "700",
    color: "#f1f5f9",
    marginBottom: 4,
  },
  headerSubtitle: {
    fontSize: 14,
    color: "#64748b",
  },
  contactRow: {
    flexDirection: "row",
    alignItems: "center",
    paddingVertical: 12,
    paddingHorizontal: 20,
  },
  avatar: {
    width: 44,
    height: 44,
    borderRadius: 22,
    justifyContent: "center",
    alignItems: "center",
  },
  avatarText: {
    color: "#ffffff",
    fontSize: 15,
    fontWeight: "600",
  },
  contactInfo: {
    marginLeft: 14,
    flex: 1,
  },
  contactName: {
    color: "#e2e8f0",
    fontSize: 16,
    fontWeight: "500",
    marginBottom: 2,
  },
  contactEmail: {
    color: "#64748b",
    fontSize: 13,
  },
});

React Native Pull to Refresh List

A reusable FlatList wrapper that provides built-in pull-to-refresh behavior, a custom refresh indicator, loading states with an activity indicator footer, and an empty state placeholder. Drop it into any React Native screen to get a polished scrollable list experience with minimal setup.

Props

PropTypeRequiredDescription
dataT[]YesArray of items to render in the list.
renderItemListRenderItem<T>YesFunction that returns the JSX for each list item.
onRefresh() => voidYesCallback triggered when the user pulls to refresh.
refreshingbooleanYesWhether the refresh indicator is currently active.
emptyMessagestringNoCustom message displayed when the list has no items. Defaults to "No items to display".
ListHeaderComponentReact.ReactElementNoOptional header rendered above the list content.
loadingbooleanNoWhen true, shows an activity indicator at the bottom of the list.
keyExtractor(item: T, index: number) => stringNoFunction to extract a unique key for each item.

Usage

import PullToRefreshList from "./PullToRefreshList";

function MyScreen() {
  const [items, setItems] = useState(data);
  const [refreshing, setRefreshing] = useState(false);

  const handleRefresh = () => {
    setRefreshing(true);
    fetchData().then((newData) => {
      setItems(newData);
      setRefreshing(false);
    });
  };

  return (
    <PullToRefreshList
      data={items}
      renderItem={({ item }) => <MyListItem item={item} />}
      onRefresh={handleRefresh}
      refreshing={refreshing}
      emptyMessage="Nothing here yet"
    />
  );
}

How it works

The component wraps React Native’s FlatList and attaches a RefreshControl with custom tint and title colors. When the user pulls the list past the scroll threshold, the onRefresh callback fires and the refresh indicator appears until refreshing is set back to false.

A footer ActivityIndicator is conditionally rendered when the loading prop is true, useful for paginated lists that load more data as the user scrolls. When the data array is empty and no loading is in progress, the component renders a centered placeholder view with the emptyMessage text, giving users clear feedback that the list is intentionally empty rather than broken.

An ItemSeparatorComponent renders a subtle divider between rows automatically, keeping the list visually clean without requiring manual separator logic in each renderItem implementation.