React Native【实战范例】账号管理(含转换分组列表数据的封装,分组折叠的实现,账号的增删改查,表单校验等)
最终效果
技术要点
将普通数组转换为分组列表
// 转换函数
const convertToSectionData = (accountList: type_item[]): SectionData[] => {const sectionMap = new Map<string, SectionData>();accountList.forEach((item) => {if (sectionMap.has(item.type)) {const section = sectionMap.get(item.type)!;section.data.push(item);} else {sectionMap.set(item.type, {title: item.type,show: true, // 默认显示所有的sectionHeadedata: [item],});}});return Array.from(sectionMap.values());
};
setSectionData(convertToSectionData(accountList));
分组折叠时,左右下角变圆角
{borderBottomLeftRadius:!section.data.length || !section.show ? 12 : 0,borderBottomRightRadius:!section.data.length || !section.show ? 12 : 0,},
分组支持折叠
onPress={() => {setSectionData((prevSectionData) => {return prevSectionData.map((item) => {if (item.title === section.title) {return { ...item, show: !item.show };} else {return item;}});});}}
renderItem 中
if (!section.show) {return null;}
密码的显隐切换
const [showPassword, setShowPassword] = useState(true);
<Text style={styles.accpwdTxt}>{`密码:${showPassword ? item.password : "********"}`}</Text>
<Switchstyle={styles.switch}ios_backgroundColor="#3e3e3e"onValueChange={(value) => {setShowPassword(value);}}value={showPassword}/>
删除账号
onLongPress={() => {const buttons = [{ text: "取消", onPress: () => {} },{ text: "确定", onPress: () => deleteAccount(item) },];Alert.alert("提示",`确定删除「${item.platformName}」的账号吗?`,buttons);}}
const deleteAccount = (account: type_item) => {get("accountList").then((data) => {if (!data) {return;}let accountList = JSON.parse(data);accountList = accountList.filter((item: type_item) => item.id !== account.id);set("accountList", JSON.stringify(accountList)).then(() => {loadData();});});};
表单校验
const [errors, setErrors] = useState<{platformName?: string;account?: string;password?: string;}>({});
// 校验函数const validate = () => {const newErrors: {platformName?: string;account?: string;password?: string;} = {};let isValid = true;if (!platformName) {newErrors.platformName = "平台名称不能为空";isValid = false;}if (!account) {newErrors.account = "账号不能为空";isValid = false;}if (!password) {newErrors.password = "密码不能为空";isValid = false;} else if (password.length < 6) {newErrors.password = "密码长度至少6位";isValid = false;}setErrors(newErrors);return isValid;};
const save = () => {if (!validate()) {return;}
<><TextInputstyle={styles.input}maxLength={20}value={platformName}onChangeText={(text) => {setPlatformName(text || "");}}/>{errors.platformName && (<View style={styles.errorBox}><MaterialIcons name="error" size={18} color="red" /><Text style={styles.error}>{errors.platformName}</Text></View>)}</>
账号的新增和修改
根据 id 区分
const save = () => {if (!validate()) {return;}const newAccount = {id: id || getUUID(),type,platformName,account,password,};get("accountList").then((data) => {let accountList = data ? JSON.parse(data) : [];if (!id) {accountList.push(newAccount);} else {accountList = accountList.map((item: type_item) => {if (item.id === id) {return newAccount;}return item;});}set("accountList", JSON.stringify(accountList)).then(() => {props.onRefresh();hide();});});};
账号类型的切换
https://blog.csdn.net/weixin_41192489/article/details/148847637
代码实现
安装依赖
npm i react-native-get-random-values
npm i uuid
npm i @react-native-async-storage/async-storage
app/(tabs)/index.tsx
import AddAccount from "@/components/addAccount";
import {SectionData,type_iconType,type_item,type_itemType,type_ref_AddAccount,
} from "@/types/account";
import { get, set } from "@/utils/Storage";
import Entypo from "@expo/vector-icons/Entypo";
import { useEffect, useRef, useState } from "react";
import {Alert,LayoutAnimation,Platform,SectionList,StyleSheet,Switch,Text,TouchableOpacity,View,
} from "react-native";
import {SafeAreaProvider,useSafeAreaInsets,
} from "react-native-safe-area-context";
// 启用 Android 布局动画支持(仅 API Level 19+)
if (Platform.OS === "android") {if (Platform.Version >= 19) {LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);}
}
const iconDic: Record<type_itemType, type_iconType> = {社交: "chat",游戏: "game-controller",其他: "flag",
};
// 转换函数
const convertToSectionData = (accountList: type_item[]): SectionData[] => {const sectionMap = new Map<string, SectionData>();accountList.forEach((item) => {if (sectionMap.has(item.type)) {const section = sectionMap.get(item.type)!;section.data.push(item);} else {sectionMap.set(item.type, {title: item.type,show: true, // 默认显示所有的sectionHeadedata: [item],});}});return Array.from(sectionMap.values());
};
export default function Demo() {const insets = useSafeAreaInsets();const ref_AddAccount = useRef<type_ref_AddAccount>(null);const showAddAccount = () => {ref_AddAccount.current?.show({id: "",type: "社交",platformName: "",account: "",password: "",});};const [sectionData, setSectionData] = useState<SectionData[]>([]);const [showPassword, setShowPassword] = useState(true);const loadData = () => {get("accountList").then((data) => {let accountList = data ? JSON.parse(data) : [];setSectionData(convertToSectionData(accountList));});};useEffect(() => {loadData();}, []);const renderSectionHeader = ({ section }: { section: SectionData }) => {return (<Viewstyle={[styles.groupHeader,{borderBottomLeftRadius:!section.data.length || !section.show ? 12 : 0,borderBottomRightRadius:!section.data.length || !section.show ? 12 : 0,},]}><Entypo name={iconDic[section.title]} size={24} color="black" /><Text style={styles.typeTxt}>{section.title}</Text><TouchableOpacitystyle={styles.arrowButton}onPress={() => {setSectionData((prevSectionData) => {return prevSectionData.map((item) => {if (item.title === section.title) {return { ...item, show: !item.show };} else {return item;}});});}}><Entyponame={section.show ? "chevron-down" : "chevron-right"}size={24}color="black"/></TouchableOpacity></View>);};const renderItem = ({item,index,section,}: {item: type_item;index: number;section: SectionData;}) => {if (!section.show) {return null;}return (<TouchableOpacitystyle={styles.itemLayout}onPress={() => {ref_AddAccount.current?.show(item);}}onLongPress={() => {const buttons = [{ text: "取消", onPress: () => {} },{ text: "确定", onPress: () => deleteAccount(item) },];Alert.alert("提示",`确定删除「${item.platformName}」的账号吗?`,buttons);}}><Text style={styles.nameTxt}>{item.platformName}</Text><View style={styles.accpwdLayout}><Text style={styles.accpwdTxt}>{`账号:${item.account}`}</Text><Text style={styles.accpwdTxt}>{`密码:${showPassword ? item.password : "********"}`}</Text></View></TouchableOpacity>);};const deleteAccount = (account: type_item) => {get("accountList").then((data) => {if (!data) {return;}let accountList = JSON.parse(data);accountList = accountList.filter((item: type_item) => item.id !== account.id);set("accountList", JSON.stringify(accountList)).then(() => {loadData();});});};return (<SafeAreaProvider><Viewstyle={{flex: 1,paddingTop: insets.top, // 顶部安全区域paddingBottom: insets.bottom, // 底部安全区域}}><View style={styles.titleBox}><Text style={styles.title}>账号管理</Text><Switchstyle={styles.switch}ios_backgroundColor="#3e3e3e"onValueChange={(value) => {setShowPassword(value);}}value={showPassword}/></View><SectionListsections={sectionData}keyExtractor={(item, index) => `${item}-${index}`}renderItem={renderItem}renderSectionHeader={renderSectionHeader}contentContainerStyle={styles.listContainer}/><TouchableOpacitystyle={styles.addBtn}activeOpacity={0.5}onPress={showAddAccount}><Entypo name="plus" size={24} color="white" /></TouchableOpacity><AddAccount ref={ref_AddAccount} onRefresh={loadData} /></View></SafeAreaProvider>);
}
const styles = StyleSheet.create({titleBox: {height: 44,backgroundColor: "#fff",justifyContent: "center",},title: {textAlign: "center",fontSize: 20,fontWeight: "bold",color: "#000",},addBtn: {position: "absolute",right: 20,bottom: 20,width: 40,height: 40,borderRadius: "50%",backgroundColor: "#007ea4",justifyContent: "center",alignItems: "center",},root: {width: "100%",height: "100%",backgroundColor: "#F0F0F0",},titleLayout: {width: "100%",height: 46,backgroundColor: "white",justifyContent: "center",alignItems: "center",flexDirection: "row",},titleTxt: {fontSize: 18,color: "#333333",fontWeight: "bold",},addButton: {position: "absolute",bottom: 64,right: 28,},addImg: {width: 56,height: 56,resizeMode: "contain",},groupHeader: {width: "100%",height: 46,backgroundColor: "white",borderTopLeftRadius: 12,borderTopRightRadius: 12,flexDirection: "row",alignItems: "center",paddingHorizontal: 12,marginTop: 12,},typeImg: {width: 24,height: 24,resizeMode: "contain",},listContainer: {paddingHorizontal: 12,},typeTxt: {fontSize: 16,color: "#333",fontWeight: "bold",marginLeft: 16,},arrowButton: {position: "absolute",right: 0,padding: 16,},arrowImg: {width: 20,height: 20,},itemLayout: {width: "100%",flexDirection: "column",paddingHorizontal: 12,paddingVertical: 10,backgroundColor: "white",borderTopWidth: 1,borderTopColor: "#E0E0E0",},nameTxt: {fontSize: 16,color: "#333",fontWeight: "bold",},accpwdLayout: {width: "100%",flexDirection: "row",alignItems: "center",},accpwdTxt: {flex: 1,fontSize: 14,color: "#666666",marginTop: 12,marginBottom: 6,},switch: {position: "absolute",right: 12,},
});
components/addAccount.tsx
import { type_item } from "@/types/account";
import { get, set } from "@/utils/Storage";
import { getUUID } from "@/utils/UUID";
import { AntDesign } from "@expo/vector-icons";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { forwardRef, useCallback, useImperativeHandle, useState } from "react";
import {KeyboardAvoidingView,Modal,Platform,StyleSheet,Text,TextInput,TouchableOpacity,View,
} from "react-native";
// eslint-disable-next-line react/display-name
export default forwardRef((props: {onRefresh: () => void;},ref) => {const [visible, setVisible] = useState(false);const [type, setType] = useState("社交");const [platformName, setPlatformName] = useState("");const [account, setAccount] = useState("");const [password, setPassword] = useState("");const [id, setId] = useState("");const [errors, setErrors] = useState<{platformName?: string;account?: string;password?: string;}>({});// 校验函数const validate = () => {const newErrors: {platformName?: string;account?: string;password?: string;} = {};let isValid = true;if (!platformName) {newErrors.platformName = "平台名称不能为空";isValid = false;}if (!account) {newErrors.account = "账号不能为空";isValid = false;}if (!password) {newErrors.password = "密码不能为空";isValid = false;} else if (password.length < 6) {newErrors.password = "密码长度至少6位";isValid = false;}setErrors(newErrors);return isValid;};const hide = () => {setVisible(false);};const show = (data: type_item) => {if (data) {setId(data.id);setType(data.type);setPlatformName(data.platformName);setAccount(data.account);setPassword(data.password);}setErrors({});setVisible(true);};const save = () => {if (!validate()) {return;}const newAccount = {id: id || getUUID(),type,platformName,account,password,};get("accountList").then((data) => {let accountList = data ? JSON.parse(data) : [];if (!id) {accountList.push(newAccount);} else {accountList = accountList.map((item: type_item) => {if (item.id === id) {return newAccount;}return item;});}set("accountList", JSON.stringify(accountList)).then(() => {props.onRefresh();hide();});});};// 暴露方法给父组件useImperativeHandle(ref, () => ({show,}));const Render_Type = () => {const TypeList = ["社交", "游戏", "其他"];const handlePress = useCallback((item: string) => {setType(item);}, []);const styles = StyleSheet.create({typeBox: {display: "flex",flexDirection: "row",justifyContent: "space-between",alignItems: "center",marginTop: 10,marginBottom: 10,},itemBox: {borderWidth: 1,borderColor: "#C0C0C0",flex: 1,height: 30,justifyContent: "center",alignItems: "center",},moveLeft1Pix: {marginLeft: -1,},leftItem: {borderTopLeftRadius: 8,borderBottomLeftRadius: 8,},rightItem: {borderTopRightRadius: 8,borderBottomRightRadius: 8,},activeItem: {backgroundColor: "#007ea4",color: "#fff",},});return (<View style={styles.typeBox}>{TypeList.map((item, index) => (<TouchableOpacityactiveOpacity={0.5}onPress={() => handlePress(item)}key={item}style={[styles.itemBox,index > 0 && styles.moveLeft1Pix,index === 0 && styles.leftItem,index === TypeList.length - 1 && styles.rightItem,type === item && styles.activeItem,]}><Text style={[type === item && styles.activeItem]}>{item}</Text></TouchableOpacity>))}</View>);};const Render_platformName = () => {return (<><TextInputstyle={styles.input}maxLength={20}value={platformName}onChangeText={(text) => {setPlatformName(text || "");}}/>{errors.platformName && (<View style={styles.errorBox}><MaterialIcons name="error" size={18} color="red" /><Text style={styles.error}>{errors.platformName}</Text></View>)}</>);};const Render_account = () => {return (<><TextInputstyle={styles.input}maxLength={20}value={account}onChangeText={(text) => {setAccount(text || "");}}/>{errors.account && (<View style={styles.errorBox}><MaterialIcons name="error" size={18} color="red" /><Text style={styles.error}>{errors.account}</Text></View>)}</>);};const Render_password = () => {return (<><TextInputstyle={styles.input}maxLength={20}value={password}onChangeText={(text) => {setPassword(text || "");}}/>{errors.password && (<View style={styles.errorBox}><MaterialIcons name="error" size={18} color="red" /><Text style={styles.error}>{errors.password}</Text></View>)}</>);};return (<ModalanimationType="fade"transparent={true}visible={visible}onRequestClose={hide}><KeyboardAvoidingViewbehavior={Platform.OS === "ios" ? "padding" : "height"}style={styles.winBox}><View style={styles.contentBox}><View style={styles.titleBox}><Text style={styles.titleText}>{id ? "修改账号" : "添加账号"}</Text>{/* 使用 AntDesign 图标集 */}<AntDesignstyle={styles.closeIcon}name="close"size={20}onPress={hide}/></View><Text>账号类型</Text>{Render_Type()}<Text>平台名称</Text>{Render_platformName()}<Text>账号</Text>{Render_account()}<Text>密码</Text>{Render_password()}<TouchableOpacitystyle={styles.saveBtn}activeOpacity={0.5}onPress={save}><Text style={styles.saveBtnLabel}>保存</Text></TouchableOpacity></View></KeyboardAvoidingView></Modal>);}
);
const styles = StyleSheet.create({winBox: {flex: 1,backgroundColor: "rgba(0,0,0,0.5)",justifyContent: "center",alignItems: "center",},contentBox: {width: "80%",backgroundColor: "#fff",borderRadius: 10,justifyContent: "center",overflow: "hidden",paddingHorizontal: 20,paddingVertical: 10,},titleBox: {backgroundColor: "#fff",justifyContent: "center",alignItems: "center",flexDirection: "row",fontWeight: "bold",marginBottom: 10,},titleText: {fontSize: 18,fontWeight: "bold",},closeIcon: {position: "absolute",right: 10,},input: {marginTop: 10,marginBottom: 10,borderWidth: 1,borderColor: "#C0C0C0", // 边框颜色borderRadius: 8, // 圆角paddingHorizontal: 12,fontSize: 16, // 字体大小height: 40, // 输入框高度},saveBtn: {marginTop: 10,marginBottom: 10,height: 40,borderRadius: 8,backgroundColor: "#007ea4",justifyContent: "center",alignItems: "center",},saveBtnLabel: {color: "#fff",fontSize: 16,fontWeight: "bold",},errorBox: {flexDirection: "row",alignItems: "center",marginBottom: 10,},error: { color: "red", marginBottom: 2, marginLeft: 4 },
});
types/account.ts
// 定义子组件暴露的方法接口
export interface type_ref_AddAccount {show: (item: type_item) => void;
}
export type type_itemType = "社交" | "游戏" | "其他";
export type type_iconType = "chat" | "game-controller" | "flag";
export interface type_item {id: string;type: type_itemType;platformName: string;account: string;password: string;
}
// 定义 SectionData 类型
export type SectionData = {title: type_itemType;show: boolean;data: type_item[];
};
utils/Storage.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
export const set = async (key: string, value: string) => {try {return await AsyncStorage.setItem(key, value);} catch (e) {console.log(e);}
};
export const get = async (key: string) => {try {return await AsyncStorage.getItem(key);} catch (e) {console.log(e);}
};
export const del = async (key: string) => {try {return await AsyncStorage.removeItem(key);} catch (e) {console.log(e);}
};
utils/UUID.ts
import "react-native-get-random-values";
import { v4 } from "uuid";
export const getUUID = () => {return v4();
};