最终效果
顶栏
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",
},
});