React Native【实战范例】账号管理(含转换分组列表数据的封装,分组折叠的实现,账号的增删改查,表单校验等)

发布于:2025-06-26 ⋅ 阅读:(13) ⋅ 点赞:(0)

最终效果

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

技术要点

将普通数组转换为分组列表

// 转换函数
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();
};

网站公告

今日签到

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