ReactNative【实战系列教程】我的小红书 4 -- 首页(含顶栏tab切换,横向滚动频道,频道编辑弹窗,瀑布流布局列表等)

发布于:2025-07-09 ⋅ 阅读:(25) ⋅ 点赞:(0)

最终效果

在这里插入图片描述

顶栏

在这里插入图片描述

modules/index/components/topBar.tsx

import icon_daily from "@/assets/images/icon_daily.png";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { useCallback, useState } from "react";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
export default function IndexTopBar() {
  const [tab, setTab] = useState("关注");
  const tabList = [
    {
      label: "关注",
    },
    {
      label: "发现",
    },
    {
      label: "成都",
    },
  ];
  const handlePress = useCallback((item: string) => {
    setTab(item);
  }, []);
  return (
    <View style={styles.container}>
      <TouchableOpacity style={styles.dailyButton}>
        <Image style={styles.icon} source={icon_daily} />
      </TouchableOpacity>
      <View style={styles.typeBox}>
        {tabList.map((item) => (
          <TouchableOpacity
            activeOpacity={0.5}
            onPress={() => handlePress(item.label)}
            key={item.label}
            style={[styles.itemBox, tab === item.label && styles.activeItemBox]}
          >
            <Text
              style={[
                styles.itemText,
                tab === item.label && styles.activeItemText,
              ]}
            >
              {item.label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>
      <MaterialIcons name="search" size={24} color="black" />
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    width: "100%",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    paddingHorizontal: 12,
    paddingVertical: 6,
    borderBottomWidth: 1,
    borderBottomColor: "#f5f5f5",
    backgroundColor: "#fff",
  },
  icon: {
    width: 28,
    height: 28,
  },
  dailyButton: {
    justifyContent: "center",
    alignItems: "center",
  },
  typeBox: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
  },
  itemBox: {
    marginHorizontal: 12,
    paddingVertical: 4,
  },
  activeItemBox: {
    borderBottomWidth: 3,
    borderBottomColor: "#ff2442",
  },
  itemText: {
    fontSize: 16,
    color: "#999",
  },
  activeItemText: {
    fontSize: 17,
    color: "#333",
  },
});

首页导入

app/(tabs)/index.tsx

import TopBar from "@/modules/index/components/topBar";
<TopBar />

频道栏(含编辑弹窗)

在这里插入图片描述

编辑弹窗

在这里插入图片描述

modules/index/components/typeBar.tsx

import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { useCallback, useEffect, useRef, useState } from "react";
import {
  ScrollView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
import TypeModal from "./typeModal";
type Props = {
  allCategoryList: Category[];
  onCategoryChange: (category: Category) => void;
};
// eslint-disable-next-line react/display-name
export default ({ allCategoryList, onCategoryChange }: Props) => {
  const modalRef = useRef<{
    show: () => void;
    hide: () => void;
  }>(null);
  const [category, setCategory] = useState<Category>();
  const [myTypeList, setMyTypeList] = useState<Category[]>([]);
  useEffect(() => {
    setMyTypeList(allCategoryList.filter((i) => i.isAdd));
    setCategory(myTypeList.find((i) => i.name === "推荐"));
  }, []);
  const onAllCategoryListChange = (categoryList: Category[]) => {
    setMyTypeList(categoryList.filter((i) => i.isAdd));
  };
  const handlePress = useCallback((item: Category) => {
    setCategory(item);
    onCategoryChange(item);
  }, []);
  return (
    <View style={styles.container}>
      <ScrollView
        horizontal
        keyboardDismissMode="on-drag"
        scrollEventThrottle={16}
      >
        {myTypeList.map((item, index) => (
          <TouchableOpacity
            key={index}
            style={styles.itemBox}
            onPress={() => handlePress(item)}
          >
            <Text
              style={[
                styles.itemText,
                category?.name === item.name && styles.activeItemText,
              ]}
            >
              {item.name}
            </Text>
          </TouchableOpacity>
        ))}
      </ScrollView>
      <TouchableOpacity onPress={() => modalRef.current?.show()}>
        <MaterialIcons name="keyboard-arrow-down" size={24} color="black" />
      </TouchableOpacity>
      <TypeModal
        ref={modalRef}
        categoryList={allCategoryList}
        onCategoryListChange={onAllCategoryListChange}
      />
    </View>
  );
};
const styles = StyleSheet.create({
  container: {
    flexDirection: "row",
    paddingHorizontal: 12,
    paddingVertical: 6,
    backgroundColor: "#fff",
  },
  scrollBox: {
    marginHorizontal: 12,
    paddingVertical: 4,
  },
  itemBox: {
    paddingRight: 26,
  },
  itemText: {
    fontSize: 16,
    color: "#999",
  },
  activeItemText: {
    fontSize: 16,
    color: "#333",
    fontWeight: "bold",
  },
});

modules/index/components/typeModal.tsx

import { save } from "@/utils/Storage";
import AntDesign from "@expo/vector-icons/AntDesign";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import {
  Dimensions,
  Modal,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
type Props = {
  categoryList: Category[];
  onCategoryListChange: (categoryList: Category[]) => void;
};
// eslint-disable-next-line react/display-name
export default forwardRef(
  ({ categoryList, onCategoryListChange }: Props, ref) => {
    const [visible, setVisible] = useState(false);
    const [edit, setEdit] = useState<boolean>(false);
    const [myList, setMyList] = useState<Category[]>([]);
    const [otherList, setOtherList] = useState<Category[]>([]);
    useEffect(() => {
      if (!categoryList) {
        return;
      }
      const list1 = categoryList.filter((i) => i.isAdd);
      const list2 = categoryList.filter((i) => !i.isAdd);
      setMyList(list1);
      setOtherList(list2);
    }, [categoryList]);
    const hide = () => {
      setVisible(false);
    };
    const saveChanges = () => {
      const newCategoryList = [...myList, ...otherList];
      save("categoryList", JSON.stringify(newCategoryList));
      onCategoryListChange(newCategoryList);
    };
    const show = () => {
      setVisible(true);
    };
    const delItem = (item: Category) => {
      const newMyList = myList.filter((i) => i.name !== item.name);
      const item_copy = { ...item, isAdd: false };
      const newOtherList = [...otherList, item_copy];
      setMyList(newMyList);
      setOtherList(newOtherList);
    };
    const addItem = (item: Category) => {
      if (!edit) {
        return;
      }
      const newOtherList = otherList.filter((i) => i.name !== item.name);
      const item_copy = { ...item, isAdd: true };
      const newMyList = [...myList, item_copy];
      setMyList(newMyList);
      setOtherList(newOtherList);
    };
    // 暴露方法给父组件
    useImperativeHandle(ref, () => ({
      show,
    }));
    return (
      <Modal
        animationType="fade"
        transparent={true}
        visible={visible}
        onRequestClose={hide}
      >
        <View style={styles.winBox}>
          <View style={styles.contentBox}>
            <View style={styles.titleBox}>
              <Text style={styles.titleTxt}>我的频道</Text>
              <Text style={styles.subTitleTxt}>
                {edit ? "点击移除频道" : "点击进入频道"}
              </Text>
              <TouchableOpacity
                style={styles.editButton}
                onPress={() => {
                  if (edit) {
                    saveChanges();
                  }
                  setEdit(!edit);
                }}
              >
                <Text style={styles.editTxt}>
                  {edit ? "完成编辑" : "进入编辑"}
                </Text>
              </TouchableOpacity>
              <TouchableOpacity onPress={() => hide()}>
                <MaterialIcons
                  name="keyboard-arrow-up"
                  size={24}
                  color="black"
                />
              </TouchableOpacity>
            </View>
            <View style={styles.listBox}>
              {myList.map((item: Category, index: number) => {
                return (
                  <TouchableOpacity
                    key={`${item.name}`}
                    style={
                      item.default
                        ? styles.itemLayoutDefault
                        : styles.itemLayout
                    }
                    onPress={() => {
                      if (edit && !item.default) {
                        delItem(item);
                      }
                    }}
                  >
                    <Text style={styles.itemTxt}>{item.name}</Text>
                    {edit && !item.default && (
                      <AntDesign
                        style={styles.delIcon}
                        name="closecircle"
                        size={14}
                        color="#cecece"
                      />
                    )}
                  </TouchableOpacity>
                );
              })}
            </View>
            <View style={styles.otherBox}>
              <View style={styles.titleBox}>
                <Text style={styles.titleTxt}>推荐频道</Text>
                <Text style={styles.subTitleTxt}>点击添加频道</Text>
              </View>
              <View style={styles.listBox}>
                {otherList.map((item: Category, index: number) => {
                  return (
                    <TouchableOpacity
                      key={`${item.name}`}
                      style={
                        item.default
                          ? styles.itemLayoutDefault
                          : styles.itemLayout
                      }
                      onPress={() => {
                        addItem(item);
                      }}
                    >
                      <Text style={styles.itemTxt}> + {item.name}</Text>
                    </TouchableOpacity>
                  );
                })}
              </View>
            </View>
          </View>
          <View style={styles.bottomBox}></View>
        </View>
      </Modal>
    );
  }
);
const styles = StyleSheet.create({
  winBox: {
    flex: 1,
    alignItems: "center",
    backgroundColor: "transparent",
  },
  contentBox: {
    marginTop: 56,
    width: "100%",
    backgroundColor: "#fff",
  },
  titleBox: {
    flexDirection: "row",
    alignItems: "center",
    paddingHorizontal: 12,
  },
  titleTxt: {
    fontSize: 16,
    color: "#333",
    fontWeight: "bold",
    marginLeft: 6,
  },
  subTitleTxt: {
    fontSize: 13,
    color: "#999",
    marginLeft: 12,
    flex: 1,
  },
  bottomBox: {
    flex: 1,
    width: "100%",
    backgroundColor: "rgba(0,0,0,0.5)",
  },
  editButton: {
    paddingHorizontal: 10,
    height: 28,
    backgroundColor: "#EEE",
    borderRadius: 14,
    justifyContent: "center",
    alignItems: "center",
    marginRight: 6,
  },
  editTxt: {
    fontSize: 13,
  },
  listBox: {
    marginTop: 6,
    width: "100%",
    flexDirection: "row",
    flexWrap: "wrap",
  },
  itemLayout: {
    width: (SCREEN_WIDTH - 80) >> 2,
    height: 32,
    justifyContent: "center",
    alignItems: "center",
    borderWidth: 1,
    borderColor: "#f5f5f5",
    borderRadius: 6,
    marginLeft: 16,
    marginTop: 12,
  },
  itemLayoutDefault: {
    width: (SCREEN_WIDTH - 80) >> 2,
    height: 32,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "#f5f5f5",
    borderRadius: 6,
    marginLeft: 16,
    marginTop: 12,
  },
  itemTxt: {
    fontSize: 14,
    color: "#666",
  },
  otherBox: {
    marginVertical: 30,
  },
  delIcon: {
    position: "absolute",
    right: -6,
    top: -6,
  },
});

首页导入

import TypeBar from "@/modules/index/components/typeBar";

作为列表的页眉渲染

        // 列表顶部
        renderHeader={() =>
          (!isLoading_type && (
            <TypeBar
              allCategoryList={store.categoryList}
              onCategoryChange={(category: Category) => {
                console.log(JSON.stringify(category));
              }}
            />
          )) || <></>
        }

数据来自 store

import IndexStore from "@/modules/index/IndexStore";
const store = useLocalObservable(() => new IndexStore());

因数据是异步加载,需跟进其加载状态

const [isLoading_type, setIsLoading_type] = useState(true);

在页面初始渲染时异步加载数据

  useEffect(() => {
    const Loading_type = async () => {
      try {
        await store.getCategoryList();
      } catch (error) {
        console.error("Failed to fetch category list:", error);
      } finally {
        setIsLoading_type(false);
      }
    };
    Loading_type();
    store.requestHomeList();
  }, []);

modules/index/IndexStore.ts

暂用的 mock 数据,解开注释,可访问真实接口。

import articles from "@/mock/articles";
import { load } from "@/utils/Storage";
import { Toast } from "@ant-design/react-native";
import { action, observable } from "mobx";
const SIZE = 10;
export default class IndexStore {
  page: number = 1;
  @observable homeList: ArticleSimple[] = [];
  @observable refreshing: boolean = false;
  @observable categoryList: Category[] = [];
  @action
  resetPage = () => {
    this.page = 1;
  };
  requestHomeList = async () => {
    if (this.refreshing) {
      return;
    }
    const loading = Toast.loading("加载中...");
    try {
      this.refreshing = true;
      const params = {
        page: this.page,
        size: SIZE,
      };
      // const { data } = await request("homeList", params);
      let data = articles.map((item) => ({
        ...item,
        image: item.images[0],
      }));
      if (data?.length) {
        if (this.page === 1) {
          this.homeList = data;
        } else {
          // this.homeList = [...this.homeList, ...data];
        }
        this.page = this.page + 1;
      } else {
        if (this.page === 1) {
          this.homeList = [];
        } else {
          // 已经加载完了,没有更多数据
        }
      }
    } catch (error) {
      console.log(error);
    } finally {
      this.refreshing = false;
      Toast.remove(loading);
    }
  };
  getCategoryList = async () => {
    const cacheListStr = await load("categoryList");
    if (cacheListStr) {
      const cacheList = JSON.parse(cacheListStr);
      if (cacheList?.length) {
        this.categoryList = cacheList;
      } else {
        this.categoryList = DEFAULT_CATEGORY_LIST;
      }
    } else {
      this.categoryList = DEFAULT_CATEGORY_LIST;
    }
  };
}
const DEFAULT_CATEGORY_LIST: Category[] = [
  // 默认添加频道
  { name: "推荐", default: true, isAdd: true },
  { name: "视频", default: true, isAdd: true },
  { name: "直播", default: true, isAdd: true },
  { name: "摄影", default: false, isAdd: true },
  { name: "穿搭", default: false, isAdd: true },
  { name: "读书", default: false, isAdd: true },
  { name: "影视", default: false, isAdd: true },
  { name: "科技", default: false, isAdd: true },
  { name: "健身", default: false, isAdd: true },
  { name: "科普", default: false, isAdd: true },
  { name: "美食", default: false, isAdd: true },
  { name: "情感", default: false, isAdd: true },
  { name: "舞蹈", default: false, isAdd: true },
  { name: "学习", default: false, isAdd: true },
  { name: "男士", default: false, isAdd: true },
  { name: "搞笑", default: false, isAdd: true },
  { name: "汽车", default: false, isAdd: true },
  { name: "职场", default: false, isAdd: true },
  { name: "运动", default: false, isAdd: true },
  { name: "旅行", default: false, isAdd: true },
  { name: "音乐", default: false, isAdd: true },
  { name: "护肤", default: false, isAdd: true },
  { name: "动漫", default: false, isAdd: true },
  { name: "游戏", default: false, isAdd: true },
  // 默认添加频道
  { name: "家装", default: false, isAdd: false },
  { name: "心理", default: false, isAdd: false },
  { name: "户外", default: false, isAdd: false },
  { name: "手工", default: false, isAdd: false },
  { name: "减脂", default: false, isAdd: false },
  { name: "校园", default: false, isAdd: false },
  { name: "社科", default: false, isAdd: false },
  { name: "露营", default: false, isAdd: false },
  { name: "文化", default: false, isAdd: false },
  { name: "机车", default: false, isAdd: false },
  { name: "艺术", default: false, isAdd: false },
  { name: "婚姻", default: false, isAdd: false },
  { name: "家居", default: false, isAdd: false },
  { name: "母婴", default: false, isAdd: false },
  { name: "绘画", default: false, isAdd: false },
  { name: "壁纸", default: false, isAdd: false },
  { name: "头像", default: false, isAdd: false },
];

瀑布流布局列表

https://blog.csdn.net/weixin_41192489/article/details/149202367

首页最终代码

app/(tabs)/index.tsx

import Heart from "@/components/Heart";
import ResizeImage from "@/components/ResizeImage";
import articleList from "@/mock/articleList";
import TopBar from "@/modules/index/components/topBar";
import TypeBar from "@/modules/index/components/typeBar";
import IndexStore from "@/modules/index/IndexStore";
import { useRouter } from "expo-router";
import { observer, useLocalObservable } from "mobx-react-lite";
import { useCallback, useEffect, useState } from "react";
import {
  Dimensions,
  Image,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
import WaterfallFlow from "../../components/WaterfallFlow";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export default observer(function IndexScreen() {
  const router = useRouter();
  const store = useLocalObservable(() => new IndexStore());
  const [isLoading_type, setIsLoading_type] = useState(true);
  useEffect(() => {
    const Loading_type = async () => {
      try {
        await store.getCategoryList();
      } catch (error) {
        console.error("Failed to fetch category list:", error);
      } finally {
        setIsLoading_type(false);
      }
    };
    Loading_type();
    store.requestHomeList();
  }, []);
  const onArticlePress = useCallback(
    (article: ArticleSimple) => () => {
      router.push(`/articleDetail?id=${article.id}`);
    },
    []
  );
  const renderItem = (item: ArticleSimple) => {
    return (
      <TouchableOpacity style={styles.item} onPress={onArticlePress(item)}>
        <ResizeImage uri={item.image} />
        <Text style={styles.titleTxt}>{item.title}</Text>
        <View style={[styles.nameLayout]}>
          <Image style={styles.avatarImg} source={{ uri: item.avatarUrl }} />
          <Text style={styles.nameTxt}>{item.userName}</Text>
          <Heart
            value={item.isFavorite}
            onValueChanged={(value: boolean) => {
              console.log(value);
            }}
          />
          <Text style={styles.countTxt}>{item.favoriteCount}</Text>
        </View>
      </TouchableOpacity>
    );
  };
  const loadMoreData = () => {
    store.requestHomeList();
  };
  const refreshNewData = () => {
    store.resetPage();
    store.requestHomeList();
  };
  const Footer = () => {
    return <Text style={styles.footerTxt}>---- 没有更多数据了 ---- </Text>;
  };
  return (
    <View style={styles.page}>
      <TopBar />
      <WaterfallFlow
        data={articleList}
        // 列数
        numColumns={2}
        // 列间距
        columnGap={8}
        // 行间距
        rowGap={4}
        // 触顶下拉刷新
        onRefresh={refreshNewData}
        // 触底加载更多数据
        onLoadMore={loadMoreData}
        // 是否在刷新
        refreshing={store.refreshing}
        // 列表顶部
        renderHeader={() =>
          (!isLoading_type && (
            <TypeBar
              allCategoryList={store.categoryList}
              onCategoryChange={(category: Category) => {
                console.log(JSON.stringify(category));
              }}
            />
          )) || <></>
        }
        // 列表项
        renderItem={renderItem}
        // 列表底部
        renderFooter={Footer}
      />
    </View>
  );
});
const styles = StyleSheet.create({
  page: {
    paddingBottom: 50,
  },
  item: {
    width: (SCREEN_WIDTH - 18) >> 1,
    backgroundColor: "white",
    marginLeft: 6,
    marginBottom: 6,
    borderRadius: 8,
    overflow: "hidden",
  },
  countTxt: {
    fontSize: 14,
    color: "#999",
    marginLeft: 4,
  },
  titleTxt: {
    fontSize: 14,
    color: "#333",
    marginHorizontal: 10,
    marginVertical: 4,
  },
  nameLayout: {
    width: "100%",
    flexDirection: "row",
    alignItems: "center",
    paddingHorizontal: 10,
    marginBottom: 10,
  },
  avatarImg: {
    width: 20,
    height: 20,
    resizeMode: "cover",
    borderRadius: 10,
  },
  nameTxt: {
    fontSize: 12,
    color: "#999",
    marginLeft: 6,
    flex: 1,
  },
  footerTxt: {
    width: "100%",
    fontSize: 14,
    color: "#999",
    marginVertical: 16,
    textAlign: "center",
    textAlignVertical: "center",
  },
});

网站公告

今日签到

点亮在社区的每一天
去签到