请添加图片描述

项目开源地址: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],视觉上表示不可交互。

边框颜色的优先级

边框颜色的计算逻辑体现了状态的优先级:

  1. 如果有 error,边框是红色(danger),这是最高优先级,错误状态必须明显
  2. 如果没有 error 但有 focused,边框是主题色(primary),表示当前正在输入
  3. 如果既没有 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 时,用户无法输入。

onFocusonBlur 更新焦点状态,触发边框颜色的变化。

{...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

Logo

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

更多推荐