React Native for OpenHarmony 实战:Switch 开关组件的实现思路
本文介绍了如何为React Native封装一个自定义开关组件。文章首先分析了原生Switch组件的局限性,如样式不统一、功能有限等问题,进而提出封装自定义开关的必要性。作者从交互本质出发,将开关视为二元选择器,详细阐述了接口设计、尺寸配置、滑块位置计算、轨道和滑块渲染、点击事件处理等核心实现细节。文章重点讲解了如何通过精确的尺寸比例和位置计算实现美观的开关效果,包括轨道宽高比设计、滑块间隙处理、
项目开源地址: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
更多推荐


所有评论(0)