请添加图片描述

项目开源地址:https://atomgit.com/nutpi/rn_for_openharmony_element

上周产品经理拿着原型图来找我,说要做一个商品筛选页面,用户可以勾选多个品牌、多个价格区间。我看了一眼,这不就是一堆复选框嘛。但仔细一想,复选框这东西还真有点讲究。

今天就把我封装 Checkbox 组件的过程记录下来,从需求分析到最终代码,一步步来。


产品给的原型图里有这么几种复选框的用法:

第一种是最普通的,一个方框加一段文字,点一下打勾,再点一下取消。品牌筛选、标签选择都是这种。

第二种是全选功能。上面有个"全选"复选框,下面是一堆子选项。全选框有三种状态:全没选的时候是空的,全选了是打勾,选了一部分是个横杠。这个横杠状态叫"半选"或者"不确定状态"。

第三种是禁用状态。有些选项因为库存不足或者其他原因不能选,要灰显出来。

第四种是不同颜色。成功的用绿色,警告的用橙色,危险的用红色。

需求清楚了,开始设计接口。


interface CheckboxProps {
  checked: boolean;
  onPress: (checked: boolean) => void;
  label?: string;
  color?: ColorType;
  size?: SizeType;
  disabled?: boolean;
  indeterminate?: boolean;
  style?: ViewStyle;
}

这个接口定义了复选框的所有配置项,下面逐个解释:

  • checkedonPress 是核心属性。checked 表示当前是否选中,onPress 是点击时的回调。这是受控组件的写法,状态由外部管理。为什么用受控模式?因为复选框很少单独使用,通常是一组复选框配合使用,需要在外部统一管理状态。

  • onPress 的参数是新的 checked 值,不是事件对象。调用方直接拿到新值,不用自己取反,写起来就是 onPress={setChecked} 这么简单。

  • indeterminate 是半选状态。这个词有点长,但它是业界通用的叫法,HTML 的 checkbox 也用这个词。半选状态只是视觉上的,逻辑上还是 checked 或者 unchecked。

  • label 是复选框旁边的文字。大多数情况下复选框都要配文字说明,把这个功能内置进来,省得每次都要自己写 Text 组件。


const sizeMap: Record<SizeType, { box: number; icon: number; fontSize: number }> = {
  sm: { box: 16, icon: 10, fontSize: 12 },
  md: { box: 20, icon: 12, fontSize: 14 },
  lg: { box: 24, icon: 14, fontSize: 16 },
};

尺寸配置定义了三种规格下的具体数值:

  • box 是方框的边长。sm 是 16px,适合紧凑列表;md 是 20px,默认尺寸;lg 是 24px,适合需要强调的场景。

  • icon 是勾或横杠的字号,大概是 box 的 60%。这个比例是试出来的,太大会撑满方框,太小又看不清。

  • fontSize 是旁边文字的大小,和 box 要匹配。小方框配小字,大方框配大字,视觉上才协调。


<View
  style={[
    styles.box,
    {
      width: sizeMap[size].box,
      height: sizeMap[size].box,
      borderRadius: UITheme.borderRadius.sm,
      backgroundColor: isActive ? colorValue : 'transparent',
      borderColor: isActive ? colorValue : UITheme.colors.gray[400],
      opacity: disabled ? 0.5 : 1,
    },
  ]}
>

方框的样式逻辑是整个组件的核心:

  • isActivechecked || indeterminate,只要选中或者半选,方框就是激活状态。

  • 激活状态下背景色和边框色都是主题色,形成一个实心的彩色方框。未激活状态下背景透明,边框是灰色,形成一个空心的灰色方框。这个设计让两种状态的视觉差异很明显。

  • borderRadius 用的是 sm(4px),让方框有一点圆角但不会太圆。复选框传统上是方的,圆角太大会像单选框。

  • disabled 状态下 opacity 变成 0.5,整个复选框变淡,这是通用的禁用状态表示方式。


{checked && <Text style={[styles.icon, { fontSize: sizeMap[size].icon }]}>✓</Text>}
{indeterminate && !checked && <Text style={[styles.icon, { fontSize: sizeMap[size].icon }]}>−</Text>}

勾和横杠的显示逻辑:

  • 选中状态显示 ✓,半选状态显示 −。用 Text 组件渲染 Unicode 字符,不用引入图标库。

  • 第二行的条件是 indeterminate && !checked。如果同时传了 checked 和 indeterminate,checked 优先。这是因为 indeterminate 只是一个视觉状态,逻辑上 checked 才是真正的值。

  • icon 的样式是白色粗体:{ color: UITheme.colors.white, fontWeight: '700' },在彩色背景上很清晰。


<TouchableOpacity
  style={[styles.container, style]}
  onPress={() => !disabled && onPress(!checked)}
  activeOpacity={0.7}
  disabled={disabled}
>

点击事件的处理有几个要点:

  • 整个复选框(方框 + 文字)都可以点击,不只是方框。这样点击区域更大,更容易操作,这是我踩过的坑,一开始只让方框可点,用户反馈说不好点。

  • onPress 里先检查 disabled,禁用状态下不触发回调。然后调用外部传入的 onPress,参数是 checked 的取反值。

  • 即使是半选状态,点击后也是变成 checked。因为半选只是视觉状态,点击的逻辑还是在 checked 和 unchecked 之间切换。


{label && (
  <Text
    style={[
      styles.label,
      { fontSize: sizeMap[size].fontSize, opacity: disabled ? 0.5 : 1 },
    ]}
  >
    {label}
  </Text>
)}

标签的渲染逻辑:

  • 有 label 才渲染,没有就不渲染,用 {label && ...} 短路求值实现。

  • label 的基础样式是 { marginLeft: 8px, color: gray[700] },让文字和方框之间有间距,颜色比纯黑淡一点。

  • disabled 状态下 label 也要变淡,和方框保持一致的视觉效果。


完整代码:

import React from 'react';
import { TouchableOpacity, View, Text, StyleSheet, ViewStyle } from 'react-native';
import { UITheme, ColorType, SizeType } from './theme';

interface CheckboxProps {
  checked: boolean;
  onPress: (checked: boolean) => void;
  label?: string;
  color?: ColorType;
  size?: SizeType;
  disabled?: boolean;
  indeterminate?: boolean;
  style?: ViewStyle;
}

export const Checkbox: React.FC<CheckboxProps> = ({
  checked,
  onPress,
  label,
  color = 'primary',
  size = 'md',
  disabled = false,
  indeterminate = false,
  style,
}) => {
  const colorValue = UITheme.colors[color];
  const sizeMap: Record<SizeType, { box: number; icon: number; fontSize: number }> = {
    sm: { box: 16, icon: 10, fontSize: 12 },
    md: { box: 20, icon: 12, fontSize: 14 },
    lg: { box: 24, icon: 14, fontSize: 16 },
  };

  const isActive = checked || indeterminate;

  return (
    <TouchableOpacity
      style={[styles.container, style]}
      onPress={() => !disabled && onPress(!checked)}
      activeOpacity={0.7}
      disabled={disabled}
    >
      <View
        style={[
          styles.box,
          {
            width: sizeMap[size].box,
            height: sizeMap[size].box,
            borderRadius: UITheme.borderRadius.sm,
            backgroundColor: isActive ? colorValue : 'transparent',
            borderColor: isActive ? colorValue : UITheme.colors.gray[400],
            opacity: disabled ? 0.5 : 1,
          },
        ]}
      >
        {checked && <Text style={[styles.icon, { fontSize: sizeMap[size].icon }]}>✓</Text>}
        {indeterminate && !checked && <Text style={[styles.icon, { fontSize: sizeMap[size].icon }]}>−</Text>}
      </View>
      {label && (
        <Text
          style={[
            styles.label,
            { fontSize: sizeMap[size].fontSize, opacity: disabled ? 0.5 : 1 },
          ]}
        >
          {label}
        </Text>
      )}
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  container: { flexDirection: 'row', alignItems: 'center' },
  box: { borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
  icon: { color: UITheme.colors.white, fontWeight: '700' },
  label: { marginLeft: UITheme.spacing.sm, color: UITheme.colors.gray[700] },
});

下面是几个实际使用场景。

最简单的用法,单个复选框:

const [agreed, setAgreed] = useState(false);

<Checkbox
  checked={agreed}
  onPress={setAgreed}
  label="我已阅读并同意用户协议"
/>

一行代码搞定。checked 绑定状态,onPress 直接传 setState,label 写上文字。


多选场景,比如选择兴趣标签:

const [selected, setSelected] = useState<string[]>([]);
const options = ['音乐', '电影', '读书', '运动', '旅行', '美食'];

const toggleOption = (option: string) => {
  setSelected(prev =>
    prev.includes(option)
      ? prev.filter(item => item !== option)
      : [...prev, option]
  );
};

<View>
  {options.map(option => (
    <Checkbox
      key={option}
      checked={selected.includes(option)}
      onPress={() => toggleOption(option)}
      label={option}
      style={{ marginBottom: 8 }}
    />
  ))}
</View>

这个例子的关键点:

  • 数组存储选中的选项,而不是为每个选项单独建一个 state。
  • toggleOption 函数处理选中和取消选中的逻辑:已选中的就从数组里删掉,没选中的就加进去。
  • 每个 Checkbox 的 checked 通过 selected.includes(option) 计算得出。

全选功能,这个稍微复杂一点:

const [items, setItems] = useState([
  { id: 1, name: '苹果', checked: false },
  { id: 2, name: '香蕉', checked: true },
  { id: 3, name: '橙子', checked: false },
]);

const allChecked = items.every(item => item.checked);
const someChecked = items.some(item => item.checked);

const toggleAll = () => {
  const newValue = !allChecked;
  setItems(items.map(item => ({ ...item, checked: newValue })));
};

const toggleItem = (id: number) => {
  setItems(items.map(item =>
    item.id === id ? { ...item, checked: !item.checked } : item
  ));
};

<View>
  <Checkbox
    checked={allChecked}
    indeterminate={someChecked && !allChecked}
    onPress={toggleAll}
    label="全选"
  />
  <View style={{ marginLeft: 24, marginTop: 8 }}>
    {items.map(item => (
      <Checkbox
        key={item.id}
        checked={item.checked}
        onPress={() => toggleItem(item.id)}
        label={item.name}
        style={{ marginTop: 8 }}
      />
    ))}
  </View>
</View>

全选框的状态由子选项决定:

  • 全部选中:checked = true, indeterminate = false
  • 部分选中:checked = false, indeterminate = true
  • 全部未选:checked = false, indeterminate = false

点击全选框时,如果当前不是全选状态就全选,如果已经全选就全取消。子选项缩进 24px,视觉上形成层级关系。


表单验证场景:

const [agreed, setAgreed] = useState(false);
const [error, setError] = useState('');

const handleSubmit = () => {
  if (!agreed) {
    setError('请先同意用户协议');
    return;
  }
  setError('');
  // 提交表单
};

<View>
  <Checkbox
    checked={agreed}
    onPress={(v) => {
      setAgreed(v);
      if (v) setError('');
    }}
    label="我已阅读并同意用户协议"
    color={error ? 'danger' : 'primary'}
  />
  {error && <Text style={{ color: 'red', marginTop: 4 }}>{error}</Text>}
  <Button title="提交" onPress={handleSubmit} style={{ marginTop: 16 }} />
</View>

这个例子展示了错误状态的处理:

  • 没勾选就提交时显示错误提示,复选框变成红色
  • 勾选后错误消失,颜色恢复正常
  • 通过 color 属性动态切换颜色,不需要额外的样式处理。

最后说说我踩过的坑:

第一个坑是点击区域太小。 一开始我只让方框可以点击,用户反馈说不好点。后来改成整个区域(方框 + 文字)都可以点击,体验好多了。

第二个坑是半选状态的逻辑。 一开始我把 indeterminate 当成第三种状态,和 checked、unchecked 并列。后来发现这样逻辑很乱,改成 indeterminate 只是视觉状态,逻辑上还是二元的,就清晰多了。

第三个坑是动画。 我试过给勾加个缩放动画,从 0 放大到 1。效果是挺好看的,但在列表里快速点击多个复选框时会卡。后来去掉了动画,流畅度更重要。


再补充几个进阶用法。

复选框组的封装:

如果项目里多选场景很多,可以封装一个 CheckboxGroup 组件:

interface CheckboxGroupProps {
  options: { label: string; value: string; disabled?: boolean }[];
  value: string[];
  onChange: (value: string[]) => void;
}

const CheckboxGroup: React.FC<CheckboxGroupProps> = ({ options, value, onChange }) => {
  const toggle = (optionValue: string) => {
    onChange(
      value.includes(optionValue)
        ? value.filter(v => v !== optionValue)
        : [...value, optionValue]
    );
  };

  return (
    <View>
      {options.map(option => (
        <Checkbox
          key={option.value}
          checked={value.includes(option.value)}
          onPress={() => toggle(option.value)}
          label={option.label}
          disabled={option.disabled}
          style={{ marginBottom: 8 }}
        />
      ))}
    </View>
  );
};

使用起来就简单多了:

const [selected, setSelected] = useState(['music']);

<CheckboxGroup
  options={[
    { label: '音乐', value: 'music' },
    { label: '电影', value: 'movie' },
    { label: '读书', value: 'reading', disabled: true },
  ]}
  value={selected}
  onChange={setSelected}
/>

这样封装的好处是:

  • 统一管理选中状态,不用在每个 Checkbox 上写 checked 和 onPress
  • options 配置化,数据和 UI 分离,方便从接口获取选项
  • 支持禁用单个选项,通过 disabled 字段控制

和表单库配合使用:

如果项目用了 Formik 或 React Hook Form 这类表单库,Checkbox 可以这样接入:

// 配合 React Hook Form
import { Controller, useForm } from 'react-hook-form';

const { control, handleSubmit } = useForm({
  defaultValues: { agreed: false }
});

<Controller
  control={control}
  name="agreed"
  rules={{ required: '请同意用户协议' }}
  render={({ field: { onChange, value }, fieldState: { error } }) => (
    <View>
      <Checkbox
        checked={value}
        onPress={onChange}
        label="我已阅读并同意用户协议"
        color={error ? 'danger' : 'primary'}
      />
      {error && <Text style={{ color: 'red' }}>{error.message}</Text>}
    </View>
  )}
/>

关键点是:

  • Controller 包裹 Checkbox,让表单库管理状态
  • field.onChange 直接传给 onPress,因为我们的 onPress 参数就是新值
  • fieldState.error 用来显示验证错误和改变颜色

无障碍支持:

为了让视障用户也能使用复选框,需要添加无障碍属性:

<TouchableOpacity
  accessible={true}
  accessibilityRole="checkbox"
  accessibilityState={{ checked, disabled }}
  accessibilityLabel={label}
  // ... 其他属性
>

这几个属性的作用:

  • accessibilityRole=“checkbox” 告诉屏幕阅读器这是一个复选框
  • accessibilityState 传递当前状态,屏幕阅读器会朗读"已选中"或"未选中"
  • accessibilityLabel 是朗读的内容,通常就是 label 的文字

在 OpenHarmony 上这些属性同样有效,能让应用对所有用户都友好。


性能优化:

在长列表里使用复选框时,要注意性能:

// 用 useCallback 缓存 toggle 函数
const toggleItem = useCallback((id: number) => {
  setItems(prev => prev.map(item =>
    item.id === id ? { ...item, checked: !item.checked } : item
  ));
}, []);

// 用 React.memo 包裹列表项
const CheckboxItem = React.memo(({ item, onToggle }) => (
  <Checkbox
    checked={item.checked}
    onPress={() => onToggle(item.id)}
    label={item.name}
  />
));

// 在 FlatList 里使用
<FlatList
  data={items}
  renderItem={({ item }) => <CheckboxItem item={item} onToggle={toggleItem} />}
  keyExtractor={item => item.id.toString()}
/>

优化要点:

  • useCallback 缓存回调函数,避免每次渲染都创建新函数
  • React.memo 包裹列表项组件,props 不变就不重新渲染
  • FlatList 代替 ScrollView + map,自动回收屏幕外的组件

这些优化在选项很多(比如几十上百个)的时候特别重要。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐