最终效果
技术要点
将普通数组转换为分组列表
// 转换函数
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, // 默认显示所有的sectionHeade
data: [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>
<Switch
style={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;
}
<>
<TextInput
style={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, // 默认显示所有的sectionHeade
data: [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 (
<View
style={[
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>
<TouchableOpacity
style={styles.arrowButton}
onPress={() => {
setSectionData((prevSectionData) => {
return prevSectionData.map((item) => {
if (item.title === section.title) {
return { ...item, show: !item.show };
} else {
return item;
}
});
});
}}
>
<Entypo
name={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 (
<TouchableOpacity
style={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>
<View
style={{
flex: 1,
paddingTop: insets.top, // 顶部安全区域
paddingBottom: insets.bottom, // 底部安全区域
}}
>
<View style={styles.titleBox}>
<Text style={styles.title}>账号管理</Text>
<Switch
style={styles.switch}
ios_backgroundColor="#3e3e3e"
onValueChange={(value) => {
setShowPassword(value);
}}
value={showPassword}
/>
</View>
<SectionList
sections={sectionData}
keyExtractor={(item, index) => `${item}-${index}`}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
contentContainerStyle={styles.listContainer}
/>
<TouchableOpacity
style={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) => (
<TouchableOpacity
activeOpacity={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 (
<>
<TextInput
style={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 (
<>
<TextInput
style={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 (
<>
<TextInput
style={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 (
<Modal
animationType="fade"
transparent={true}
visible={visible}
onRequestClose={hide}
>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.winBox}
>
<View style={styles.contentBox}>
<View style={styles.titleBox}>
<Text style={styles.titleText}>
{id ? "修改账号" : "添加账号"}
</Text>
{/* 使用 AntDesign 图标集 */}
<AntDesign
style={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()}
<TouchableOpacity
style={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();
};