【HarmonyOS】RN of HarmonyOS实战项目:TextInput数字键盘输入优化

项目概述:本文详细介绍在HarmonyOS平台上使用React Native实现优化的数字键盘输入组件,涵盖金额输入、验证码输入、数量选择等多种数字输入场景,提供从基础实现到生产级应用的完整解决方案。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
一、项目背景
数字键盘输入是移动应用中常见的交互方式,广泛应用于金额输入、密码验证、数量选择等场景。在HarmonyOS平台上实现优化的数字输入功能需要考虑:
- 键盘类型:根据场景选择合适的键盘类型
- 格式化显示:金额千分位、小数点处理等
- 输入限制:最大值、最小值、小数位数控制
- 用户体验:光标位置、删除操作、快捷输入
二、技术架构
2.1 数字输入类型
typescript
/**
* 数字输入模式
*/
type NumberInputMode =
| 'integer' // 整数输入
| 'decimal' // 小数输入
| 'currency' // 金额输入(带千分位)
| 'percentage'; // 百分比输入
/**
* 数字输入配置
*/
interface NumberInputConfig {
mode: NumberInputMode;
minValue?: number;
maxValue?: number;
decimalPlaces?: number; // 小数位数
allowNegative?: boolean; // 是否允许负数
prefix?: string; // 前缀(如¥、$)
suffix?: string; // 后缀(如%)
thousandSeparator?: boolean; // 是否使用千分位
}
2.2 数字处理流程
整数
小数
金额
百分比
有效
无效
用户输入数字
输入模式
过滤非数字字符
处理小数点
添加千分位
添加%符号
范围验证
更新显示
拒绝输入
三、核心实现代码
3.1 数字格式化工具类
typescript
/**
* 数字格式化工具类
* 提供数字输入的各种格式化和验证功能
*
* @platform HarmonyOS 2.0+
* @react-native 0.72+
*/
export class NumberFormatter {
/**
* 格式化金额(带千分位)
*/
static formatCurrency(
value: number,
options: {
prefix?: string;
suffix?: string;
decimalPlaces?: number;
thousandSeparator?: boolean;
} = {}
): string {
const {
prefix = '',
suffix = '',
decimalPlaces = 2,
thousandSeparator = true,
} = options;
const fixed = value.toFixed(decimalPlaces);
const [integerPart, decimalPart] = fixed.split('.');
let formatted = integerPart;
if (thousandSeparator) {
formatted = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
let result = prefix + formatted;
if (decimalPlaces > 0) {
result += '.' + decimalPart;
}
return result + suffix;
}
/**
* 解析格式化的金额字符串
*/
static parseCurrency(
formatted: string,
options: {
prefix?: string;
suffix?: string;
} = {}
): number {
const { prefix = '', suffix = '' } = options;
let cleaned = formatted;
// 移除前缀
if (prefix && cleaned.startsWith(prefix)) {
cleaned = cleaned.substring(prefix.length);
}
// 移除后缀
if (suffix && cleaned.endsWith(suffix)) {
cleaned = cleaned.substring(0, cleaned.length - suffix.length);
}
// 移除千分位
cleaned = cleaned.replace(/,/g, '');
return parseFloat(cleaned) || 0;
}
/**
* 格式化百分比
*/
static formatPercentage(value: number, decimalPlaces: number = 2): string {
return `${(value * 100).toFixed(decimalPlaces)}%`;
}
/**
* 解析百分比字符串
*/
static parsePercentage(formatted: string): number {
return parseFloat(formatted.replace('%', '')) / 100;
}
/**
* 验证数字输入
*/
static validateNumberInput(
input: string,
config: NumberInputConfig
): { isValid: boolean; formatted?: string; error?: string } {
let cleaned = input;
// 移除前缀后缀
if (config.prefix && cleaned.startsWith(config.prefix)) {
cleaned = cleaned.substring(config.prefix.length);
}
if (config.suffix && cleaned.endsWith(config.suffix)) {
cleaned = cleaned.substring(0, cleaned.length - config.suffix.length);
}
// 移除千分位
if (config.thousandSeparator) {
cleaned = cleaned.replace(/,/g, '');
}
// 空值检查
if (!cleaned || cleaned === '-' || cleaned === '.') {
return { isValid: true };
}
// 负号处理
if (!config.allowNegative && cleaned.includes('-')) {
return { isValid: false, error: '不允许负数' };
}
// 只允许数字、小数点、负号
if (!/^[-]?\d*\.?\d*$/.test(cleaned)) {
return { isValid: false, error: '格式不正确' };
}
const value = parseFloat(cleaned);
// 范围检查
if (config.minValue !== undefined && value < config.minValue) {
return {
isValid: false,
error: `不能小于 ${config.formatCurrency?.(config.minValue) || config.minValue}`,
};
}
if (config.maxValue !== undefined && value > config.maxValue) {
return {
isValid: false,
error: `不能大于 ${config.formatCurrency?.(config.maxValue) || config.maxValue}`,
};
}
// 小数位数检查
const decimalIndex = cleaned.indexOf('.');
if (decimalIndex !== -1) {
const decimalPlaces = cleaned.length - decimalIndex - 1;
const maxDecimals = config.decimalPlaces ?? 2;
if (decimalPlaces > maxDecimals) {
return { isValid: false, error: `最多${maxDecimals}位小数` };
}
}
return { isValid: true };
}
/**
* 限制输入范围
*/
static clamp(value: number, min?: number, max?: number): number {
let clamped = value;
if (min !== undefined) clamped = Math.max(clamped, min);
if (max !== undefined) clamped = Math.min(clamped, max);
return clamped;
}
}
3.2 数字输入组件
typescript
/**
* 智能数字输入组件
* 支持多种数字输入模式,自动格式化
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
View,
TextInput,
Text,
TouchableOpacity,
StyleSheet,
NativeSyntheticEvent,
TextInputKeyPressEventData,
} from 'react-native';
import { NumberFormatter, NumberInputConfig } from './NumberFormatter';
interface NumberInputProps {
config: NumberInputConfig;
value?: number;
onChange?: (value: number) => void;
placeholder?: string;
autoFocus?: boolean;
}
export const NumberInput: React.FC<NumberInputProps> = ({
config,
value: controlledValue,
onChange,
placeholder = '请输入数字',
autoFocus = false,
}) => {
const [displayValue, setDisplayValue] = useState('');
const [internalValue, setInternalValue] = useState<number>(0);
const inputRef = useRef<TextInput>(null);
const value = controlledValue ?? internalValue;
/**
* 初始化显示值
*/
useEffect(() => {
updateDisplayValue(value);
}, []);
/**
* 更新显示值
*/
const updateDisplayValue = useCallback((num: number) => {
let formatted = '';
switch (config.mode) {
case 'currency':
formatted = NumberFormatter.formatCurrency(num, {
prefix: config.prefix,
suffix: config.suffix,
decimalPlaces: config.decimalPlaces,
thousandSeparator: config.thousandSeparator ?? true,
});
break;
case 'percentage':
formatted = NumberFormatter.formatPercentage(
num,
config.decimalPlaces ?? 2
);
break;
case 'decimal':
formatted = num.toFixed(config.decimalPlaces ?? 2);
break;
case 'integer':
default:
formatted = Math.floor(num).toString();
break;
}
setDisplayValue(formatted);
}, [config]);
/**
* 处理输入变化
*/
const handleChange = useCallback((text: string) => {
const validation = NumberFormatter.validateNumberInput(text, config);
if (!validation.isValid && validation.error) {
return;
}
let cleaned = text;
// 移除格式化符号进行解析
if (config.mode === 'currency') {
const parsed = NumberFormatter.parseCurrency(text, {
prefix: config.prefix,
suffix: config.suffix,
});
setInternalValue(parsed);
onChange?.(parsed);
} else if (config.mode === 'percentage') {
const parsed = NumberFormatter.parsePercentage(text);
setInternalValue(parsed);
onChange?.(parsed);
} else {
const num = parseFloat(text) || 0;
const clamped = NumberFormatter.clamp(
num,
config.minValue,
config.maxValue
);
setInternalValue(clamped);
onChange?.(clamped);
}
setDisplayValue(text);
}, [config, onChange]);
/**
* 处理失焦
*/
const handleBlur = useCallback(() => {
updateDisplayValue(value);
}, [value, updateDisplayValue]);
/**
* 处理聚焦
*/
const handleFocus = useCallback(() => {
// 聚焦时显示原始数字
setDisplayValue(value.toString());
}, [value]);
/**
* 增加数值
*/
const handleIncrement = useCallback(() => {
const step = config.mode === 'integer' ? 1 : 0.01;
const newValue = NumberFormatter.clamp(
value + step,
config.minValue,
config.maxValue
);
setInternalValue(newValue);
onChange?.(newValue);
updateDisplayValue(newValue);
}, [value, config, onChange, updateDisplayValue]);
/**
* 减少数值
*/
const handleDecrement = useCallback(() => {
const step = config.mode === 'integer' ? 1 : 0.01;
const newValue = NumberFormatter.clamp(
value - step,
config.minValue,
config.maxValue
);
setInternalValue(newValue);
onChange?.(newValue);
updateDisplayValue(newValue);
}, [value, config, onChange, updateDisplayValue]);
/**
* 获取键盘类型
*/
const getKeyboardType = () => {
switch (config.mode) {
case 'integer':
return 'number-pad';
case 'decimal':
case 'currency':
return 'decimal-pad';
default:
return 'numbers-and-punctuation';
}
};
return (
<View style={numberStyles.container}>
<View style={numberStyles.inputWrapper}>
{config.prefix && (
<Text style={numberStyles.prefix}>{config.prefix}</Text>
)}
<TextInput
ref={inputRef}
style={numberStyles.input}
value={displayValue}
onChangeText={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
placeholder={placeholder}
placeholderTextColor="#999"
keyboardType={getKeyboardType()}
autoFocus={autoFocus}
returnKeyType="done"
/>
{config.suffix && (
<Text style={numberStyles.suffix}>{config.suffix}</Text>
)}
{config.mode === 'integer' && (
<View style={numberStyles.stepper}>
<TouchableOpacity
onPress={handleDecrement}
style={numberStyles.stepperButton}
disabled={config.minValue !== undefined && value <= config.minValue}
>
<Text style={numberStyles.stepperIcon}>−</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleIncrement}
style={numberStyles.stepperButton}
disabled={config.maxValue !== undefined && value >= config.maxValue}
>
<Text style={numberStyles.stepperIcon}>+</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
};
const numberStyles = StyleSheet.create({
container: {
width: '100%',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 12,
paddingHorizontal: 16,
},
prefix: {
fontSize: 16,
color: '#666',
marginRight: 4,
},
input: {
flex: 1,
height: 50,
fontSize: 18,
color: '#333',
paddingVertical: 0,
},
suffix: {
fontSize: 16,
color: '#666',
marginLeft: 4,
},
stepper: {
flexDirection: 'row',
gap: 4,
marginLeft: 8,
},
stepperButton: {
width: 32,
height: 32,
borderRadius: 6,
backgroundColor: '#F5F5F5',
justifyContent: 'center',
alignItems: 'center',
},
stepperIcon: {
fontSize: 18,
color: '#333',
fontWeight: 'bold',
},
});
3.3 验证码/密码数字输入组件
typescript
/**
* 验证码数字输入组件
* 专门用于6位数字验证码输入
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
TextInput,
} from 'react-native';
interface CodeInputProps {
length?: number;
onComplete?: (code: string) => void;
onChange?: (code: string) => void;
autoFocus?: boolean;
secureTextEntry?: boolean;
}
export const CodeInput: React.FC<CodeInputProps> = ({
length = 6,
onComplete,
onChange,
autoFocus = true,
secureTextEntry = false,
}) => {
const [code, setCode] = useState<string[]>(Array(length).fill(''));
const [focusedIndex, setFocusedIndex] = useState(0);
const inputs = useRef<(TextInput | null)[]>([]);
/**
* 聚焦指定输入框
*/
const focusInput = useCallback((index: number) => {
setFocusedIndex(index);
inputs.current[index]?.focus();
}, []);
/**
* 处理输入变化
*/
const handleChange = useCallback((value: string, index: number) => {
const numeric = value.replace(/\D/g, '').slice(-1);
const newCode = [...code];
newCode[index] = numeric;
setCode(newCode);
onChange?.(newCode.join(''));
// 自动聚焦下一个
if (numeric && index < length - 1) {
focusInput(index + 1);
}
// 检查是否完成
if (newCode.every(c => c)) {
onComplete?.(newCode.join(''));
}
}, [code, length, focusInput, onChange, onComplete]);
/**
* 处理按键
*/
const handleKeyPress = useCallback((e: any, index: number) => {
if (e.nativeEvent.key === 'Backspace' && !code[index] && index > 0) {
focusInput(index - 1);
}
}, [code, focusInput]);
/**
* 处理点击
*/
const handlePress = useCallback((index: number) => {
focusInput(index);
}, [focusInput]);
/**
* 初始聚焦
*/
useEffect(() => {
if (autoFocus) {
focusInput(0);
}
}, [autoFocus, focusInput]);
return (
<View style={codeStyles.container}>
{code.map((digit, index) => (
<TouchableOpacity
key={index}
style={[
codeStyles.box,
focusedIndex === index && codeStyles.boxFocused,
digit && codeStyles.boxFilled,
]}
onPress={() => handlePress(index)}
activeOpacity={1}
>
<TextInput
ref={ref => (inputs.current[index] = ref)}
style={codeStyles.input}
value={digit}
onChangeText={value => handleChange(value, index)}
onKeyPress={e => handleKeyPress(e, index)}
onFocus={() => setFocusedIndex(index)}
keyboardType="number-pad"
maxLength={1}
secureTextEntry={secureTextEntry}
selectTextOnFocus
textAlign="center"
/>
{!digit && focusedIndex === index && (
<View style={codeStyles.cursor} />
)}
</TouchableOpacity>
))}
</View>
);
};
const codeStyles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
gap: 10,
},
box: {
width: 48,
height: 56,
backgroundColor: '#F5F5F5',
borderRadius: 12,
borderWidth: 2,
borderColor: '#E5E5E5',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
boxFocused: {
borderColor: '#007AFF',
backgroundColor: '#FFF',
},
boxFilled: {
backgroundColor: '#E3F2FD',
borderColor: '#007AFF',
},
input: {
width: '100%',
height: '100%',
fontSize: 28,
fontWeight: 'bold',
color: '#333',
letterSpacing: 0,
},
cursor: {
position: 'absolute',
width: 2,
height: 28,
backgroundColor: '#007AFF',
borderRadius: 1,
},
});
3.4 数字选择器组件
typescript
/**
* 数字选择器组件
* 带动画效果的数字选择器
*/
import React, { useState, useCallback } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Animated,
} from 'react-native';
interface NumberPickerProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
label?: string;
}
export const NumberPicker: React.FC<NumberPickerProps> = ({
value,
onChange,
min = 0,
max = 100,
step = 1,
label,
}) => {
const scaleAnim = React.useRef(new Animated.Value(1)).current;
/**
* 动画效果
*/
const animate = useCallback(() => {
Animated.sequence([
Animated.timing(scaleAnim, {
toValue: 0.9,
duration: 100,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}),
]).start();
}, [scaleAnim]);
/**
* 增加数值
*/
const handleIncrement = useCallback(() => {
const newValue = Math.min(value + step, max);
if (newValue !== value) {
onChange(newValue);
animate();
}
}, [value, step, max, onChange, animate]);
/**
* 减少数值
*/
const handleDecrement = useCallback(() => {
const newValue = Math.max(value - step, min);
if (newValue !== value) {
onChange(newValue);
animate();
}
}, [value, step, min, onChange, animate]);
return (
<View style={pickerStyles.container}>
{label && <Text style={pickerStyles.label}>{label}</Text>}
<View style={pickerStyles.picker}>
<TouchableOpacity
style={[
pickerStyles.button,
value <= min && pickerStyles.buttonDisabled,
]}
onPress={handleDecrement}
disabled={value <= min}
activeOpacity={0.7}
>
<Text style={pickerStyles.buttonText}>−</Text>
</TouchableOpacity>
<Animated.View
style={[
pickerStyles.valueContainer,
{ transform: [{ scale: scaleAnim }] },
]}
>
<Text style={pickerStyles.value}>{value}</Text>
</Animated.View>
<TouchableOpacity
style={[
pickerStyles.button,
value >= max && pickerStyles.buttonDisabled,
]}
onPress={handleIncrement}
disabled={value >= max}
activeOpacity={0.7}
>
<Text style={pickerStyles.buttonText}>+</Text>
</TouchableOpacity>
</View>
</View>
);
};
const pickerStyles = StyleSheet.create({
container: {
alignItems: 'center',
},
label: {
fontSize: 14,
color: '#666',
marginBottom: 12,
},
picker: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 16,
padding: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
button: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
buttonDisabled: {
backgroundColor: '#E5E5E5',
},
buttonText: {
fontSize: 24,
color: '#fff',
fontWeight: 'bold',
},
valueContainer: {
minWidth: 80,
height: 44,
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: 16,
},
value: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
},
});
3.5 使用示例
typescript
/**
* 数字输入组件使用示例
*/
import React, { useState } from 'react';
import {
View,
StyleSheet,
ScrollView,
SafeAreaView,
} from 'react-native';
import { NumberInput } from './NumberInput';
import { CodeInput } from './CodeInput';
import { NumberPicker } from './NumberPicker';
const NumberInputDemo: React.FC = () => {
const [amount, setAmount] = useState(0);
const [quantity, setQuantity] = useState(1);
const [percentage, setPercentage] = useState(0);
const [code, setCode] = useState('');
return (
<SafeAreaView style={demoStyles.container}>
<ScrollView contentContainerStyle={demoStyles.content}>
<Text style={demoStyles.title}>数字键盘输入</Text>
{/* 金额输入 */}
<View style={demoStyles.section}>
<Text style={demoStyles.sectionTitle}>金额输入</Text>
<NumberInput
config={{
mode: 'currency',
prefix: '¥',
minValue: 0,
maxValue: 999999,
decimalPlaces: 2,
}}
value={amount}
onChange={setAmount}
placeholder="请输入金额"
/>
<Text style={demoStyles.valueText}>
当前值: ¥{amount.toFixed(2)}
</Text>
</View>
{/* 数量选择 */}
<View style={demoStyles.section}>
<Text style={demoStyles.sectionTitle}>数量选择</Text>
<NumberPicker
value={quantity}
onChange={setQuantity}
min={1}
max={99}
step={1}
label="商品数量"
/>
</View>
{/* 百分比输入 */}
<View style={demoStyles.section}>
<Text style={demoStyles.sectionTitle}>百分比输入</Text>
<NumberInput
config={{
mode: 'percentage',
minValue: 0,
maxValue: 100,
decimalPlaces: 1,
}}
value={percentage}
onChange={setPercentage}
placeholder="请输入百分比"
/>
</View>
{/* 验证码输入 */}
<View style={demoStyles.section}>
<Text style={demoStyles.sectionTitle}>验证码输入</Text>
<CodeInput
length={6}
onChange={setCode}
onComplete={(c) => console.log('Complete:', c)}
/>
{code.length > 0 && (
<Text style={demoStyles.valueText}>
输入: {code}
</Text>
)}
</View>
</ScrollView>
</SafeAreaView>
);
};
const demoStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
content: {
padding: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginBottom: 30,
textAlign: 'center',
},
section: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 12,
},
valueText: {
fontSize: 14,
color: '#999',
marginTop: 8,
textAlign: 'center',
},
});
export default NumberInputDemo;
四、最佳实践
| 功能 | 实现方式 | 效果 |
|---|---|---|
| 金额格式化 | 千分位分隔符 | 清晰的金额显示 |
| 验证码输入 | 自动聚焦下一格 | 流畅的输入体验 |
| 数字选择器 | 带动画的增减按钮 | 直观的交互 |
| 小数处理 | 可配置小数位数 | 灵活的精度控制 |
| 范围限制 | min/max值验证 | 防止无效输入 |
五、总结
本文详细介绍了在HarmonyOS平台上实现数字键盘输入的完整方案,涵盖:
- 多种输入模式:整数、小数、金额、百分比
- 格式化显示:千分位、货币符号、百分比符号
- 专用组件:验证码输入、数字选择器
- 用户体验:自动聚焦、动画效果、范围限制
完整项目代码 :AtomGitDemos