请添加图片描述

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

单选框和复选框长得很像,但用途完全不同。复选框是"多选",可以选零个、一个或多个;单选框是"单选",必须且只能选一个。性别选择、支付方式、配送时间……这些"只能选一个"的场景都要用单选框。

这篇文章记录一下我封装 Radio 组件的过程,重点说说它和 Checkbox 的设计差异。


单选框的特殊之处

单选框有个特点:它不能单独存在,必须是一组。

想想看,如果只有一个单选框,选中了就没法取消(单选框点击选中的不会取消),那这个单选框有什么意义?所以单选框天生就是成组使用的。

这就决定了 Radio 组件的设计思路:不是封装单个单选框,而是封装一组单选框

Checkbox 可以单独使用,所以它的接口是 checked + onPress,管理单个选项的状态。Radio 必须成组,所以它的接口是 value + onChange + options,管理整组的状态。


接口设计

interface RadioOption {
  value: string;
  label: string;
  disabled?: boolean;
}

interface RadioProps {
  value: string;
  onChange: (value: string) => void;
  options: RadioOption[];
  color?: ColorType;
  size?: SizeType;
  direction?: 'horizontal' | 'vertical';
  disabled?: boolean;
  style?: ViewStyle;
}

先看 RadioOption,这是单个选项的类型:

  • value 是选项的值,用于标识这个选项。通常是字符串,比如 'male''female'
  • label 是显示的文字,用户看到的是这个。
  • disabled 可以禁用单个选项,比如某个配送方式暂时不可用。

再看 RadioProps,这是整个组件的属性:

  • value 是当前选中的值,对应某个 option 的 value。
  • onChange 是选中值变化时的回调,参数是新选中的 value。
  • options 是所有选项的数组。

这种设计的好处是状态集中管理。不管有多少个选项,外部只需要维护一个 value 状态。对比一下如果用 Checkbox 的方式来实现单选:

// 用 Checkbox 方式实现单选(不推荐)
const [selected, setSelected] = useState('a');

<Checkbox checked={selected === 'a'} onPress={() => setSelected('a')} label="选项A" />
<Checkbox checked={selected === 'b'} onPress={() => setSelected('b')} label="选项B" />
<Checkbox checked={selected === 'c'} onPress={() => setSelected('c')} label="选项C" />
// 用 Radio 方式实现单选(推荐)
const [selected, setSelected] = useState('a');

<Radio
  value={selected}
  onChange={setSelected}
  options={[
    { value: 'a', label: '选项A' },
    { value: 'b', label: '选项B' },
    { value: 'c', label: '选项C' },
  ]}
/>

Radio 的方式明显更简洁,而且 options 可以从接口获取,更灵活。


direction 属性的设计

direction?: 'horizontal' | 'vertical';

单选框组可以垂直排列水平排列

  • vertical(默认):选项从上到下排列,适合选项文字较长或选项较多的情况。
  • horizontal:选项从左到右排列,适合选项较少且文字较短的情况,比如"是/否"、“男/女”。

这个属性在 Checkbox 里没有,因为 Checkbox 通常是单独使用的,排列方式由外部控制。Radio 是成组的,排列方式应该由组件内部控制。


尺寸配置

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

单选框的视觉结构是两个同心圆:外圈是边框,内圈是选中时的实心圆。

  • outer 是外圈的直径
  • inner 是内圈的直径,大约是外圈的一半
  • fontSize 是旁边文字的大小

inner 设为 outer 的一半是有讲究的。太大的话内圈会贴着外圈,看不出是两个圆;太小的话内圈太小,不够醒目。一半的比例刚好,既能看出两个圆的层次,内圈又足够明显。


外圈的渲染

<View
  style={[
    styles.outer,
    {
      width: sizeMap[size].outer,
      height: sizeMap[size].outer,
      borderRadius: sizeMap[size].outer / 2,
      borderColor: isSelected ? colorValue : UITheme.colors.gray[400],
      opacity: isDisabled ? 0.5 : 1,
    },
  ]}
>

外圈是一个圆形边框

  • borderRadius 设为宽度的一半,让方形变成圆形。
  • borderColor 根据选中状态变化:选中时是主题色,未选中时是灰色。这个颜色变化是重要的视觉反馈。
  • opacity 在禁用状态下变成 0.5。

基础样式 styles.outer 定义了边框宽度和居中对齐:

outer: { borderWidth: 2, alignItems: 'center', justifyContent: 'center' },

borderWidth 用 2px,和 Checkbox 保持一致。alignItems 和 justifyContent 让内圈在外圈中居中。


内圈的渲染

{isSelected && (
  <View
    style={[
      styles.inner,
      {
        width: sizeMap[size].inner,
        height: sizeMap[size].inner,
        borderRadius: sizeMap[size].inner / 2,
        backgroundColor: colorValue,
      },
    ]}
  />
)}

内圈只在选中时显示:

  • {isSelected && ...} 条件渲染,未选中时不渲染内圈。
  • backgroundColor 是主题色,形成一个实心的彩色圆。
  • borderRadius 同样是宽度的一半,保证是圆形。

内圈没有边框,就是一个纯色的圆。它和外圈的颜色一致,形成视觉上的呼应。


选项的渲染

{options.map((option) => {
  const isSelected = value === option.value;
  const isDisabled = disabled || option.disabled;

  return (
    <TouchableOpacity
      key={option.value}
      style={[styles.option, direction === 'horizontal' && styles.optionHorizontal]}
      onPress={() => !isDisabled && onChange(option.value)}
      activeOpacity={0.7}
      disabled={isDisabled}
    >
      {/* 外圈和内圈 */}
      <Text style={[styles.label, { fontSize: sizeMap[size].fontSize, opacity: isDisabled ? 0.5 : 1 }]}>
        {option.label}
      </Text>
    </TouchableOpacity>
  );
})}

遍历 options 渲染每个选项,每个选项包含圆圈文字

  • isSelected 通过比较 value === option.value 判断,这就是为什么 value 要用字符串而不是索引。
  • isDisabled 要考虑两种情况:整个组禁用(disabled)或单个选项禁用(option.disabled)。
  • onPress 调用 onChange 并传入当前选项的 value。注意这里不是取反,而是直接传 value,因为单选框点击就是选中,不存在取消。

样式方面:

option: { flexDirection: 'row', alignItems: 'center', marginBottom: UITheme.spacing.sm },
optionHorizontal: { marginRight: UITheme.spacing.lg, marginBottom: 0 },
  • option 是基础样式,水平排列圆圈和文字,底部有间距。
  • optionHorizontal 是水平方向时的样式,把底部间距改成右侧间距。

容器的布局

<View style={[styles.container, direction === 'horizontal' && styles.horizontal, style]}>
  {/* 选项列表 */}
</View>

容器根据 direction 切换布局:

container: {},
horizontal: { flexDirection: 'row', flexWrap: 'wrap' },
  • vertical 方向不需要特殊样式,View 默认就是垂直排列。
  • horizontal 方向设置 flexDirection: 'row' 水平排列,flexWrap: 'wrap' 允许换行(选项太多时)。

完整代码

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

interface RadioOption {
  value: string;
  label: string;
  disabled?: boolean;
}

interface RadioProps {
  value: string;
  onChange: (value: string) => void;
  options: RadioOption[];
  color?: ColorType;
  size?: SizeType;
  direction?: 'horizontal' | 'vertical';
  disabled?: boolean;
  style?: ViewStyle;
}

export const Radio: React.FC<RadioProps> = ({
  value,
  onChange,
  options,
  color = 'primary',
  size = 'md',
  direction = 'vertical',
  disabled = false,
  style,
}) => {
  const colorValue = UITheme.colors[color];
  const sizeMap: Record<SizeType, { outer: number; inner: number; fontSize: number }> = {
    sm: { outer: 16, inner: 8, fontSize: 12 },
    md: { outer: 20, inner: 10, fontSize: 14 },
    lg: { outer: 24, inner: 12, fontSize: 16 },
  };

  return (
    <View style={[styles.container, direction === 'horizontal' && styles.horizontal, style]}>
      {options.map((option) => {
        const isSelected = value === option.value;
        const isDisabled = disabled || option.disabled;

        return (
          <TouchableOpacity
            key={option.value}
            style={[styles.option, direction === 'horizontal' && styles.optionHorizontal]}
            onPress={() => !isDisabled && onChange(option.value)}
            activeOpacity={0.7}
            disabled={isDisabled}
          >
            <View
              style={[
                styles.outer,
                {
                  width: sizeMap[size].outer,
                  height: sizeMap[size].outer,
                  borderRadius: sizeMap[size].outer / 2,
                  borderColor: isSelected ? colorValue : UITheme.colors.gray[400],
                  opacity: isDisabled ? 0.5 : 1,
                },
              ]}
            >
              {isSelected && (
                <View
                  style={[
                    styles.inner,
                    {
                      width: sizeMap[size].inner,
                      height: sizeMap[size].inner,
                      borderRadius: sizeMap[size].inner / 2,
                      backgroundColor: colorValue,
                    },
                  ]}
                />
              )}
            </View>
            <Text
              style={[
                styles.label,
                { fontSize: sizeMap[size].fontSize, opacity: isDisabled ? 0.5 : 1 },
              ]}
            >
              {option.label}
            </Text>
          </TouchableOpacity>
        );
      })}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {},
  horizontal: { flexDirection: 'row', flexWrap: 'wrap' },
  option: { flexDirection: 'row', alignItems: 'center', marginBottom: UITheme.spacing.sm },
  optionHorizontal: { marginRight: UITheme.spacing.lg, marginBottom: 0 },
  outer: { borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
  inner: {},
  label: { marginLeft: UITheme.spacing.sm, color: UITheme.colors.gray[700] },
});

使用场景

性别选择:

const [gender, setGender] = useState('');

<Radio
  value={gender}
  onChange={setGender}
  options={[
    { value: 'male', label: '男' },
    { value: 'female', label: '女' },
  ]}
  direction="horizontal"
/>

性别选择用水平排列,因为只有两个选项,文字也短。初始值设为空字符串,表示还没选择。


支付方式:

const [payment, setPayment] = useState('alipay');

<Radio
  value={payment}
  onChange={setPayment}
  options={[
    { value: 'alipay', label: '支付宝' },
    { value: 'wechat', label: '微信支付' },
    { value: 'card', label: '银行卡', disabled: true },
  ]}
/>

支付方式用垂直排列,选项较多。银行卡选项禁用,可能是因为还没绑定银行卡。初始值设为 'alipay',默认选中支付宝。


配送时间:

const [deliveryTime, setDeliveryTime] = useState('anytime');

<Radio
  value={deliveryTime}
  onChange={setDeliveryTime}
  options={[
    { value: 'anytime', label: '任意时间' },
    { value: 'workday', label: '工作日(周一至周五)' },
    { value: 'weekend', label: '周末(周六至周日)' },
    { value: 'night', label: '晚间(18:00-22:00)' },
  ]}
  size="sm"
/>

配送时间选项较多,文字也较长,用垂直排列。用小号尺寸,因为这不是页面的主要内容。


问卷调查:

const questions = [
  {
    id: 1,
    title: '您对本次服务的满意度?',
    options: [
      { value: '5', label: '非常满意' },
      { value: '4', label: '满意' },
      { value: '3', label: '一般' },
      { value: '2', label: '不满意' },
      { value: '1', label: '非常不满意' },
    ],
  },
  // 更多问题...
];

const [answers, setAnswers] = useState<Record<number, string>>({});

<View>
  {questions.map(q => (
    <View key={q.id} style={{ marginBottom: 24 }}>
      <Text style={{ fontSize: 16, fontWeight: '600', marginBottom: 12 }}>{q.title}</Text>
      <Radio
        value={answers[q.id] || ''}
        onChange={(v) => setAnswers({ ...answers, [q.id]: v })}
        options={q.options}
      />
    </View>
  ))}
</View>

问卷调查场景展示了如何管理多组单选框的状态:

  • 用一个对象 answers 存储所有答案,key 是问题 id,value 是选中的值。
  • 每个 Radio 的 onChange 更新对应问题的答案。

和表单库配合:

import { Controller, useForm } from 'react-hook-form';

const { control, handleSubmit } = useForm({
  defaultValues: { gender: '' }
});

<Controller
  control={control}
  name="gender"
  rules={{ required: '请选择性别' }}
  render={({ field: { onChange, value }, fieldState: { error } }) => (
    <View>
      <Radio
        value={value}
        onChange={onChange}
        options={[
          { value: 'male', label: '男' },
          { value: 'female', label: '女' },
        ]}
        direction="horizontal"
        color={error ? 'danger' : 'primary'}
      />
      {error && <Text style={{ color: 'red', marginTop: 4 }}>{error.message}</Text>}
    </View>
  )}
/>

和 React Hook Form 配合使用:

  • Controller 包裹 Radio,让表单库管理状态
  • field.onChange 直接传给 Radio 的 onChange
  • rules.required 设置必填验证
  • 验证失败时 Radio 变成红色,并显示错误信息

和 Checkbox 的对比

最后总结一下 Radio 和 Checkbox 的区别:

特性 Radio Checkbox
选择数量 只能选一个 可以选多个
取消选择 不能取消,只能选其他 可以取消
组件粒度 一组选项是一个组件 单个选项是一个组件
状态类型 单个值(string) 布尔值或数组
视觉形状 圆形 方形

选择用哪个,主要看业务需求:

  • 必须选一个,用 Radio:性别、支付方式、配送时间
  • 可以选多个或不选,用 Checkbox:兴趣标签、筛选条件、同意协议

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

Logo

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

更多推荐