最终效果

点左上角菜单按钮,弹出左侧菜单后

代码实现
app/(tabs)/mine.tsx
c
import icon_add from "@/assets/icons/icon_add.png";
import mine_bg from "@/assets/images/mine_bg.png";
import Heart from "@/components/Heart";
import articleList from "@/mock/articleList";
import SideMenu, { SideMenuRef } from "@/modules/mine/components/SideMenu";
import Entypo from "@expo/vector-icons/Entypo";
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { useRouter } from "expo-router";
import { useCallback, useRef, useState } from "react";
import {
Dimensions,
Image,
LayoutChangeEvent,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import icon_no_collection from "../../assets/icons/icon_no_collection.webp";
import icon_no_favorate from "../../assets/icons/icon_no_favorate.webp";
import icon_no_note from "../../assets/icons/icon_no_note.webp";
import Empty from "../../components/Empty";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const EMPTY_CONFIG = [
{ icon: icon_no_note, tips: "快去发布今日的好心情吧~" },
{ icon: icon_no_collection, tips: "快去收藏你喜欢的作品吧~" },
{ icon: icon_no_favorate, tips: "喜欢点赞的人运气不会太差哦~" },
];
export default function MineScreen() {
const sideMenuRef = useRef<SideMenuRef>(null);
const router = useRouter();
const [bgImgHeight, setBgImgHeight] = useState<number>(400);
const [tabIndex, setTabIndex] = useState<number>(0);
const onArticlePress = useCallback(
(article: ArticleSimple) => () => {
router.push(`/articleDetail?id=${article.id}`);
},
[]
);
const renderTitle = () => {
const styles = StyleSheet.create({
titleLayout: {
width: "100%",
height: 48,
flexDirection: "row",
alignItems: "center",
},
menuButton: {
height: "100%",
paddingHorizontal: 16,
justifyContent: "center",
},
menuImg: {
width: 28,
height: 28,
resizeMode: "contain",
},
rightMenuImg: {
marginRight: 14,
},
});
return (
<View style={styles.titleLayout}>
<TouchableOpacity
style={styles.menuButton}
onPress={() => {
sideMenuRef.current?.show();
}}
>
<Entypo name="menu" size={24} color="white" />
</TouchableOpacity>
<View style={{ flex: 1 }} />
<Entypo
style={styles.rightMenuImg}
name="shopping-cart"
size={24}
color="white"
/>
<Entypo
style={styles.rightMenuImg}
name="share"
size={24}
color="white"
/>
</View>
);
};
const renderInfo = () => {
const userInfo = {
avatar:
"https://img0.baidu.com/it/u=919979501,2820948992&fm=253&app=120&f=JPEG?w=800&h=800",
nickName: "清禾",
redBookId: "635942",
desc: "钟爱编程,偏前端开发,欢迎私信我加入EC尽享编程俱乐部共同学习,交流成长!",
sex: "female",
};
const { avatar, nickName, redBookId, desc, sex } = userInfo;
const styles = StyleSheet.create({
avatarLayout: {
width: "100%",
flexDirection: "row",
alignItems: "flex-end",
padding: 16,
},
avatarImg: {
width: 96,
height: 96,
resizeMode: "cover",
borderRadius: 48,
},
addImg: {
width: 28,
height: 28,
marginLeft: -28,
marginBottom: 2,
},
nameLayout: {
marginLeft: 20,
},
nameTxt: {
fontSize: 22,
color: "white",
fontWeight: "bold",
},
idLayout: {
flexDirection: "row",
alignItems: "center",
marginTop: 16,
marginBottom: 20,
},
idTxt: {
fontSize: 12,
color: "#bbb",
},
qrcodeImg: {
width: 12,
height: 12,
marginLeft: 6,
tintColor: "#bbb",
},
descTxt: {
fontSize: 14,
color: "white",
paddingHorizontal: 16,
},
sexLayout: {
width: 32,
height: 24,
backgroundColor: "#ffffff50",
borderRadius: 12,
marginTop: 12,
marginLeft: 16,
justifyContent: "center",
alignItems: "center",
},
sexImg: {
width: 12,
height: 12,
resizeMode: "contain",
},
infoLayout: {
width: "100%",
paddingRight: 16,
flexDirection: "row",
alignItems: "center",
marginTop: 20,
marginBottom: 28,
},
infoItem: {
alignItems: "center",
paddingHorizontal: 12,
},
infoValue: {
fontSize: 18,
color: "white",
},
infoLabel: {
fontSize: 12,
color: "#ddd",
marginTop: 6,
},
infoButton: {
height: 32,
paddingHorizontal: 16,
borderWidth: 1,
borderColor: "white",
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
marginLeft: 16,
},
editTxt: {
fontSize: 14,
color: "#ffffff",
},
settingImg: {
width: 20,
height: 20,
tintColor: "#ffffff",
},
});
return (
<View
onLayout={(e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout;
setBgImgHeight(height);
}}
>
<View style={styles.avatarLayout}>
<Image style={styles.avatarImg} source={{ uri: avatar }} />
<Image style={styles.addImg} source={icon_add} />
<View style={styles.nameLayout}>
<Text style={styles.nameTxt}>{nickName}</Text>
<View style={styles.idLayout}>
<Text style={styles.idTxt}>小红书号:{redBookId}</Text>
<MaterialCommunityIcons
style={{
marginLeft: 6,
}}
name="qrcode"
size={12}
color="white"
/>
</View>
</View>
</View>
<Text style={styles.descTxt}>{desc}</Text>
<View style={styles.sexLayout}>
<MaterialCommunityIcons
name={sex === "male" ? "gender-male" : "gender-female"}
size={14}
color="white"
/>
</View>
<View style={styles.infoLayout}>
<View style={styles.infoItem}>
<Text style={styles.infoValue}>1</Text>
<Text style={styles.infoLabel}>关注</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoValue}>65</Text>
<Text style={styles.infoLabel}>粉丝</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoValue}>625</Text>
<Text style={styles.infoLabel}>获赞与收藏</Text>
</View>
<View style={{ flex: 1 }} />
<TouchableOpacity style={styles.infoButton}>
<Text style={styles.editTxt}>编辑资料</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.infoButton}>
<MaterialIcons name="settings" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
);
};
const renderTabs = () => {
const styles = StyleSheet.create({
titleLayout: {
width: "100%",
height: 48,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: "white",
paddingHorizontal: 16,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
icon: {
width: 28,
height: 28,
},
line: {
width: 28,
height: 2,
backgroundColor: "#ff2442",
borderRadius: 1,
position: "absolute",
bottom: 6,
},
tabButton: {
height: "100%",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 14,
},
tabTxt: {
fontSize: 17,
color: "#999",
},
tabTxtSelected: {
fontSize: 17,
color: "#333",
},
});
return (
<View style={styles.titleLayout}>
<TouchableOpacity
style={styles.tabButton}
onPress={() => {
setTabIndex(0);
}}
>
<Text style={tabIndex === 0 ? styles.tabTxtSelected : styles.tabTxt}>
笔记
</Text>
{tabIndex === 0 && <View style={styles.line} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabButton}
onPress={() => {
setTabIndex(1);
}}
>
<Text style={tabIndex === 1 ? styles.tabTxtSelected : styles.tabTxt}>
收藏
</Text>
{tabIndex === 1 && <View style={styles.line} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabButton}
onPress={() => {
setTabIndex(2);
}}
>
<Text style={tabIndex === 2 ? styles.tabTxtSelected : styles.tabTxt}>
赞过
</Text>
{tabIndex === 2 && <View style={styles.line} />}
</TouchableOpacity>
</View>
);
};
const renderList = () => {
const noteList: ArticleSimple[] = [];
const collectionList: ArticleSimple[] = [];
const favorateList: ArticleSimple[] = articleList.filter(
(item) => item.isFavorite
);
const currentList = [noteList, collectionList, favorateList][tabIndex];
if (!currentList?.length) {
const config = EMPTY_CONFIG[tabIndex];
return <Empty icon={config.icon} tips={config.tips} />;
}
const styles = StyleSheet.create({
listContainer: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
backgroundColor: "white",
},
item: {
width: (SCREEN_WIDTH - 18) >> 1,
backgroundColor: "white",
marginLeft: 6,
marginBottom: 6,
borderRadius: 8,
overflow: "hidden",
marginTop: 8,
},
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,
},
heart: {
width: 20,
height: 20,
resizeMode: "contain",
},
countTxt: {
fontSize: 14,
color: "#999",
marginLeft: 4,
},
itemImg: {
width: (SCREEN_WIDTH - 18) >> 1,
height: 240,
},
});
return (
<View style={styles.listContainer}>
{currentList.map((item, index) => {
return (
<TouchableOpacity
key={`${item.id}-${index}`}
style={styles.item}
onPress={onArticlePress(item)}
>
<Image style={styles.itemImg} source={{ 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>
);
})}
</View>
);
};
return (
<View style={styles.page}>
<Image
style={[styles.bgImg, { height: bgImgHeight + 64 }]}
source={mine_bg}
/>
{renderTitle()}
<ScrollView style={styles.scrollView}>
{renderInfo()}
{renderTabs()}
{renderList()}
</ScrollView>
<SideMenu ref={sideMenuRef} />
</View>
);
}
const styles = StyleSheet.create({
scrollView: {
width: "100%",
flex: 1,
},
page: {
width: "100%",
height: "100%",
backgroundColor: "white",
},
bgImg: {
position: "absolute",
top: 0,
width: "100%",
height: 400,
},
});
相关组件
modules/mine/components/SideMenu.tsx
左侧弹窗菜单
c
import icon_browse_histroy from "@/assets/icons/icon_browse_history.png";
import icon_community from "@/assets/icons/icon_community.png";
import icon_coupon from "@/assets/icons/icon_coupon.png";
import icon_create_center from "@/assets/icons/icon_create_center.png";
import icon_draft from "@/assets/icons/icon_draft.png";
import icon_exit from "@/assets/icons/icon_exit.png";
import icon_fid_user from "@/assets/icons/icon_find_user.png";
import icon_free_net from "@/assets/icons/icon_free_net.png";
import icon_nice_goods from "@/assets/icons/icon_nice_goods.png";
import icon_orders from "@/assets/icons/icon_orders.png";
import icon_packet from "@/assets/icons/icon_packet.png";
import icon_red_vip from "@/assets/icons/icon_red_vip.png";
import icon_scan from "@/assets/icons/icon_scan.png";
import icon_service from "@/assets/icons/icon_service.png";
import icon_setting from "@/assets/icons/icon_setting.png";
import icon_shop_car from "@/assets/icons/icon_shop_car.png";
import icon_wish from "@/assets/icons/icon_wish.png";
import { remove } from "@/utils/Storage";
import { useRouter } from "expo-router";
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useState,
} from "react";
import {
Dimensions,
Image,
LayoutAnimation,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
const MENUS = [
[{ icon: icon_fid_user, name: "发现好友" }],
[
{ icon: icon_draft, name: "我的草稿" },
{ icon: icon_create_center, name: "创作中心" },
{ icon: icon_browse_histroy, name: "浏览记录" },
{ icon: icon_packet, name: "钱包" },
{ icon: icon_free_net, name: "免流量" },
{ icon: icon_nice_goods, name: "好物体验" },
],
[
{ icon: icon_orders, name: "订单" },
{ icon: icon_shop_car, name: "购物车" },
{ icon: icon_coupon, name: "卡券" },
{ icon: icon_wish, name: "心愿单" },
{ icon: icon_red_vip, name: "小红书会员" },
],
[
{ icon: icon_community, name: "社区公约" },
{ icon: icon_exit, name: "退出登陆" },
],
];
const BOTTOM_MENUS = [
{ icon: icon_setting, txt: "设置" },
{ icon: icon_service, txt: "帮助与客服" },
{ icon: icon_scan, txt: "扫一扫" },
];
export interface SideMenuRef {
show: () => void;
hide: () => void;
}
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ContentWidth = SCREEN_WIDTH * 0.75;
// eslint-disable-next-line react/display-name
export default forwardRef((props: any, ref) => {
const [visible, setVisible] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const show = () => {
setVisible(true);
setTimeout(() => {
LayoutAnimation.easeInEaseOut();
setOpen(true);
}, 100);
};
const hide = () => {
LayoutAnimation.easeInEaseOut();
setOpen(false);
setTimeout(() => {
setVisible(false);
}, 300);
};
useImperativeHandle(ref, () => {
return {
show,
hide,
};
});
const onMenuItemPress = useCallback(
(item: any) => async () => {
if (item.name === "退出登陆") {
hide();
await remove("userInfo");
router.replace("/login");
}
},
[]
);
const renderContent = () => {
return (
<View style={[styles.content, { marginLeft: open ? 0 : -ContentWidth }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.container}
showsVerticalScrollIndicator={false}
>
{MENUS.map((item, index) => {
return (
<View key={`${index}`}>
{item.map((subItem, subIndex) => {
return (
<TouchableOpacity
key={`${index}-${subIndex}`}
style={styles.menuItem}
onPress={onMenuItemPress(subItem)}
>
<Image
style={styles.menuItemIcon}
source={subItem.icon}
/>
<Text style={styles.menuItemTxt}>{subItem.name}</Text>
</TouchableOpacity>
);
})}
{index !== MENUS.length - 1 && (
<View style={styles.divideLine} />
)}
</View>
);
})}
</ScrollView>
<View style={styles.bottomLayout}>
{BOTTOM_MENUS.map((item) => {
return (
<TouchableOpacity
key={`${item.txt}`}
style={styles.bottomMenuItem}
>
<View style={styles.bottomMenuIconWrap}>
<Image style={styles.bottomMenuIcon} source={item.icon} />
</View>
<Text style={styles.bottomMenuTxt}>{item.txt}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
return (
<Modal
transparent={true}
visible={visible}
statusBarTranslucent={false}
animationType="fade"
onRequestClose={hide}
>
<TouchableOpacity style={styles.root} onPress={hide} activeOpacity={1}>
{renderContent()}
</TouchableOpacity>
</Modal>
);
});
const styles = StyleSheet.create({
root: {
width: "100%",
height: "100%",
backgroundColor: "#000000C0",
flexDirection: "row",
},
content: {
height: "100%",
width: ContentWidth,
backgroundColor: "white",
},
scrollView: {
width: "100%",
flex: 1,
},
bottomLayout: {
width: "100%",
flexDirection: "row",
paddingTop: 12,
paddingBottom: 20,
},
bottomMenuItem: {
flex: 1,
alignItems: "center",
},
bottomMenuIconWrap: {
width: 44,
height: 44,
backgroundColor: "#f0f0f0",
borderRadius: 22,
justifyContent: "center",
alignItems: "center",
},
bottomMenuIcon: {
width: 26,
height: 26,
},
bottomMenuTxt: {
fontSize: 13,
color: "#666",
marginTop: 8,
},
divideLine: {
width: "100%",
height: 1,
backgroundColor: "#eee",
},
menuItem: {
width: "100%",
height: 64,
flexDirection: "row",
alignItems: "center",
},
menuItemIcon: {
width: 32,
height: 32,
resizeMode: "contain",
},
menuItemTxt: {
fontSize: 16,
color: "#333",
marginLeft: 14,
},
container: {
paddingTop: 10,
paddingHorizontal: 28,
paddingBottom: 12,
},
});
components/Heart.tsx
c
import AntDesign from "@expo/vector-icons/AntDesign";
import React, { useEffect, useRef, useState } from "react";
import { Animated, TouchableOpacity } from "react-native";
type Props = {
value: boolean;
onValueChanged?: (value: boolean) => void;
size?: number;
color?: string;
};
// eslint-disable-next-line react/display-name
export default (props: Props) => {
const { value, onValueChanged, size = 20, color = "black" } = props;
const [showState, setShowState] = useState<boolean>(false);
const scale = useRef<Animated.Value>(new Animated.Value(0)).current;
const alpha = useRef<Animated.Value>(new Animated.Value(0)).current;
useEffect(() => {
setShowState(value);
}, [value]);
const onHeartPress = () => {
const newState = !showState;
setShowState(newState);
onValueChanged?.(newState);
if (newState) {
alpha.setValue(1);
const scaleAnim = Animated.timing(scale, {
toValue: 1.8,
duration: 300,
useNativeDriver: false,
});
const alphaAnim = Animated.timing(alpha, {
toValue: 0,
duration: 400,
useNativeDriver: false,
delay: 200,
});
Animated.parallel([scaleAnim, alphaAnim]).start();
} else {
scale.setValue(0);
alpha.setValue(0);
}
};
return (
<TouchableOpacity onPress={onHeartPress}>
{showState ? (
<AntDesign name="heart" size={size} color="red" />
) : (
<AntDesign name="hearto" size={size} color={color} />
)}
<Animated.View
style={{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: size / 20,
position: "absolute",
borderColor: "#ff2442",
transform: [{ scale: scale }],
opacity: alpha,
}}
/>
</TouchableOpacity>
);
};
components/Empty.tsx
c
import React from "react";
import { Image, StyleSheet, Text, View } from "react-native";
type Props = {
icon: number;
tips: string;
};
// eslint-disable-next-line react/display-name
export default ({ icon, tips }: Props) => {
return (
<View style={styles.root}>
<Image style={styles.icon} source={icon} />
<Text style={styles.tipsTxt}>{tips}</Text>
</View>
);
};
const styles = StyleSheet.create({
root: {
alignItems: "center",
paddingTop: 120,
},
icon: {
width: 96,
height: 96,
resizeMode: "contain",
},
tipsTxt: {
fontSize: 14,
color: "#bbb",
marginTop: 16,
},
});
模拟数据
mock/articleList.ts
c
const articleList: ArticleSimple[] = [
{
id: 1,
title: "让我抱抱,一起温暖,真的好治愈",
userName: "小飞飞爱猫咪",
avatarUrl:
"https://img2.baidu.com/it/u=902203086,3868774028&fm=253&app=138&f=JPEG?w=500&h=500",
image:
"http://gips2.baidu.com/it/u=195724436,3554684702&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960",
favoriteCount: 325,
isFavorite: true,
},
{
id: 2,
title: "不愧是网友给的配方,真的香迷糊了",
userName: "大厨师小飞象",
avatarUrl:
"https://pic.rmb.bdstatic.com/bjh/events/eeae3b71dabc9a372afd7f9e112287086428.jpeg@h_1280",
image:
"http://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280",
favoriteCount: 1098,
isFavorite: true,
},
{
id: 3,
title: "一觉醒来,满树的柑橘爬上了我的窗",
userName: "小小风筝",
avatarUrl:
"https://img1.baidu.com/it/u=1811602911,3261262340&fm=253&app=138&f=JPEG?w=500&h=500",
image:
"http://gips3.baidu.com/it/u=1537137094,335954266&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280",
favoriteCount: 18700,
isFavorite: false,
},
{
id: 4,
title: "满床清梦压星河",
userName: "失忆",
avatarUrl:
"https://img1.baidu.com/it/u=3505470809,2700212068&fm=253&app=138&f=JPEG?w=500&h=500",
image:
"https://gips3.baidu.com/it/u=1014935733,598223672&fm=3074&app=3074&f=PNG?w=1440&h=2560",
favoriteCount: 8700,
isFavorite: true,
},
{
id: 5,
title: "手机拍出来的星星,没想到那么多人喜欢",
userName: "慢慢",
avatarUrl:
"https://img1.baidu.com/it/u=1924685292,2387273894&fm=253&app=138&f=JPEG?w=500&h=500",
image:
"https://img2.baidu.com/it/u=2585843050,3523947274&fm=253&app=138&f=JPEG?w=1422&h=800",
favoriteCount: 2655,
isFavorite: false,
},
{
id: 6,
title: "告白如同田野间的风在青春里轰然",
userName: "潇潇",
avatarUrl:
"https://img1.baidu.com/it/u=3843254675,2187553494&fm=253&app=120&f=JPEG?w=800&h=800",
image:
"https://img1.baidu.com/it/u=1926713654,274347830&fm=253&app=138&f=JPEG?w=1422&h=800",
favoriteCount: 2655,
isFavorite: false,
},
];
export default articleList;