请添加图片描述

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

开关是设置页面的常客。WiFi 开关、通知开关、深色模式开关……用户对这个组件太熟悉了,滑一下或者点一下,状态就变了。看起来简单,但要做好还是有些门道的。

为什么不用原生 Switch

React Native 自带了 Switch 组件,为什么还要自己封装?

原生 Switch 有几个问题。首先是样式不统一,在不同平台上长得不一样,想改样式也很难改。其次是功能有限,不支持自定义颜色、不支持添加标签、尺寸也不能调。

自己封装的好处是完全可控。想要什么颜色就什么颜色,想要多大就多大,还能加标签、加动画,和整个 UI 库的风格保持一致。

开关的交互本质

在写代码之前,先想想开关是个什么东西。

从交互角度看,开关就是一个二元选择器,只有开和关两种状态。用户点击或滑动,状态就切换。这和 Checkbox 很像,但视觉上更直观——开关的位置直接告诉你当前是开还是关。

从视觉角度看,开关由两部分组成:轨道(track)和滑块(thumb)。轨道是背景,滑块在轨道上左右移动。开的时候滑块在右边,轨道是彩色的;关的时候滑块在左边,轨道是灰色的。

理解了这些,代码就好写了。

接口设计

interface SwitchProps {
  value: boolean;
  onValueChange: (value: boolean) => void;
  color?: ColorType;
  size?: SizeType;
  disabled?: boolean;
  label?: string;
  labelPosition?: 'left' | 'right';
  style?: ViewStyle;
}

value 和 onValueChange 是受控组件的标准写法。value 是当前状态,onValueChange 是状态变化时的回调。为什么用受控模式而不是非受控?因为开关的状态通常需要和其他逻辑联动,比如打开通知开关后要请求通知权限,受控模式让这种联动更容易实现。

onValueChange 的参数是新的值,不是事件对象。这样用起来更方便,直接 onValueChange={setValue} 就行,不用写 e => setValue(e.target.value) 这种。

color 控制开启状态的颜色。默认是 primary,但有时候可能想用 success(绿色)表示"好的状态",或者用 danger(红色)表示"危险操作"。

size 提供三档尺寸。设置页面的开关可能用 md,表单里的开关可能用 sm,需要强调的开关可能用 lg。

disabled 禁用开关。禁用后点击无效,视觉上也会变淡,告诉用户这个开关现在不能操作。

label 和 labelPosition 用于在开关旁边显示文字。很多设置项都是"文字 + 开关"的组合,把这个功能内置到组件里,用起来更方便。labelPosition 控制文字在左边还是右边,默认在右边。

尺寸配置的细节

const sizeMap: Record<SizeType, { width: number; height: number; thumbSize: number }> = {
  sm: { width: 36, height: 20, thumbSize: 16 },
  md: { width: 44, height: 24, thumbSize: 20 },
  lg: { width: 52, height: 28, thumbSize: 24 },
};

这个配置定义了三种尺寸下轨道的宽高和滑块的大小。

先说宽高比。轨道的宽高比大约是 1.8:1,这个比例让开关看起来比较舒服。太宽会显得扁,太窄会显得像个圆形按钮。

再说滑块大小。滑块比轨道高度小 4px,这样滑块和轨道边缘之间有 2px 的间隙。这个间隙很重要,没有间隙的话滑块会贴着轨道边缘,看起来很挤。

sm 尺寸的轨道是 36x20,滑块是 16x16。这个尺寸比较小,适合空间紧张的场景,比如列表项里的开关。

md 尺寸是 44x24,滑块 20x20。这是默认尺寸,大多数场景都适用。44px 的宽度也符合最小点击区域的建议。

lg 尺寸是 52x28,滑块 24x24。当开关是页面的主要操作时用这个尺寸,比如"一键开启所有通知"这种重要开关。

滑块位置的计算

const { width, height, thumbSize } = sizeMap[size];
const translateX = value ? width - thumbSize - 4 : 2;

这两行代码计算滑块的水平位置。

先从 sizeMap 里取出当前尺寸的配置。用解构赋值让后面的代码更简洁,不用写 sizeMap[size].width 这种长长的路径。

translateX 是滑块的水平偏移量。关闭状态时偏移 2px,让滑块贴着左边但留一点间隙。开启状态时偏移 width - thumbSize - 4,这个值让滑块贴着右边,同样留 2px 间隙。

为什么是 4 不是 2?因为要减去左边的 2px 间隙和右边的 2px 间隙,一共 4px。

举个例子,md 尺寸下:

  • 关闭:translateX = 2,滑块左边缘在 2px 位置
  • 开启:translateX = 44 - 20 - 4 = 20,滑块左边缘在 20px 位置,右边缘在 40px 位置,距离轨道右边缘(44px)还有 4px,但滑块宽度占了 2px,所以实际间隙是 2px

这个计算有点绕,但结果就是滑块在两端都有 2px 的间隙,看起来很对称。

轨道的渲染

<View
  style={[
    styles.track,
    {
      width,
      height,
      borderRadius: height / 2,
      backgroundColor: value ? colorValue : UITheme.colors.gray[300],
      opacity: disabled ? 0.5 : 1,
    },
  ]}
>

轨道是一个圆角矩形。borderRadius 设为高度的一半,让两端变成半圆形,这是开关的经典造型。

背景色根据 value 变化。开启时用传入的颜色(默认是 primary 紫色),关闭时用灰色。这个颜色变化是开关状态最直观的视觉反馈。

disabled 状态下 opacity 变成 0.5,整个开关变得半透明。这是一种通用的禁用状态表示方式,用户一眼就能看出这个开关不能操作。

styles.track 里只有一个 justifyContent: 'center',让滑块垂直居中。水平位置由 translateX 控制,不需要在这里处理。

滑块的渲染

<View
  style={[
    styles.thumb,
    {
      width: thumbSize,
      height: thumbSize,
      borderRadius: thumbSize / 2,
      transform: [{ translateX }],
    },
  ]}
/>

滑块是一个圆形。宽高相等,borderRadius 是宽度的一半,就变成了圆形。

transform: [{ translateX }] 控制滑块的水平位置。React Native 的 transform 是个数组,可以组合多个变换。这里只用了 translateX,让滑块水平移动。

styles.thumb 里定义了背景色(白色)和阴影。白色滑块在彩色或灰色轨道上都很清晰。阴影让滑块有一点立体感,看起来像是"浮"在轨道上面。

thumb: { backgroundColor: UITheme.colors.white, ...UITheme.shadow.sm },

阴影用的是主题里定义的 sm 级别,很淡的阴影。开关本身就小,阴影太重会显得很奇怪。

点击事件的处理

<TouchableOpacity
  onPress={() => !disabled && onValueChange(!value)}
  activeOpacity={0.8}
  disabled={disabled}
>

整个开关用 TouchableOpacity 包裹,点击任何位置都能切换状态。

onPress 里先检查 disabled,禁用状态下不触发回调。然后调用 onValueChange,传入当前值的取反。这样每次点击状态就会切换。

为什么同时用 !disabled &&disabled={disabled}?看起来重复了,但其实有区别。disabled={disabled} 会禁用 TouchableOpacity 的点击效果(透明度变化),!disabled && 确保即使点击效果没被禁用,回调也不会触发。双重保险,更安全。

activeOpacity={0.8} 让点击时的透明度变化更温和。默认值 0.2 变化太大,0.8 刚刚好,用户能感知到点击了,但不会太刺眼。

标签的处理

if (!label) return <View style={style}>{switchElement}</View>;

return (
  <View style={[styles.container, style]}>
    {labelPosition === 'left' && <Text style={styles.label}>{label}</Text>}
    {switchElement}
    {labelPosition === 'right' && <Text style={styles.label}>{label}</Text>}
  </View>
);

没有 label 时直接返回开关元素,外面包一层 View 是为了应用 style 属性。

有 label 时,根据 labelPosition 决定文字放在开关的左边还是右边。用条件渲染 {labelPosition === 'left' && ...} 来控制,比用三元表达式更清晰。

styles.container 设置了 flexDirection: 'row'alignItems: 'center',让开关和文字水平排列并垂直居中。

styles.label 设置了字号、颜色和水平间距。marginHorizontal 让文字和开关之间有一定距离,不会挤在一起。

label: {
  fontSize: UITheme.fontSize.md,
  color: UITheme.colors.gray[700],
  marginHorizontal: UITheme.spacing.sm,
},

字号用 md(14px),是正文字号,和开关搭配比较协调。颜色用 gray[700],比纯黑色淡一点,不会太抢眼。

完整代码

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

interface SwitchProps {
  value: boolean;
  onValueChange: (value: boolean) => void;
  color?: ColorType;
  size?: SizeType;
  disabled?: boolean;
  label?: string;
  labelPosition?: 'left' | 'right';
  style?: ViewStyle;
}

export const Switch: React.FC<SwitchProps> = ({
  value,
  onValueChange,
  color = 'primary',
  size = 'md',
  disabled = false,
  label,
  labelPosition = 'right',
  style,
}) => {
  const colorValue = UITheme.colors[color];
  const sizeMap: Record<SizeType, { width: number; height: number; thumbSize: number }> = {
    sm: { width: 36, height: 20, thumbSize: 16 },
    md: { width: 44, height: 24, thumbSize: 20 },
    lg: { width: 52, height: 28, thumbSize: 24 },
  };

  const { width, height, thumbSize } = sizeMap[size];
  const translateX = value ? width - thumbSize - 4 : 2;

  const switchElement = (
    <TouchableOpacity
      onPress={() => !disabled && onValueChange(!value)}
      activeOpacity={0.8}
      disabled={disabled}
    >
      <View
        style={[
          styles.track,
          {
            width,
            height,
            borderRadius: height / 2,
            backgroundColor: value ? colorValue : UITheme.colors.gray[300],
            opacity: disabled ? 0.5 : 1,
          },
        ]}
      >
        <View
          style={[
            styles.thumb,
            {
              width: thumbSize,
              height: thumbSize,
              borderRadius: thumbSize / 2,
              transform: [{ translateX }],
            },
          ]}
        />
      </View>
    </TouchableOpacity>
  );

  if (!label) return <View style={style}>{switchElement}</View>;

  return (
    <View style={[styles.container, style]}>
      {labelPosition === 'left' && <Text style={styles.label}>{label}</Text>}
      {switchElement}
      {labelPosition === 'right' && <Text style={styles.label}>{label}</Text>}
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flexDirection: 'row', alignItems: 'center' },
  track: { justifyContent: 'center' },
  thumb: { backgroundColor: UITheme.colors.white, ...UITheme.shadow.sm },
  label: {
    fontSize: UITheme.fontSize.md,
    color: UITheme.colors.gray[700],
    marginHorizontal: UITheme.spacing.sm,
  },
});

使用场景

设置页面

const SettingsPage = () => {
  const [notifications, setNotifications] = useState(true);
  const [darkMode, setDarkMode] = useState(false);
  const [autoUpdate, setAutoUpdate] = useState(true);

  return (
    <View style={styles.settings}>
      <View style={styles.settingItem}>
        <Switch
          value={notifications}
          onValueChange={setNotifications}
          label="推送通知"
        />
      </View>
      <View style={styles.settingItem}>
        <Switch
          value={darkMode}
          onValueChange={setDarkMode}
          label="深色模式"
        />
      </View>
      <View style={styles.settingItem}>
        <Switch
          value={autoUpdate}
          onValueChange={setAutoUpdate}
          label="自动更新"
        />
      </View>
    </View>
  );
};

设置页面是开关最常见的使用场景。每个设置项一个开关,用 label 显示设置名称。这种布局简洁明了,用户一眼就能看出每个开关控制什么。

settingItem 可以加一些样式,比如底部边框、垂直间距,让设置项之间有清晰的分隔。

表单里的开关

const FormWithSwitch = () => {
  const [agreeTerms, setAgreeTerms] = useState(false);
  const [subscribeNews, setSubscribeNews] = useState(false);

  return (
    <View style={styles.form}>
      <Input label="邮箱" placeholder="请输入邮箱" />
      <Input label="密码" placeholder="请输入密码" secureTextEntry />
      
      <Switch
        value={agreeTerms}
        onValueChange={setAgreeTerms}
        label="我已阅读并同意用户协议"
        size="sm"
      />
      <Switch
        value={subscribeNews}
        onValueChange={setSubscribeNews}
        label="订阅新闻邮件"
        size="sm"
      />
      
      <Button title="注册" disabled={!agreeTerms} />
    </View>
  );
};

表单里的开关通常用小号,因为不是主要内容。"同意用户协议"这种开关经常和提交按钮联动,没勾选就不能提交。

带条件的开关

const ConditionalSwitch = () => {
  const [mainSwitch, setMainSwitch] = useState(false);
  const [subSwitch1, setSubSwitch1] = useState(false);
  const [subSwitch2, setSubSwitch2] = useState(false);

  return (
    <View>
      <Switch
        value={mainSwitch}
        onValueChange={setMainSwitch}
        label="开启高级功能"
        size="lg"
        color="success"
      />
      
      {mainSwitch && (
        <View style={styles.subSwitches}>
          <Switch
            value={subSwitch1}
            onValueChange={setSubSwitch1}
            label="功能 A"
          />
          <Switch
            value={subSwitch2}
            onValueChange={setSubSwitch2}
            label="功能 B"
          />
        </View>
      )}
    </View>
  );
};

主开关控制子开关的显示。主开关关闭时,子开关隐藏;主开关打开时,子开关才显示出来。这种层级关系在设置页面很常见。

主开关用 lg 尺寸和 success 颜色,强调它的重要性。子开关用默认尺寸,视觉上形成层级。

异步操作的开关

const AsyncSwitch = () => {
  const [value, setValue] = useState(false);
  const [loading, setLoading] = useState(false);

  const handleChange = async (newValue) => {
    setLoading(true);
    try {
      await api.updateSetting(newValue);
      setValue(newValue);
    } catch (error) {
      // 请求失败,不改变状态
      Alert.alert('操作失败', error.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Switch
      value={value}
      onValueChange={handleChange}
      disabled={loading}
      label={loading ? '保存中...' : '开启功能'}
    />
  );
};

有些开关的状态变化需要调用接口。这时候要处理好加载状态:请求中禁用开关防止重复点击,请求失败不改变状态。label 可以显示当前状态,让用户知道正在保存。

可以加的动画

当前的实现没有动画,滑块是瞬间移动的。加上动画会更流畅:

const translateX = useRef(new Animated.Value(value ? width - thumbSize - 4 : 2)).current;

useEffect(() => {
  Animated.spring(translateX, {
    toValue: value ? width - thumbSize - 4 : 2,
    useNativeDriver: true,
    tension: 50,
    friction: 7,
  }).start();
}, [value]);

// 滑块用 Animated.View
<Animated.View
  style={[
    styles.thumb,
    {
      width: thumbSize,
      height: thumbSize,
      borderRadius: thumbSize / 2,
      transform: [{ translateX }],
    },
  ]}
/>

用 spring 动画让滑块有弹性地移动,比线性动画更有活力。tension 和 friction 控制弹性的强度,这两个值是调出来的,让动画既明显又不会太夸张。

useNativeDriver: true 让动画在原生线程执行,性能更好。transform 属性支持原生驱动,所以可以用。

和 Checkbox 的区别

开关和复选框都是二元选择,什么时候用哪个?

开关适合"立即生效"的场景。打开 WiFi 开关,WiFi 立刻就开了;打开通知开关,通知立刻就能收到。用户的操作直接产生效果。

复选框适合"稍后提交"的场景。勾选"记住密码",要等点击登录按钮后才生效;勾选多个商品,要等点击删除按钮后才删除。用户的操作需要配合其他操作才能生效。

从视觉上看,开关更直观,状态一目了然。复选框需要看勾选标记才能判断状态。所以重要的、需要强调的选项用开关,普通的、批量的选项用复选框。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐