2026-04-01 04:48:38 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 验证码输入组件
|
|
|
|
|
|
* 参考设计:4-6位数字验证码,每个数字独立显示在一个方框中
|
|
|
|
|
|
* 支持自动聚焦、粘贴、删除回退
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useRef, useEffect, useCallback } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
TextInput,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
Keyboard,
|
|
|
|
|
|
Platform,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
ViewStyle,
|
|
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { useAppColors, type AppColors } from '../../theme';
|
|
|
|
|
|
|
|
|
|
|
|
interface VerificationCodeInputProps {
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
onChangeValue: (value: string) => void;
|
|
|
|
|
|
length?: number; // 验证码位数,默认4位
|
|
|
|
|
|
autoFocus?: boolean;
|
|
|
|
|
|
onComplete?: (value: string) => void; // 输入完成回调
|
|
|
|
|
|
containerStyle?: ViewStyle;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CELL_WIDTH = 56;
|
|
|
|
|
|
const CELL_HEIGHT = 64;
|
|
|
|
|
|
const CELL_MARGIN = 8;
|
|
|
|
|
|
|
|
|
|
|
|
export const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChangeValue,
|
|
|
|
|
|
length = 4,
|
|
|
|
|
|
autoFocus = true,
|
|
|
|
|
|
onComplete,
|
|
|
|
|
|
containerStyle,
|
|
|
|
|
|
disabled = false,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = createStyles(colors);
|
|
|
|
|
|
|
|
|
|
|
|
// 为每个数字创建一个 ref
|
|
|
|
|
|
const inputRefs = useRef<(TextInput | null)[]>(Array(length).fill(null));
|
|
|
|
|
|
|
|
|
|
|
|
// 将 value 分割为单个字符数组
|
|
|
|
|
|
const chars = value.split('').slice(0, length);
|
|
|
|
|
|
// 填充空位
|
|
|
|
|
|
while (chars.length < length) {
|
|
|
|
|
|
chars.push('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前聚焦的位置
|
|
|
|
|
|
const getFocusedIndex = useCallback(() => {
|
|
|
|
|
|
return Math.min(value.length, length - 1);
|
|
|
|
|
|
}, [value.length, length]);
|
|
|
|
|
|
|
|
|
|
|
|
// 聚焦到指定位置
|
|
|
|
|
|
const focusInput = useCallback((index: number) => {
|
|
|
|
|
|
if (index >= 0 && index < length) {
|
|
|
|
|
|
inputRefs.current[index]?.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [length]);
|
|
|
|
|
|
|
|
|
|
|
|
// 自动聚焦第一个输入框
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (autoFocus && !disabled) {
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
|
focusInput(0);
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [autoFocus, disabled, focusInput]);
|
|
|
|
|
|
|
|
|
|
|
|
// 当值改变时,自动聚焦到下一个输入框
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (value.length < length) {
|
|
|
|
|
|
focusInput(value.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 当输入完成时触发回调
|
|
|
|
|
|
if (value.length === length && onComplete) {
|
|
|
|
|
|
onComplete(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [value, length, onComplete, focusInput]);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理文本变化
|
|
|
|
|
|
const handleChangeText = (text: string, index: number) => {
|
|
|
|
|
|
if (disabled) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 只允许数字
|
|
|
|
|
|
const numericText = text.replace(/[^0-9]/g, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (numericText.length === 0) {
|
|
|
|
|
|
// 删除操作
|
|
|
|
|
|
if (value.length > index) {
|
|
|
|
|
|
// 删除当前位置的字符
|
|
|
|
|
|
const newValue = value.slice(0, index) + value.slice(index + 1);
|
|
|
|
|
|
onChangeValue(newValue);
|
|
|
|
|
|
// 聚焦到前一个输入框
|
|
|
|
|
|
if (index > 0) {
|
|
|
|
|
|
focusInput(index - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (numericText.length === 1) {
|
|
|
|
|
|
// 单个字符输入
|
|
|
|
|
|
let newValue: string;
|
|
|
|
|
|
if (index < value.length) {
|
|
|
|
|
|
// 替换已有字符
|
|
|
|
|
|
newValue = value.slice(0, index) + numericText + value.slice(index + 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 追加新字符
|
|
|
|
|
|
newValue = value + numericText;
|
|
|
|
|
|
}
|
|
|
|
|
|
onChangeValue(newValue.slice(0, length));
|
|
|
|
|
|
// 自动聚焦到下一个
|
|
|
|
|
|
if (index < length - 1) {
|
|
|
|
|
|
focusInput(index + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (numericText.length > 1) {
|
|
|
|
|
|
// 粘贴操作:尝试将粘贴的内容作为完整验证码
|
|
|
|
|
|
const newValue = numericText.slice(0, length);
|
|
|
|
|
|
onChangeValue(newValue);
|
|
|
|
|
|
// 聚焦到最后一个或下一个
|
|
|
|
|
|
const focusIndex = Math.min(newValue.length, length - 1);
|
|
|
|
|
|
focusInput(focusIndex);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理按键事件(用于删除键)
|
|
|
|
|
|
const handleKeyPress = (e: any, index: number) => {
|
|
|
|
|
|
if (disabled) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (e.nativeEvent.key === 'Backspace') {
|
|
|
|
|
|
if (value.length > index) {
|
|
|
|
|
|
// 删除当前位置的字符
|
|
|
|
|
|
const newValue = value.slice(0, index) + value.slice(index + 1);
|
|
|
|
|
|
onChangeValue(newValue);
|
|
|
|
|
|
} else if (index > 0 && value.length === index) {
|
|
|
|
|
|
// 当前位置没有字符,删除前一个
|
|
|
|
|
|
const newValue = value.slice(0, -1);
|
|
|
|
|
|
onChangeValue(newValue);
|
|
|
|
|
|
focusInput(index - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 聚焦时全选文本
|
|
|
|
|
|
const handleFocus = (index: number) => {
|
|
|
|
|
|
if (disabled) return;
|
|
|
|
|
|
inputRefs.current[index]?.setNativeProps({
|
|
|
|
|
|
selection: { start: 0, end: 1 },
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={[styles.container, containerStyle]}>
|
|
|
|
|
|
<View style={styles.cellsContainer}>
|
|
|
|
|
|
{chars.map((char, index) => {
|
|
|
|
|
|
const isFocused = value.length === index;
|
|
|
|
|
|
const isFilled = char !== '';
|
|
|
|
|
|
const isLastFilled = index === value.length - 1 && isFilled;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.cell,
|
|
|
|
|
|
isFocused && styles.cellFocused,
|
|
|
|
|
|
isFilled && styles.cellFilled,
|
|
|
|
|
|
disabled && styles.cellDisabled,
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<TextInput
|
|
|
|
|
|
ref={(ref) => {
|
|
|
|
|
|
inputRefs.current[index] = ref;
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={styles.cellInput}
|
|
|
|
|
|
value={char}
|
|
|
|
|
|
onChangeText={(text) => handleChangeText(text, index)}
|
|
|
|
|
|
onKeyPress={(e) => handleKeyPress(e, index)}
|
|
|
|
|
|
onFocus={() => handleFocus(index)}
|
|
|
|
|
|
keyboardType="number-pad"
|
|
|
|
|
|
maxLength={1}
|
|
|
|
|
|
selectTextOnFocus
|
|
|
|
|
|
editable={!disabled}
|
|
|
|
|
|
caretHidden={true}
|
|
|
|
|
|
textContentType="oneTimeCode" // iOS 自动填充支持
|
|
|
|
|
|
/>
|
|
|
|
|
|
{/* 光标指示器 */}
|
|
|
|
|
|
{isFocused && !isFilled && !disabled && (
|
|
|
|
|
|
<View style={styles.cursor} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function createStyles(colors: AppColors) {
|
2026-04-01 16:30:44 +08:00
|
|
|
|
return StyleSheet.create({
|
2026-04-01 04:48:38 +08:00
|
|
|
|
container: {
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
cellsContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
cell: {
|
|
|
|
|
|
width: CELL_WIDTH,
|
|
|
|
|
|
height: CELL_HEIGHT,
|
|
|
|
|
|
borderRadius: 12,
|
|
|
|
|
|
borderWidth: 1.5,
|
|
|
|
|
|
borderColor: colors.divider,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
marginHorizontal: CELL_MARGIN / 2,
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
},
|
|
|
|
|
|
cellFocused: {
|
|
|
|
|
|
borderColor: colors.primary.main,
|
2026-04-01 16:30:44 +08:00
|
|
|
|
backgroundColor: `${colors.primary.main}10`,
|
2026-04-01 04:48:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
cellFilled: {
|
|
|
|
|
|
borderColor: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
cellDisabled: {
|
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
|
backgroundColor: colors.background.disabled,
|
|
|
|
|
|
},
|
|
|
|
|
|
cellInput: {
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
fontSize: 28,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
padding: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
cursor: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
width: 2,
|
|
|
|
|
|
height: 28,
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
borderRadius: 1,
|
|
|
|
|
|
},
|
2026-04-01 16:30:44 +08:00
|
|
|
|
});
|
2026-04-01 04:48:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default VerificationCodeInput;
|