前言

在移动应用开发中,表单输入是用户与应用交互的重要方式。无论是注册登录、添加数据、还是编辑信息,都离不开输入框组件。一个设计良好的输入框不仅要美观易用,还要具备完善的验证机制,确保用户输入的数据符合要求。

本文将通过一个实际案例——健康管理应用的运动类型添加功能,带你深入理解 TextInput 组件的使用方法和表单设计技巧。

本文适合: 已经了解基础语法的初学者

你将学到:

  • TextInput 组件的基础用法和属性配置
  • 输入验证与字数限制的实现
  • 输入框样式设计与用户体验优化

案例背景

我们要实现一个"添加运动类型"的对话框,用户可以自定义运动类型,包含以下功能:

  1. 名称输入:用户输入运动类型名称(如"爬山"、"游泳"等)
  2. 字数限制:名称最多 6 个字符
  3. 图标选择:从预设图标库中选择一个图标
  4. 卡路里设置:设置每分钟消耗的卡路里
  5. 输入验证:提交前检查名称是否为空

最终效果如下图所示:
效果

一、完整代码实现

让我们先看添加运动类型对话框的完整实现代码。

@Component
export struct ExerciseTabContent {
  // 添加类型对话框相关状态
  @State showAddTypeDialog: boolean = false;
  @State newTypeName: string = '';
  @State newTypeIconIndex: number = 0;
  @State newTypeCalories: number = 5;
  @State currentBreakpoint: BreakpointType = getBreakpointManager().getCurrentBreakpoint();
  
  private prefService: PreferencesService | null = null;

  aboutToAppear(): void {
    const ctx = getContext(this) as common.UIAbilityContext;
    this.prefService = PreferencesService.getInstance(ctx);
  }

  // 添加自定义运动类型
  addCustomExerciseType(): void {
    if (!this.prefService) return;
    
    // 验证:名称不能为空
    if (this.newTypeName.trim().length === 0) return;

    const newType: CustomExerciseType = {
      id: `custom_${Date.now()}`,
      name: this.newTypeName.trim(),
      icon: EXERCISE_ICONS[this.newTypeIconIndex],
      caloriesPerMinute: this.newTypeCalories,
      isDefault: false
    };

    const pref: PreferencesService = this.prefService;
    pref.addExerciseType(newType).then((): void => {
      this.loadExerciseTypes();
      this.showAddTypeDialog = false;
      // 重置表单
      this.newTypeName = '';
      this.newTypeIconIndex = 0;
      this.newTypeCalories = 5;
    });
  }

  build() {
    Stack() {
      // 主页面内容...

      // 添加运动类型对话框
      if (this.showAddTypeDialog) {
        Column() {
          Column() {
            // 标题
            Text('添加运动类型')
              .fontSize(this.getDialogTitleSize())
              .fontWeight(FontWeight.Bold)
              .fontColor($r('app.color.text_primary'))

            // 名称输入
            Column() {
              Text('运动名称')
                .fontSize(this.getDialogLabelSize())
                .fontColor($r('app.color.text_secondary'))
                .width('100%')
                .margin({ top: this.getSectionGap() } as Padding)

              TextInput({ placeholder: '请输入名称', text: this.newTypeName })
                .width('100%')
                .height(this.getDialogButtonHeight())
                .backgroundColor($r('app.color.secondary_background'))
                .borderRadius(this.getCardRadius())
                .margin({ top: this.getItemGap() } as Padding)
                .maxLength(6)
                .onChange((value: string) => {
                  this.newTypeName = value;
                })
            }

            // 图标选择
            Column() {
              Text('选择图标')
                .fontSize(this.getDialogLabelSize())
                .fontColor($r('app.color.text_secondary'))
                .width('100%')
                .margin({ top: this.getSectionGap() } as Padding)

              Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
                ForEach(EXERCISE_ICONS, (icon: string, index: number) => {
                  Column() {
                    Text(icon)
                      .fontSize(this.getTypeItemIconSize())
                  }
                  .width(this.getTypeIconButtonSize())
                  .height(this.getTypeIconButtonSize())
                  .justifyContent(FlexAlign.Center)
                  .backgroundColor(this.newTypeIconIndex === index ? 
                    $r('app.color.exercise_surface') : 
                    $r('app.color.secondary_background'))
                  .borderRadius(this.getCardRadius())
                  .border({
                    width: this.newTypeIconIndex === index ? 2 : 0,
                    color: $r('app.color.exercise_orange')
                  })
                  .margin(4 as Padding)
                  .onClick(() => {
                    this.newTypeIconIndex = index;
                  })
                })
              }
              .width('100%')
              .margin({ top: this.getItemGap() } as Padding)
            }

            // 卡路里设置
            Column() {
              Text('每分钟消耗(kcal)')
                .fontSize(this.getDialogLabelSize())
                .fontColor($r('app.color.text_secondary'))
                .width('100%')
                .margin({ top: this.getSectionGap() } as Padding)

              Row() {
                Button('-')
                  .width(this.getDialogSmallButtonSize())
                  .height(this.getDialogSmallButtonSize())
                  .fontSize(this.getDialogLabelSize())
                  .backgroundColor($r('app.color.divider_color'))
                  .fontColor($r('app.color.text_primary'))
                  .onClick(() => {
                    if (this.newTypeCalories > 1) this.newTypeCalories -= 1;
                  })

                Text(this.newTypeCalories.toString())
                  .fontSize(getValueByBreakpoint(this.currentBreakpoint, 
                    new BreakpointValue<number>(24, 28, 32)))
                  .fontWeight(FontWeight.Bold)
                  .fontColor($r('app.color.exercise_orange'))
                  .margin({ left: this.getSectionGap(), right: this.getSectionGap() } as Padding)

                Button('+')
                  .width(this.getDialogSmallButtonSize())
                  .height(this.getDialogSmallButtonSize())
                  .fontSize(this.getDialogLabelSize())
                  .backgroundColor($r('app.color.divider_color'))
                  .fontColor($r('app.color.text_primary'))
                  .onClick(() => {
                    if (this.newTypeCalories < 20) this.newTypeCalories += 1;
                  })
              }
              .margin({ top: this.getItemGap() } as Padding)
            }

            // 底部按钮
            Row() {
              Button('取消')
                .layoutWeight(1)
                .height(this.getDialogButtonHeight())
                .backgroundColor($r('app.color.input_background'))
                .fontColor($r('app.color.text_primary'))
                .onClick(() => {
                  this.showAddTypeDialog = false;
                })

              Button('添加')
                .layoutWeight(1)
                .height(this.getDialogButtonHeight())
                .backgroundColor($r('app.color.primary_color'))
                .fontColor(Color.White)
                .margin({ left: 12 } as Padding)
                .onClick(() => {
                  this.addCustomExerciseType();
                })
            }
            .width('100%')
            .margin({ top: this.getSectionGap() } as Padding)
          }
          .width(this.getDialogWidth())
          .padding(this.getDialogPadding() as Padding)
          .backgroundColor($r('app.color.card_background'))
          .borderRadius(this.getDialogRadius())
        }
        .width('100%')
        .height('100%')
        .backgroundColor($r('app.color.overlay_background'))
        .justifyContent(FlexAlign.Center)
      }
    }
  }
}

// 运动图标库
export const EXERCISE_ICONS: string[] = [
  '🏃', '🚶', '🚴', '🏊', '🏀', '🏸', '🧘', '💪',
  '⚽', '🎾', '🏓', '⛷️', '🛹', '🎿', '🏋️', '🤸',
  '🚣', '🧗', '🏇', '⛳', '🎳', '🥊', '🤾', '⭐'
];

二、TextInput 组件基础

2.1 TextInput 的基本用法

最简单的 TextInput:

TextInput({ placeholder: '请输入内容' })

这会创建一个带占位符的输入框。

核心参数说明:

参数 类型 说明 示例
placeholder string 占位符文本 placeholder: '请输入名称'
text string 输入框的初始值 text: this.userName

基础示例:

@State inputValue: string = '';

TextInput({ placeholder: '请输入内容', text: this.inputValue })
  .onChange((value: string) => {
    this.inputValue = value;
  })

2.2 TextInput 的常用属性

样式属性:

TextInput({ placeholder: '请输入名称' })
  .width('100%')                                    // 宽度
  .height(44)                                       // 高度
  .backgroundColor('#F5F5F5')                       // 背景色
  .borderRadius(8)                                  // 圆角
  .fontSize(16)                                     // 字体大小
  .fontColor('#333333')                             // 文字颜色
  .placeholderColor('#999999')                      // 占位符颜色
  .placeholderFont({ size: 14, weight: 400 })      // 占位符字体

功能属性:

TextInput({ placeholder: '请输入名称' })
  .maxLength(10)                                    // 最大字符数
  .type(InputType.Normal)                           // 输入类型
  .enterKeyType(EnterKeyType.Done)                  // 回车键类型
  .caretColor('#007AFF')                            // 光标颜色

输入类型(InputType):

类型 说明 适用场景
Normal 普通文本 姓名、标题等
Number 数字 年龄、数量等
PhoneNumber 电话号码 手机号输入
Email 邮箱 邮箱地址输入
Password 密码 密码输入(显示为 ●)

2.3 TextInput 的事件处理

onChange 事件:

当输入内容变化时触发,这是最常用的事件。

@State userName: string = '';

TextInput({ placeholder: '请输入姓名', text: this.userName })
  .onChange((value: string) => {
    this.userName = value;
    console.log(`当前输入:${value}`);
  })

其他常用事件:

TextInput({ placeholder: '请输入内容' })
  .onSubmit((enterKey: EnterKeyType) => {
    // 按下回车键时触发
    console.log('提交输入');
  })
  .onEditChange((isEditing: boolean) => {
    // 输入状态改变时触发
    console.log(`编辑状态:${isEditing}`);
  })
  .onCopy((value: string) => {
    // 复制文本时触发
    console.log(`复制内容:${value}`);
  })
  .onCut((value: string) => {
    // 剪切文本时触发
    console.log(`剪切内容:${value}`);
  })
  .onPaste((value: string) => {
    // 粘贴文本时触发
    console.log(`粘贴内容:${value}`);
  })

2.4 完整的输入流程

@Component
struct TextInputDemo {
  @State inputValue: string = '';
  @State isEditing: boolean = false;

  build() {
    Column() {
      // 显示当前输入
      Text(`当前输入:${this.inputValue}`)
        .fontSize(16)
        .margin({ bottom: 12 })

      // 输入框
      TextInput({ placeholder: '请输入内容', text: this.inputValue })
        .width('100%')
        .height(44)
        .backgroundColor('#F5F5F5')
        .borderRadius(8)
        .onChange((value: string) => {
          this.inputValue = value;
        })
        .onEditChange((isEditing: boolean) => {
          this.isEditing = isEditing;
        })

      // 状态显示
      Text(this.isEditing ? '正在输入...' : '未输入')
        .fontSize(12)
        .fontColor('#999999')
        .margin({ top: 8 })
    }
    .padding(16)
  }
}

流程:

用户点击输入框
    ↓
onEditChange(true) - 进入编辑状态
    ↓
用户输入文字
    ↓
onChange(value) - 实时获取输入内容
    ↓
更新 @State 变量
    ↓
UI 自动刷新显示新内容
    ↓
用户点击完成/失焦
    ↓
onEditChange(false) - 退出编辑状态

三、字数限制与输入验证

3.1 字数限制的实现

使用 maxLength 属性:

这是最简单的字数限制方式,直接限制用户输入的字符数。

@State typeName: string = '';

TextInput({ placeholder: '请输入名称', text: this.typeName })
  .maxLength(6)  // 最多输入 6 个字符
  .onChange((value: string) => {
    this.typeName = value;
  })

效果演示:

用户输入:爬
显示:爬

用户输入:爬山
显示:爬山

用户输入:爬山运动
显示:爬山运动

用户输入:爬山运动健身游泳
显示:爬山运动健身  ← 只显示前 6 个字符,后面的无法输入

为什么需要字数限制?

  1. 界面美观:过长的名称会导致 UI 显示不完整
  2. 数据库限制:数据库字段通常有长度限制
  3. 用户体验:简短的名称更易记忆和识别
  4. 性能考虑:限制输入长度可以减少数据处理负担

3.2 实时字数统计

虽然 maxLength 可以限制输入,但用户不知道还能输入多少字符。我们可以添加字数统计提示。

@State typeName: string = '';
private maxLength: number = 6;

Column() {
  Row() {
    Text('运动名称')
      .fontSize(14)
      .fontColor('#666666')
    
    Blank()
    
    Text(`${this.typeName.length}/${this.maxLength}`)
      .fontSize(12)
      .fontColor(this.typeName.length >= this.maxLength ? '#FF0000' : '#999999')
  }
  .width('100%')

  TextInput({ placeholder: '请输入名称', text: this.typeName })
    .width('100%')
    .height(44)
    .maxLength(this.maxLength)
    .onChange((value: string) => {
      this.typeName = value;
    })
}

3.3 输入验证

在提交表单前,需要验证输入是否符合要求。

基础验证:检查是否为空

@State typeName: string = '';
@State errorMessage: string = '';

// 验证函数
private validateInput(): boolean {
  // 去除首尾空格后检查
  if (this.typeName.trim().length === 0) {
    this.errorMessage = '请输入运动名称';
    return false;
  }
  
  this.errorMessage = '';
  return true;
}

// 提交按钮
Button('添加')
  .onClick(() => {
    if (this.validateInput()) {
      // 验证通过,执行添加操作
      this.addCustomExerciseType();
    }
  })

显示错误提示:

Column() {
  Text('运动名称')
    .fontSize(14)
    .fontColor('#666666')

  TextInput({ placeholder: '请输入名称', text: this.typeName })
    .width('100%')
    .height(44)
    .maxLength(6)
    .onChange((value: string) => {
      this.typeName = value;
      // 输入时清除错误提示
      if (this.errorMessage.length > 0) {
        this.errorMessage = '';
      }
    })

  // 错误提示
  if (this.errorMessage.length > 0) {
    Row() {
      Text('⚠️')
        .fontSize(12)
      Text(this.errorMessage)
        .fontSize(12)
        .fontColor('#FF0000')
        .margin({ left: 4 })
    }
    .margin({ top: 4 })
  }
}

验证流程:

用户点击"添加"按钮
    ↓
调用 validateInput()
    ↓
检查 typeName.trim().length
    ↓
如果为 0:
  - 设置 errorMessage = '请输入运动名称'
  - 显示错误提示
  - 返回 false
    ↓
如果不为 0:
  - 清除 errorMessage
  - 返回 true
  - 执行添加操作

3.4 高级验证:多条件检查

private validateInput(): boolean {
  const trimmedName: string = this.typeName.trim();
  
  // 检查是否为空
  if (trimmedName.length === 0) {
    this.errorMessage = '请输入运动名称';
    return false;
  }
  
  // 检查最小长度
  if (trimmedName.length < 2) {
    this.errorMessage = '名称至少需要2个字符';
    return false;
  }
  
  // 检查是否包含特殊字符
  const specialChars: RegExp = /[!@#$%^&*(),.?":{}|<>]/;
  if (specialChars.test(trimmedName)) {
    this.errorMessage = '名称不能包含特殊字符';
    return false;
  }
  
  // 检查是否重复(假设有 existingTypes 数组)
  const isDuplicate: boolean = this.existingTypes.some(
    (type) => type.name === trimmedName
  );
  if (isDuplicate) {
    this.errorMessage = '该运动类型已存在';
    return false;
  }
  
  this.errorMessage = '';
  return true;
}

四、输入框样式设计

4.1 基础样式配置

一个美观的输入框需要精心设计样式。

标准样式:

TextInput({ placeholder: '请输入名称' })
  .width('100%')
  .height(44)
  .backgroundColor('#F5F5F5')
  .borderRadius(8)
  .fontSize(16)
  .fontColor('#333333')
  .placeholderColor('#999999')
  .padding({ left: 12, right: 12 })

六、实战技巧与注意事项

6.1 使用 trim() 处理空格

用户可能输入空格,提交前必须去除首尾空格。

// ❌ 错误:直接使用原始输入
if (this.newTypeName.length === 0) {
  return false;
}

// ✅ 正确:使用 trim() 去除空格
if (this.newTypeName.trim().length === 0) {
  return false;
}

为什么需要 trim()?

用户输入:"   "(三个空格)
this.newTypeName.length = 3  ← 不为 0,验证通过 ❌
this.newTypeName.trim().length = 0  ← 为 0,验证失败 ✅

6.2 输入时清除错误提示

当用户开始输入时,应该立即清除错误提示,提升用户体验。

TextInput({ placeholder: '请输入名称', text: this.newTypeName })
  .onChange((value: string) => {
    this.newTypeName = value;
    // 输入时清除错误
    if (this.errorMessage.length > 0) {
      this.errorMessage = '';
    }
  })

6.3 提交后重置表单

添加成功后,应该重置表单,方便用户继续添加。

addCustomExerciseType(): void {
  if (!this.prefService) return;
  if (this.newTypeName.trim().length === 0) return;

  const newType: CustomExerciseType = {
    id: `custom_${Date.now()}`,
    name: this.newTypeName.trim(),
    icon: EXERCISE_ICONS[this.newTypeIconIndex],
    caloriesPerMinute: this.newTypeCalories,
    isDefault: false
  };

  this.prefService.addExerciseType(newType).then((): void => {
    this.showAddTypeDialog = false;
    
    // 重置表单
    this.newTypeName = '';
    this.newTypeIconIndex = 0;
    this.newTypeCalories = 5;
    this.errorMessage = '';
  });
}

6.4 合理的默认值

为表单项设置合理的默认值,减少用户操作。

@State newTypeName: string = '';           // 名称默认为空,需要用户输入
@State newTypeIconIndex: number = 0;       // 默认选中第一个图标
@State newTypeCalories: number = 5;        // 默认 5 kcal/分钟(中等强度)

总结

通过本文的学习,我们掌握了 TextInput 组件的核心用法和表单设计技巧:

TextInput 核心知识:

  • 使用 placeholder 和 text 参数创建输入框
  • 通过 onChange 事件实时获取用户输入
  • 使用 maxLength 限制输入字符数
  • 配置样式属性美化输入框外观

输入验证技巧:

  • 使用 trim() 去除首尾空格
  • 检查输入是否为空、是否符合长度要求
  • 显示清晰的错误提示信息
  • 输入时自动清除错误状态

TextInput 是移动应用中最常用的组件之一,掌握其使用方法和设计技巧,可以大大提升应用的用户体验。
希望本文能帮助你在实际项目中灵活运用 TextInput 组件,打造出优雅、易用的表单界面。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐