React Native for OpenHarmony 实战:打造灵活易用的 Input 输入框组件
本文介绍了如何从零开始封装一个功能完善的React Native输入框组件。主要内容包括: 功能需求分析:基础输入功能、标签提示、错误状态、多种视觉风格和图标支持 类型接口设计:继承TextInputProps,扩展label、error、hint等属性 核心实现要点: 焦点状态管理 三种尺寸配置(sm/md/lg) 三种视觉变体(outlined/filled/underlined) 边框颜色优
项目开源地址:https://atomgit.com/nutpi/rn_for_openharmony_element
输入框是表单交互的核心组件,用户注册、登录、搜索、评论……几乎所有需要用户输入的场景都离不开它。一个好的输入框组件不仅要好看,还要好用:支持多种样式、能显示错误提示、有清晰的视觉反馈。本文将从零开始,封装一个功能完善的 Input 组件。
一、梳理输入框的功能需求
在写代码之前,先想清楚输入框需要具备哪些能力:
基础输入功能
这是最核心的功能,用户能在输入框里打字,组件能把输入的内容传递给父组件。React Native 的 TextInput 已经提供了这个能力,我们要做的是在它基础上封装更多功能。
标签和提示文字
输入框上方通常有一个标签,告诉用户这个输入框是干什么的,比如"用户名"、“密码”、“手机号”。输入框下方可能有提示文字,告诉用户输入的格式要求,比如"密码长度至少8位"。
错误状态显示
当用户输入不符合要求时,输入框要能显示错误状态:边框变红、显示错误提示信息。这种即时反馈能帮助用户快速发现和修正问题。
多种视觉风格
不同的页面可能需要不同风格的输入框。有的页面适合用描边风格,有的适合用填充风格,有的适合用简洁的下划线风格。组件要能支持这些变化。
图标支持
输入框左侧或右侧可能需要放图标。左侧图标通常用来表示输入类型(搜索图标、邮箱图标),右侧图标通常用来触发操作(清除内容、显示/隐藏密码)。
二、设计组件的类型接口
基于上面的需求分析,我们来定义组件的 Props 接口:
interface InputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
hint?: string;
variant?: 'outlined' | 'filled' | 'underlined';
size?: SizeType;
leftIcon?: string;
rightIcon?: string;
disabled?: boolean;
style?: ViewStyle;
}
这个接口设计有几个值得说明的地方:
继承 TextInputProps
extends Omit<TextInputProps, 'style'> 这个写法让我们的 Input 组件继承了 TextInput 的所有属性,比如 placeholder、value、onChangeText、secureTextEntry 等等。用户不需要学习新的 API,直接用熟悉的 TextInput 属性就行。
为什么要 Omit 掉 style?因为 TextInputProps 里的 style 是 TextStyle 类型,而我们想让用户传入的 style 应用到外层容器上,类型应该是 ViewStyle。所以先排除掉原来的 style,再重新定义一个 ViewStyle 类型的 style。
label、error、hint 三个文本属性
label 是输入框上方的标签,error 是错误提示,hint 是普通提示。这三个属性都是可选的,不传就不显示对应的文字。
error 和 hint 是互斥的:如果有 error,就显示 error(红色);没有 error 才显示 hint(灰色)。这个逻辑在渲染时处理,接口上不做限制,让使用更灵活。
variant 定义视觉风格
提供三种风格选择:
outlined:描边风格,有完整的边框包围输入区域,是最常见的输入框样式filled:填充风格,有灰色背景填充,底部有强调色边框,Material Design 风格underlined:下划线风格,只有底部边框,最简洁的样式
size 控制尺寸
和 Button 组件一样,提供 sm/md/lg 三档尺寸。不同尺寸影响输入框的高度和字号。
leftIcon 和 rightIcon
用字符串类型是为了简化使用,直接传 emoji 就能显示图标。如果需要更复杂的图标,可以把类型改成 ReactNode。
三、实现焦点状态管理
输入框有一个重要的交互状态:焦点状态。当用户点击输入框开始输入时,输入框获得焦点,边框颜色应该变成主题色,给用户明确的视觉反馈。
export const Input: React.FC<InputProps> = ({
label,
error,
hint,
variant = 'outlined',
size = 'md',
leftIcon,
rightIcon,
disabled = false,
style,
...props
}) => {
const [focused, setFocused] = useState(false);
// ... 其他代码
};
用 useState 创建一个 focused 状态,初始值是 false。这个状态会在 TextInput 的 onFocus 和 onBlur 事件中更新。
为什么要自己管理焦点状态,而不是用 TextInput 的 ref 来判断?因为我们需要根据焦点状态来改变边框颜色,这是一个渲染相关的逻辑。用 state 管理可以触发重新渲染,用 ref 则不会。
四、实现尺寸配置
不同尺寸的输入框,高度和字号都不一样:
const sizeStyles: Record<SizeType, { height: number; fontSize: number }> = {
sm: { height: 36, fontSize: 12 },
md: { height: 44, fontSize: 14 },
lg: { height: 52, fontSize: 16 },
};
高度的选择依据
sm 高度 36px,是比较紧凑的尺寸,适合空间有限的场景,比如表格里的筛选输入框。
md 高度 44px,是默认尺寸。44px 是 OpenHarmony 推荐的最小可点击区域高度,保证用户能轻松点中输入框。
lg 高度 52px,是比较宽松的尺寸,适合需要强调的场景,比如登录页的用户名密码输入框。
字号的匹配
字号和高度要匹配:小输入框配小字号,大输入框配大字号。如果大输入框用小字号,会显得很空旷;小输入框用大字号,文字会显得很挤。
12/14/16 这三个字号是移动端常用的字号梯度,可读性都不错。
五、实现输入框容器样式
这是组件最核心的样式逻辑,需要根据 variant、focused、error、disabled 四个因素来计算最终样式:
const getInputContainerStyle = (): ViewStyle => {
const base: ViewStyle = {
height: sizeStyles[size].height,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: UITheme.spacing.md,
backgroundColor: disabled ? UITheme.colors.gray[100] : UITheme.colors.white,
};
const borderColor = error
? UITheme.colors.danger
: focused
? UITheme.colors.primary
: UITheme.colors.gray[300];
switch (variant) {
case 'outlined':
return { ...base, borderWidth: 1.5, borderColor, borderRadius: UITheme.borderRadius.md };
case 'filled':
return {
...base,
backgroundColor: UITheme.colors.gray[100],
borderRadius: UITheme.borderRadius.md,
borderBottomWidth: 2,
borderBottomColor: borderColor,
};
case 'underlined':
return { ...base, borderBottomWidth: 2, borderBottomColor: borderColor };
default:
return base;
}
};
基础样式解析
flexDirection: 'row' 让图标和输入框水平排列。alignItems: 'center' 让它们垂直居中对齐。
paddingHorizontal: UITheme.spacing.md 设置左右内边距为 12px,让内容不会紧贴边框。
disabled 状态下背景色变成浅灰色 gray[100],视觉上表示不可交互。
边框颜色的优先级
边框颜色的计算逻辑体现了状态的优先级:
- 如果有 error,边框是红色(danger),这是最高优先级,错误状态必须明显
- 如果没有 error 但有 focused,边框是主题色(primary),表示当前正在输入
- 如果既没有 error 也没有 focused,边框是浅灰色(gray[300]),这是默认状态
这个优先级设计符合用户的心理预期:错误是最需要关注的,其次是当前操作的输入框。
outlined 变体
case 'outlined':
return { ...base, borderWidth: 1.5, borderColor, borderRadius: UITheme.borderRadius.md };
四周都有边框,圆角 8px。边框宽度用 1.5px 而不是 1px,是因为 1px 在高清屏上太细了,视觉上不够清晰。
filled 变体
case 'filled':
return {
...base,
backgroundColor: UITheme.colors.gray[100],
borderRadius: UITheme.borderRadius.md,
borderBottomWidth: 2,
borderBottomColor: borderColor,
};
背景色是浅灰色,只有底部有边框。这种设计来自 Material Design,底部边框在获得焦点时会变成主题色,形成一个"下划线高亮"的效果。
注意这里覆盖了 base 里的 backgroundColor。filled 变体不管是否 disabled,背景色都是 gray[100]。
underlined 变体
case 'underlined':
return { ...base, borderBottomWidth: 2, borderBottomColor: borderColor };
最简洁的样式,只有底部边框,没有背景色,没有圆角。适合追求极简风格的界面。
六、实现标签和提示文字
标签显示在输入框上方,提示文字显示在输入框下方:
return (
<View style={[styles.container, style]}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={getInputContainerStyle()}>
{/* 输入框内容 */}
</View>
{(error || hint) && (
<Text style={[styles.hint, error && styles.errorText]}>{error || hint}</Text>
)}
</View>
);
标签的条件渲染
{label && <Text style={styles.label}>{label}</Text>} 这个写法利用了 JavaScript 的短路求值:如果 label 是 undefined 或空字符串,整个表达式返回 falsy 值,React 不会渲染任何东西。
提示文字的条件渲染和样式切换
{(error || hint) && ...} 确保只有在有 error 或 hint 时才渲染提示文字区域。
style={[styles.hint, error && styles.errorText]} 这个写法很巧妙:styles.hint 是基础样式(灰色文字),如果有 error,就追加 styles.errorText(红色文字)。后面的样式会覆盖前面的,所以有 error 时文字是红色的。
{error || hint} 显示的内容优先是 error,没有 error 才显示 hint。
样式定义
const styles = StyleSheet.create({
container: { marginBottom: UITheme.spacing.md },
label: {
fontSize: UITheme.fontSize.sm,
fontWeight: '500',
color: UITheme.colors.gray[700],
marginBottom: UITheme.spacing.xs,
},
hint: {
fontSize: UITheme.fontSize.xs,
color: UITheme.colors.gray[500],
marginTop: UITheme.spacing.xs,
},
errorText: { color: UITheme.colors.danger },
});
container 设置 marginBottom: 12px,让多个输入框之间有间距。这个设计让用户在使用时不需要手动加间距,直接堆叠多个 Input 就能得到合理的布局。
label 用 12px 字号和 500 字重(中等粗细),颜色用 gray[700](深灰色)。标签要比输入内容小一点、淡一点,形成视觉层级。
hint 用 10px 字号和 gray[500](中灰色),是最弱的视觉元素。提示信息是辅助性的,不应该抢夺用户注意力。
七、实现图标显示
图标显示在输入框内部,左侧或右侧:
<View style={getInputContainerStyle()}>
{leftIcon && <Text style={styles.icon}>{leftIcon}</Text>}
<TextInput
style={[styles.input, { fontSize: sizeStyles[size].fontSize }]}
placeholderTextColor={UITheme.colors.gray[400]}
editable={!disabled}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
{...props}
/>
{rightIcon && <Text style={styles.icon}>{rightIcon}</Text>}
</View>
图标的实现方式
用 Text 组件显示 emoji 图标是最简单的方案,不需要引入任何图标库。styles.icon 设置了 marginHorizontal: 4,让图标和输入文字之间有一点间距。
如果需要使用专业的图标库,可以把 leftIcon 和 rightIcon 的类型改成 ReactNode,然后传入图标组件。
TextInput 的关键属性
style={[styles.input, { fontSize: sizeStyles[size].fontSize }]} 合并了基础样式和动态字号。styles.input 设置了 flex: 1 让输入框占据剩余空间,padding: 0 去掉默认内边距。
placeholderTextColor={UITheme.colors.gray[400]} 设置占位符文字的颜色。默认的占位符颜色在不同平台上不一致,显式设置可以保证视觉统一。
editable={!disabled} 控制输入框是否可编辑。disabled 为 true 时,用户无法输入。
onFocus 和 onBlur 更新焦点状态,触发边框颜色的变化。
{...props} 把剩余的属性透传给 TextInput,让用户可以使用 TextInput 的所有原生属性。
八、完整组件代码
把上面的部分组装起来:
import React, { useState } from 'react';
import { View, TextInput, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native';
import { UITheme, SizeType } from './theme';
interface InputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
hint?: string;
variant?: 'outlined' | 'filled' | 'underlined';
size?: SizeType;
leftIcon?: string;
rightIcon?: string;
disabled?: boolean;
style?: ViewStyle;
}
export const Input: React.FC<InputProps> = ({
label,
error,
hint,
variant = 'outlined',
size = 'md',
leftIcon,
rightIcon,
disabled = false,
style,
...props
}) => {
const [focused, setFocused] = useState(false);
const sizeStyles: Record<SizeType, { height: number; fontSize: number }> = {
sm: { height: 36, fontSize: 12 },
md: { height: 44, fontSize: 14 },
lg: { height: 52, fontSize: 16 },
};
const getInputContainerStyle = (): ViewStyle => {
const base: ViewStyle = {
height: sizeStyles[size].height,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: UITheme.spacing.md,
backgroundColor: disabled ? UITheme.colors.gray[100] : UITheme.colors.white,
};
const borderColor = error
? UITheme.colors.danger
: focused
? UITheme.colors.primary
: UITheme.colors.gray[300];
switch (variant) {
case 'outlined':
return { ...base, borderWidth: 1.5, borderColor, borderRadius: UITheme.borderRadius.md };
case 'filled':
return {
...base,
backgroundColor: UITheme.colors.gray[100],
borderRadius: UITheme.borderRadius.md,
borderBottomWidth: 2,
borderBottomColor: borderColor,
};
case 'underlined':
return { ...base, borderBottomWidth: 2, borderBottomColor: borderColor };
default:
return base;
}
};
return (
<View style={[styles.container, style]}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={getInputContainerStyle()}>
{leftIcon && <Text style={styles.icon}>{leftIcon}</Text>}
<TextInput
style={[styles.input, { fontSize: sizeStyles[size].fontSize }]}
placeholderTextColor={UITheme.colors.gray[400]}
editable={!disabled}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
{...props}
/>
{rightIcon && <Text style={styles.icon}>{rightIcon}</Text>}
</View>
{(error || hint) && (
<Text style={[styles.hint, error && styles.errorText]}>{error || hint}</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { marginBottom: UITheme.spacing.md },
label: {
fontSize: UITheme.fontSize.sm,
fontWeight: '500',
color: UITheme.colors.gray[700],
marginBottom: UITheme.spacing.xs,
},
input: { flex: 1, color: UITheme.colors.gray[800], padding: 0 },
icon: { fontSize: 16, marginHorizontal: 4 },
hint: {
fontSize: UITheme.fontSize.xs,
color: UITheme.colors.gray[500],
marginTop: UITheme.spacing.xs,
},
errorText: { color: UITheme.colors.danger },
});
九、实际使用示例
场景一:登录表单
const LoginForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({ username: '', password: '' });
const validate = () => {
const newErrors = { username: '', password: '' };
if (!username) newErrors.username = '请输入用户名';
if (!password) newErrors.password = '请输入密码';
else if (password.length < 6) newErrors.password = '密码长度至少6位';
setErrors(newErrors);
return !newErrors.username && !newErrors.password;
};
return (
<View style={styles.form}>
<Input
label="用户名"
placeholder="请输入用户名"
leftIcon="👤"
value={username}
onChangeText={setUsername}
error={errors.username}
/>
<Input
label="密码"
placeholder="请输入密码"
leftIcon="🔒"
value={password}
onChangeText={setPassword}
secureTextEntry
error={errors.password}
/>
<Button title="登录" onPress={() => validate() && login()} fullWidth />
</View>
);
};
这个例子展示了输入框在表单中的典型用法:受控组件模式(value + onChangeText)、表单验证、错误提示显示。
场景二:搜索输入框
const SearchInput = ({ onSearch }) => {
const [keyword, setKeyword] = useState('');
return (
<Input
placeholder="搜索商品、店铺"
leftIcon="🔍"
variant="filled"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={() => onSearch(keyword)}
returnKeyType="search"
/>
);
};
搜索框用 filled 变体,视觉上更柔和。returnKeyType="search" 让键盘上的回车键显示为"搜索"。onSubmitEditing 在用户按下搜索键时触发搜索。
场景三:多行文本输入
<Input
label="个人简介"
placeholder="介绍一下自己..."
variant="outlined"
multiline
numberOfLines={4}
style={{ height: 100 }}
hint="最多200字"
/>
TextInput 原生支持 multiline 属性,我们的 Input 组件透传了这个属性。多行输入时需要手动设置高度,覆盖掉 sizeStyles 里的固定高度。
场景四:带清除按钮的输入框
const ClearableInput = () => {
const [value, setValue] = useState('');
return (
<Input
placeholder="请输入内容"
value={value}
onChangeText={setValue}
rightIcon={value ? "✕" : undefined}
/>
);
};
当输入框有内容时显示清除图标,没有内容时不显示。这里只是显示图标,如果要实现点击清除功能,需要把 rightIcon 改成可点击的组件。
十、进阶功能扩展
添加清除按钮功能
interface InputProps {
// ... 其他属性
clearable?: boolean;
onClear?: () => void;
}
// 在组件内部
{clearable && value && (
<TouchableOpacity onPress={onClear}>
<Text style={styles.clearIcon}>✕</Text>
</TouchableOpacity>
)}
这个扩展让输入框支持一键清除功能。clearable 属性控制是否显示清除按钮,onClear 回调在点击清除按钮时触发。实际使用时,onClear 通常会调用 setValue(‘’) 来清空输入内容。
添加字数统计
interface InputProps {
// ... 其他属性
maxLength?: number;
showCount?: boolean;
}
// 在提示文字区域
{showCount && maxLength && (
<Text style={styles.count}>{value?.length || 0}/{maxLength}</Text>
)}
字数统计在多行文本输入场景很有用,比如发布动态、写评论。用户能实时看到还能输入多少字,避免超出限制后才发现要删减内容。
添加前缀和后缀
interface InputProps {
// ... 其他属性
prefix?: string;
suffix?: string;
}
// 在输入框内部
{prefix && <Text style={styles.affix}>{prefix}</Text>}
<TextInput ... />
{suffix && <Text style={styles.affix}>{suffix}</Text>}
前缀后缀适合用于显示单位(元、kg)或固定文本(+86、@gmail.com)。和图标不同的是,前缀后缀是文字,会和输入内容形成一个整体。
十一、无障碍支持
为了让视障用户也能使用输入框,需要添加无障碍属性:
<TextInput
accessible={true}
accessibilityLabel={label || placeholder}
accessibilityHint={hint}
accessibilityState={{ disabled }}
// ... 其他属性
/>
accessibilityLabel 是屏幕阅读器会朗读的内容,通常是输入框的标签。accessibilityHint 是额外的提示信息。accessibilityState 告诉屏幕阅读器当前的状态。
这些属性在 OpenHarmony 上同样适用,能让你的应用对所有用户都友好。无障碍设计不仅是道德要求,在很多国家和地区也是法律要求。
十二、常见问题和解决方案
问题一:输入框获得焦点时页面被键盘遮挡
这是移动端开发的常见问题。解决方案是用 KeyboardAvoidingView 包裹表单:
<KeyboardAvoidingView behavior="padding">
<ScrollView>
<Input label="用户名" ... />
<Input label="密码" ... />
</ScrollView>
</KeyboardAvoidingView>
KeyboardAvoidingView 会在键盘弹出时自动调整内容位置,确保当前输入框不被键盘遮挡。behavior 属性控制调整方式,“padding” 是最常用的选项。
问题二:中文输入时 onChangeText 触发多次
这是因为中文输入法有组合输入的过程。用户在输入拼音时,onChangeText 会被频繁触发。如果需要在输入完成后才处理,可以用 onEndEditing 代替 onChangeText 做验证,或者用防抖(debounce)来减少处理频率。
问题三:密码输入框的显示/隐藏切换
const [showPassword, setShowPassword] = useState(false);
<Input
label="密码"
secureTextEntry={!showPassword}
rightIcon={showPassword ? "🙈" : "👁️"}
/>
配合 rightIcon 的点击事件,可以实现密码的显示/隐藏切换。这个功能在用户输入长密码时很有用,可以确认输入是否正确。
问题四:输入框在列表中使用时性能问题
如果在 FlatList 的每一项中都有输入框,可能会遇到性能问题。解决方案是使用 React.memo 包裹列表项组件,并确保 onChangeText 回调使用 useCallback 缓存。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)