【HarmonyOS】day43:RN_of_HarmonyOS实战项目_电话号码输入

【HarmonyOS】RN_of_HarmonyOS实战项目_电话号码输入

技术栈 :HarmonyOS NEXT + React Native 0.72.5 + TypeScript
更新时间 :2026年2月
阅读时间 :约20分钟

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


📱 前言

在移动应用开发中,电话号码输入是最基础也最重要的用户交互场景之一。无论是用户注册、登录验证,还是联系客服、一键拨号,都离不开电话号码的输入与处理。

在HarmonyOS平台上使用React Native(RNOH)实现电话号码输入功能,需要考虑跨平台适配输入格式验证用户体验优化等多个维度。本文将带你从零开始,打造一个生产级的电话号码输入组件。


🎯 一、项目背景与需求分析

1.1 核心功能需求

功能模块 描述 优先级
号码输入 支持数字输入,限制11位手机号 ⭐⭐⭐
格式验证 实时验证中国大陆手机号格式 ⭐⭐⭐
自动格式化 输入时自动添加空格分隔(138 1234 5678) ⭐⭐
一键拨号 点击按钮直接拨打输入的号码 ⭐⭐
国家/地区码 支持选择不同国家区号

1.2 技术挑战

复制代码
┌─────────────────────────────────────────────────────────┐
│  HarmonyOS RN 电话号码输入技术挑战                        │
├─────────────────────────────────────────────────────────┤
│  1. TextInput组件在HarmonyOS上的适配差异                  │
│  2. 键盘类型选择(数字键盘 vs 电话键盘)                  │
│  3. 输入格式实时验证与反馈                               │
│  4. 调用系统拨号功能的权限配置                           │
│  5. 多国家区号的国际化支持                               │
└─────────────────────────────────────────────────────────┘

🛠️ 二、环境准备

2.1 开发环境要求

bash 复制代码
# Node.js 版本
node -v  # 建议 v18+

# React Native 版本
npx react-native -v  # 0.72.5

# HarmonyOS SDK
hvigorw --version  # API 20+

# RNOH 版本
npm list react-native-openharmony  # 0.72.5 或 0.77.1

2.2 权限配置

module.json5 中添加必要权限:

json5 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CALL_PHONE",
        "reason": "$string:call_permission_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

💻 三、核心组件实现

3.1 基础电话号码输入组件

tsx 复制代码
// components/PhoneNumberInput.tsx
import React, { useState, useCallback } from 'react';
import {
  View,
  TextInput,
  Text,
  StyleSheet,
  TouchableOpacity,
  KeyboardTypeOptions,
} from 'react-native';

interface PhoneNumberInputProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  showClearButton?: boolean;
}

export const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
  value,
  onChange,
  placeholder = '请输入手机号码',
  disabled = false,
  showClearButton = true,
}) => {
  // 格式化电话号码:138 1234 5678
  const formatPhoneNumber = (text: string): string => {
    // 只保留数字
    const numbers = text.replace(/\D/g, '');
    // 限制11位
    const limited = numbers.slice(0, 11);
    // 添加空格分隔
    if (limited.length <= 3) {
      return limited;
    } else if (limited.length <= 7) {
      return `${limited.slice(0, 3)} ${limited.slice(3)}`;
    } else {
      return `${limited.slice(0, 3)} ${limited.slice(3, 7)} ${limited.slice(7)}`;
    }
  };

  const handleChangeText = useCallback((text: string) => {
    // 获取纯数字
    const pureNumbers = text.replace(/\D/g, '').slice(0, 11);
    onChange(pureNumbers);
  }, [onChange]);

  const handleClear = useCallback(() => {
    onChange('');
  }, [onChange]);

  return (
    <View style={styles.container}>
      <View style={styles.inputWrapper}>
        <View style={styles.countryCode}>
          <Text style={styles.countryCodeText}>+86</Text>
        </View>
        <TextInput
          style={[styles.input, disabled && styles.inputDisabled]}
          value={formatPhoneNumber(value)}
          onChangeText={handleChangeText}
          placeholder={placeholder}
          placeholderTextColor="#999"
          keyboardType="phone-pad"
          maxLength={11}
          editable={!disabled}
          selectTextOnFocus
        />
        {showClearButton && value.length > 0 && (
          <TouchableOpacity style={styles.clearButton} onPress={handleClear}>
            <Text style={styles.clearButtonText}>✕</Text>
          </TouchableOpacity>
        )}
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    marginVertical: 8,
  },
  inputWrapper: {
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    backgroundColor: '#fff',
    paddingHorizontal: 12,
  },
  countryCode: {
    paddingRight: 12,
    borderRightWidth: 1,
    borderRightColor: '#ddd',
    marginRight: 12,
  },
  countryCodeText: {
    fontSize: 16,
    color: '#333',
    fontWeight: '500',
  },
  input: {
    flex: 1,
    height: 48,
    fontSize: 16,
    color: '#333',
  },
  inputDisabled: {
    backgroundColor: '#f5f5f5',
  },
  clearButton: {
    padding: 8,
  },
  clearButtonText: {
    fontSize: 16,
    color: '#999',
  },
});

3.2 电话号码验证Hook

tsx 复制代码
// hooks/usePhoneValidation.ts
import { useState, useCallback, useMemo } from 'react';

// 中国大陆手机号正则
const CHINA_PHONE_REGEX = /^1[3-9]\d{9}$/;

// 国际手机号正则(简化版)
const INTERNATIONAL_PHONE_REGEX = /^\+?[1-9]\d{6,14}$/;

interface ValidationState {
  isValid: boolean;
  errorMessage: string;
  phoneType: 'china' | 'international' | 'unknown';
}

export const usePhoneValidation = (phoneNumber: string) => {
  const [validationState, setValidationState] = useState<ValidationState>({
    isValid: false,
    errorMessage: '',
    phoneType: 'unknown',
  });

  const validate = useCallback((phone: string): ValidationState => {
    const purePhone = phone.replace(/\D/g, '');
    
    // 空值处理
    if (!purePhone || purePhone.length === 0) {
      return {
        isValid: false,
        errorMessage: '请输入电话号码',
        phoneType: 'unknown',
      };
    }

    // 长度验证
    if (purePhone.length < 7) {
      return {
        isValid: false,
        errorMessage: '电话号码长度不足',
        phoneType: 'unknown',
      };
    }

    if (purePhone.length > 11) {
      return {
        isValid: false,
        errorMessage: '电话号码长度超限',
        phoneType: 'unknown',
      };
    }

    // 中国大陆手机号验证
    if (CHINA_PHONE_REGEX.test(purePhone)) {
      return {
        isValid: true,
        errorMessage: '',
        phoneType: 'china',
      };
    }

    // 国际号码验证
    if (INTERNATIONAL_PHONE_REGEX.test(phone)) {
      return {
        isValid: true,
        errorMessage: '',
        phoneType: 'international',
      };
    }

    return {
      isValid: false,
      errorMessage: '电话号码格式不正确',
      phoneType: 'unknown',
    };
  }, []);

  // 实时验证
  useMemo(() => {
    const result = validate(phoneNumber);
    setValidationState(result);
  }, [phoneNumber, validate]);

  return {
    ...validationState,
    validate,
  };
};

3.3 拨打电话功能实现

tsx 复制代码
// utils/PhoneCaller.ts
import { Linking, Platform, Alert } from 'react-native';

export class PhoneCaller {
  /**
   * 拨打电话号码
   * @param phoneNumber 电话号码
   * @param confirm 是否需要确认弹窗
   */
  static async call(phoneNumber: string, confirm: boolean = true): Promise<void> {
    // 清理号码,只保留数字和+号
    const cleanNumber = phoneNumber.replace(/[^\d+]/g, '');

    if (!cleanNumber || cleanNumber.length < 7) {
      Alert.alert('提示', '电话号码格式不正确');
      return;
    }

    // 确认弹窗
    if (confirm) {
      const confirmed = await new Promise<boolean>((resolve) => {
        Alert.alert(
          '拨打电话',
          `确定要拨打 ${this.formatDisplayNumber(cleanNumber)} 吗?`,
          [
            { text: '取消', style: 'cancel', onPress: () => resolve(false) },
            { text: '拨打', onPress: () => resolve(true) },
          ]
        );
      });

      if (!confirmed) return;
    }

    try {
      // HarmonyOS 使用 tel: 协议
      const phoneUrl = Platform.OS === 'harmony' 
        ? `tel:${cleanNumber}`
        : `tel:${cleanNumber}`;
      
      const supported = await Linking.canOpenURL(phoneUrl);
      
      if (supported) {
        await Linking.openURL(phoneUrl);
      } else {
        Alert.alert('提示', '当前设备不支持拨打电话功能');
      }
    } catch (error) {
      console.error('拨打电话失败:', error);
      Alert.alert('错误', '拨打电话失败,请重试');
    }
  }

  /**
   * 格式化显示号码
   */
  static formatDisplayNumber(number: string): string {
    const pure = number.replace(/\D/g, '');
    if (pure.length === 11) {
      return `${pure.slice(0, 3)} ${pure.slice(3, 7)} ${pure.slice(7)}`;
    }
    return number;
  }
}

🎨 四、完整页面示例

tsx 复制代码
// pages/PhoneInputPage.tsx
import React, { useState, useCallback } from 'react';
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  ScrollView,
  SafeAreaView,
  StatusBar,
} from 'react-native';
import { PhoneNumberInput } from '../components/PhoneNumberInput';
import { usePhoneValidation } from '../hooks/usePhoneValidation';
import { PhoneCaller } from '../utils/PhoneCaller';

export const PhoneInputPage: React.FC = () => {
  const [phoneNumber, setPhoneNumber] = useState<string>('');
  const { isValid, errorMessage, phoneType } = usePhoneValidation(phoneNumber);

  const handleCall = useCallback(() => {
    if (!isValid) {
      return;
    }
    PhoneCaller.call(phoneNumber, true);
  }, [phoneNumber, isValid]);

  const handleQuickFill = useCallback((number: string) => {
    setPhoneNumber(number);
  }, []);

  return (
    <SafeAreaView style={styles.container}>
      <StatusBar barStyle="dark-content" />
      <ScrollView style={styles.scrollView}>
        <View style={styles.header}>
          <Text style={styles.title}>电话号码输入</Text>
          <Text style={styles.subtitle}>HarmonyOS RN 实战项目</Text>
        </View>

        <View style={styles.content}>
          {/* 输入区域 */}
          <View style={styles.inputSection}>
            <Text style={styles.label}>手机号码</Text>
            <PhoneNumberInput
              value={phoneNumber}
              onChange={setPhoneNumber}
              placeholder="请输入11位手机号码"
              showClearButton
            />
            
            {/* 验证状态提示 */}
            <View style={styles.validationHint}>
              {phoneNumber.length > 0 && (
                <Text
                  style={[
                    styles.hintText,
                    isValid ? styles.hintValid : styles.hintInvalid,
                  ]}
                >
                  {isValid ? '✓ 格式正确' : errorMessage}
                </Text>
              )}
              {isValid && (
                <Text style={styles.phoneTypeHint}>
                  类型:{phoneType === 'china' ? '中国大陆' : '国际号码'}
                </Text>
              )}
            </View>
          </View>

          {/* 快速填充示例 */}
          <View style={styles.quickFillSection}>
            <Text style={styles.sectionTitle}>快速填充测试</Text>
            <View style={styles.quickFillButtons}>
              <TouchableOpacity
                style={styles.quickFillButton}
                onPress={() => handleQuickFill('13812345678')}
              >
                <Text style={styles.quickFillText}>138****5678</Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={styles.quickFillButton}
                onPress={() => handleQuickFill('15987654321')}
              >
                <Text style={styles.quickFillText}>159****4321</Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={styles.quickFillButton}
                onPress={() => handleQuickFill('18600001111')}
              >
                <Text style={styles.quickFillText}>186****1111</Text>
              </TouchableOpacity>
            </View>
          </View>

          {/* 操作按钮 */}
          <View style={styles.actionSection}>
            <TouchableOpacity
              style={[styles.callButton, !isValid && styles.callButtonDisabled]}
              onPress={handleCall}
              disabled={!isValid}
            >
              <Text style={styles.callButtonText}>📞 拨打电话</Text>
            </TouchableOpacity>

            <TouchableOpacity
              style={styles.copyButton}
              onPress={() => {
                // 复制到剪贴板逻辑
              }}
              disabled={phoneNumber.length === 0}
            >
              <Text style={styles.copyButtonText}>📋 复制号码</Text>
            </TouchableOpacity>
          </View>

          {/* 功能说明 */}
          <View style={styles.infoSection}>
            <Text style={styles.infoTitle}>功能说明</Text>
            <View style={styles.infoList}>
              <Text style={styles.infoItem}>• 支持11位中国大陆手机号验证</Text>
              <Text style={styles.infoItem}>• 输入时自动格式化显示</Text>
              <Text style={styles.infoItem}>• 点击拨号按钮调用系统电话</Text>
              <Text style={styles.infoItem}>• 实时验证反馈用户体验</Text>
            </View>
          </View>
        </View>
      </ScrollView>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  scrollView: {
    flex: 1,
  },
  header: {
    backgroundColor: '#fff',
    paddingVertical: 24,
    paddingHorizontal: 20,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#333',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 14,
    color: '#666',
  },
  content: {
    padding: 20,
  },
  inputSection: {
    backgroundColor: '#fff',
    borderRadius: 12,
    padding: 16,
    marginBottom: 16,
  },
  label: {
    fontSize: 14,
    color: '#333',
    fontWeight: '500',
    marginBottom: 8,
  },
  validationHint: {
    marginTop: 8,
    minHeight: 40,
  },
  hintText: {
    fontSize: 13,
    marginBottom: 4,
  },
  hintValid: {
    color: '#52c41a',
  },
  hintInvalid: {
    color: '#ff4d4f',
  },
  phoneTypeHint: {
    fontSize: 12,
    color: '#999',
  },
  quickFillSection: {
    backgroundColor: '#fff',
    borderRadius: 12,
    padding: 16,
    marginBottom: 16,
  },
  sectionTitle: {
    fontSize: 14,
    color: '#333',
    fontWeight: '500',
    marginBottom: 12,
  },
  quickFillButtons: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 8,
  },
  quickFillButton: {
    backgroundColor: '#f0f0f0',
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 20,
  },
  quickFillText: {
    fontSize: 13,
    color: '#333',
  },
  actionSection: {
    gap: 12,
    marginBottom: 16,
  },
  callButton: {
    backgroundColor: '#1890ff',
    height: 48,
    borderRadius: 24,
    justifyContent: 'center',
    alignItems: 'center',
  },
  callButtonDisabled: {
    backgroundColor: '#d9d9d9',
  },
  callButtonText: {
    fontSize: 16,
    color: '#fff',
    fontWeight: '600',
  },
  copyButton: {
    backgroundColor: '#fff',
    height: 48,
    borderRadius: 24,
    justifyContent: 'center',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#1890ff',
  },
  copyButtonText: {
    fontSize: 16,
    color: '#1890ff',
    fontWeight: '600',
  },
  infoSection: {
    backgroundColor: '#fff',
    borderRadius: 12,
    padding: 16,
  },
  infoTitle: {
    fontSize: 14,
    color: '#333',
    fontWeight: '500',
    marginBottom: 12,
  },
  infoList: {
    gap: 8,
  },
  infoItem: {
    fontSize: 13,
    color: '#666',
    lineHeight: 20,
  },
});

🔧 五、进阶功能:输入格式化掩码Hook

tsx 复制代码
// hooks/useInputMask.ts
import { useState, useCallback, useMemo } from 'react';

type MaskPattern = 'phone' | 'bankCard' | 'idCard' | 'date';

interface MaskConfig {
  pattern: MaskPattern;
  separator?: string;
  groups?: number[];
}

const MASK_CONFIGS: Record<MaskPattern, MaskConfig> = {
  phone: {
    pattern: 'phone',
    separator: ' ',
    groups: [3, 4, 4],
  },
  bankCard: {
    pattern: 'bankCard',
    separator: ' ',
    groups: [4, 4, 4, 4],
  },
  idCard: {
    pattern: 'idCard',
    separator: ' ',
    groups: [6, 8, 4],
  },
  date: {
    pattern: 'date',
    separator: '-',
    groups: [4, 2, 2],
  },
};

export const useInputMask = (
  initialValue: string = '',
  maskType: MaskPattern = 'phone'
) => {
  const [rawValue, setRawValue] = useState(initialValue);
  const config = MASK_CONFIGS[maskType];

  // 格式化显示值
  const formattedValue = useMemo(() => {
    const numbers = rawValue.replace(/\D/g, '');
    
    if (!config.groups || numbers.length === 0) {
      return numbers;
    }

    let result = '';
    let startIndex = 0;

    for (const groupSize of config.groups) {
      if (startIndex >= numbers.length) break;
      
      const group = numbers.slice(startIndex, startIndex + groupSize);
      if (group) {
        if (result) result += config.separator;
        result += group;
        startIndex += groupSize;
      }
    }

    // 添加剩余数字
    if (startIndex < numbers.length) {
      if (result) result += config.separator;
      result += numbers.slice(startIndex);
    }

    return result;
  }, [rawValue, config]);

  // 设置原始值(纯数字)
  const setFormattedValue = useCallback((text: string) => {
    const numbers = text.replace(/\D/g, '');
    setRawValue(numbers);
  }, []);

  // 获取原始值
  const getRawValue = useCallback(() => rawValue, [rawValue]);

  // 重置
  const reset = useCallback(() => {
    setRawValue('');
  }, []);

  return {
    formattedValue,
    rawValue,
    setFormattedValue,
    getRawValue,
    reset,
  };
};

⚠️ 六、常见问题与解决方案

6.1 键盘类型不生效

tsx 复制代码
// 问题:HarmonyOS上 keyboardType="phone-pad" 不生效
// 解决方案:使用 keyboardType="numeric" 并配合输入限制

<TextInput
  keyboardType="numeric"
  maxLength={11}
  onChangeText={(text) => {
    // 手动过滤非数字字符
    const filtered = text.replace(/\D/g, '');
    setValue(filtered);
  }}
/>

6.2 拨号权限被拒绝

tsx 复制代码
// 在 HarmonyOS 中需要动态申请权限
import { PermissionsAndroid, Platform } from 'react-native';

async function requestCallPermission(): Promise<boolean> {
  if (Platform.OS === 'harmony') {
    // HarmonyOS 权限申请逻辑
    // 需要在 module.json5 中预先声明
    return true;
  }
  
  if (Platform.OS === 'android') {
    const granted = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.CALL_PHONE
    );
    return granted === PermissionsAndroid.RESULTS.GRANTED;
  }
  
  return true;
}

6.3 输入框焦点问题

tsx 复制代码
// 使用 useRef 管理焦点
import { useRef } from 'react';
import { TextInput } from 'react-native';

const inputRef = useRef<TextInput>(null);

// 自动聚焦
useEffect(() => {
  inputRef.current?.focus();
}, []);

// 手动聚焦
const focusInput = () => {
  inputRef.current?.focus();
};

📊 七、性能优化建议

优化点 方案 效果
输入防抖 使用 debounce 延迟验证 减少不必要的渲染
组件记忆 React.memo 包装子组件 避免重复渲染
回调缓存 useCallback 缓存事件处理 减少函数创建开销
状态拆分 分离验证状态与输入状态 精准更新
tsx 复制代码
// 防抖验证示例
import { useEffect } from 'react';
import { debounce } from 'lodash';

const debouncedValidate = debounce((phone: string) => {
  // 执行验证逻辑
}, 300);

useEffect(() => {
  debouncedValidate(phoneNumber);
  return () => debouncedValidate.cancel();
}, [phoneNumber]);

🎯 八、总结与展望

8.1 核心收获

  1. 掌握 HarmonyOS RN 输入组件适配技巧
  2. 实现完整的电话号码验证流程
  3. 学会调用系统拨号功能
  4. 理解输入格式化掩码的实现原理

8.2 后续扩展方向

  • 🔐 集成 AGC 认证服务实现短信验证码
  • 🌍 支持多国家区号选择器
  • 📱 添加联系人选择功能
  • 🎨 实现更丰富的动画反馈

8.3 资源链接


💡 提示:本文代码基于 HarmonyOS NEXT + React Native 0.72.5 编写,不同版本可能存在差异,请根据实际情况调整。
📢 欢迎加入开源鸿蒙跨平台开发者社区,一起探索更多可能性!


本文完 | 2026年2月

相关推荐
星空22234 小时前
【HarmonyOS】day44:RN_of_HarmonyOS实战项目_富文本编辑器
华为·harmonyos
松叶似针4 小时前
Flutter三方库适配OpenHarmony【doc_text】— Word 文档格式深度科普:从 OLE2 到 OOXML
flutter·harmonyos
木斯佳4 小时前
HarmonyOS实战(解决方案篇)—从实战案例了解应用并发设计
华为·harmonyos
空白诗4 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、粒子系统与流体模拟:动态粒子的视觉盛宴
flutter·harmonyos
空白诗4 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、混沌理论与奇异吸引子:从洛伦兹到音乐的动态艺术
flutter·harmonyos
lbb 小魔仙5 小时前
鸿蒙跨平台实战:React Native在OpenHarmony上的Font字体降级策略详解
react native·华为·harmonyos
hqk5 小时前
鸿蒙项目实战:手把手带你从零架构 WanAndroid 鸿蒙版
前端·架构·harmonyos
早點睡3906 小时前
Harmony Flutter 跨平台开发实战:鸿蒙与音乐律动艺术、粒子物理引力场:万有引力与排斥逻辑
flutter·华为·harmonyos
lbb 小魔仙6 小时前
鸿蒙跨平台实战:React Native在OpenHarmony上的Font字体加载管理详解
react native·华为·harmonyos