该代码实现了一个带键盘跟随功能的验证码输入组件,主要特性包括: 响应式设计,通过useVisualViewport钩子检测键盘高度,自动调整输入框位置避免遮挡 验证码输入:支持6位数字输入,包含复制粘贴功能和样式化输入框显示 交互功能: 自动发送验证码 60秒重发倒计时 键盘弹出时自动聚焦输入框 UI组件:包含关闭按钮、手机号显示、验证码输入框和确认按钮 动画效果:淡入/上滑动画,输入框激活状态视觉效果 组件适用于移动端验证场景,提供良好的键盘交互体验。
效果预览:



1.获取键盘高度组件
html
import { useState, useEffect } from "react";
export default function UseVisualViewport() {
const [viewport, setViewport] = useState({
width: window.innerWidth,
height: window.innerHeight,
visualWidth: window.visualViewport ? window.visualViewport.width : window.innerWidth,
visualHeight: window.visualViewport ? window.visualViewport.height : window.innerHeight,
keyboardHeight: 0,
isKeyboardVisible: false
});
useEffect(() => {
const handler = () => {
if (!window.visualViewport) {
setViewport(prev => ({
...prev,
width: window.innerWidth,
height: window.innerHeight
}));
return;
}
const keyboardHeight = Math.max(0, window.innerHeight - window.visualViewport.height);
const isKeyboardVisible = keyboardHeight > 50; // 键盘高度大于50px认为键盘可见
setViewport({
width: window.innerWidth,
height: window.innerHeight,
visualWidth: window.visualViewport.width,
visualHeight: window.visualViewport.height,
keyboardHeight,
isKeyboardVisible
});
};
// 添加事件监听
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", handler);
window.visualViewport.addEventListener("scroll", handler);
} else {
// 如果不支持visualViewport,使用resize事件作为fallback
window.addEventListener("resize", handler);
}
// 初始调用一次
handler();
return () => {
if (window.visualViewport) {
window.visualViewport.removeEventListener("resize", handler);
window.visualViewport.removeEventListener("scroll", handler);
} else {
window.removeEventListener("resize", handler);
}
};
}, []);
return viewport;
}
2.代码主要内容
html
import React, { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import UseVisualViewport from "@/components/UseVisualViewport";
import {maskPhoneNumber} from "util/util";
const CodeInputDrawer = (props) => {
const {
phoneNumber,
visible = false,
onClose,
onSendCode,
onConfirm,
codeLength = 6,
countdownTime = 60,
} = props;
const [code, setCode] = useState("");
const [countdown, setCountdown] = useState(0);
const [isInputFocused, setIsInputFocused] = useState(false);
const inputRef = useRef(null);
const containerRef = useRef(null);
const viewport = UseVisualViewport();
// 处理输入框聚焦
const handleInputFocus = () => {
if (inputRef.current) {
inputRef.current.focus();
setIsInputFocused(true);
// 确保输入框在键盘上方可见
if (viewport.isKeyboardVisible && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// 如果容器底部在键盘区域内,滚动到可见位置
if (containerRect.bottom > viewport.visualHeight) {
const scrollOffset = containerRect.bottom - viewport.visualHeight + 20;
window.scrollBy({
top: scrollOffset,
behavior: 'smooth'
});
}
}
}
};
// 处理输入框失焦
const handleInputBlur = () => {
setIsInputFocused(false);
};
// 处理输入变化
const handleInputChange = (e) => {
const value = e.target.value.replace(/[^0-9]/g, "").slice(0, codeLength);
setCode(value);
if (value.length === codeLength && props.onComplete) {
props.onComplete(value);
}
};
// 发送验证码
const handleSendCode = async () => {
if (!phoneNumber || phoneNumber.length !== 11) {
alert("请输入正确的手机号码");
return;
}
if (countdown > 0) {
return;
}
try {
if (onSendCode) {
await onSendCode(phoneNumber);
}
setCountdown(countdownTime);
} catch (error) {
console.error("发送验证码失败:", error);
alert("发送验证码失败,请重试");
}
};
// 确认输入的验证码
const handleConfirm = () => {
if (code.length !== codeLength) {
alert("请输入完整的验证码");
return;
}
if (onConfirm) {
onConfirm(code);
}
};
// 关闭抽屉
const handleClose = () => {
if (onClose) {
onClose();
}
};
// 倒计时效果
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
// 当键盘显示/隐藏时调整容器位置
useEffect(() => {
if (!visible) return;
// 键盘显示时确保内容可见
if (viewport.isKeyboardVisible && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
// 如果容器被键盘遮挡,滚动到可见位置
if (containerRect.bottom > viewport.visualHeight) {
const scrollOffset = containerRect.bottom - viewport.visualHeight + 20;
window.scrollBy({
top: scrollOffset,
behavior: 'smooth'
});
}
}
}, [viewport.isKeyboardVisible, visible]);
// 点击容器外部时失焦输入框
useEffect(() => {
if (!visible) return;
const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
handleInputBlur();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [visible]);
// 当抽屉可见时自动聚焦输入框
useEffect(() => {
if (visible) {
setTimeout(() => {
handleInputFocus();
}, 300);
} else {
setCode("");
setCountdown(0);
setIsInputFocused(false);
}
}, [visible]);
// 首次加载自动发送验证码
useEffect(() => {
if (visible) {
handleSendCode().catch(() => {
alert("发送验证码失败,请重试");
});
}
}, [visible]);
// 根据键盘高度获取容器样式
const getContainerStyle = () => {
if (viewport.isKeyboardVisible) {
return {
bottom: `${viewport.keyboardHeight + 20}px`, // 距离键盘20px边距
transition: "bottom 0.3s ease",
position: 'fixed',
zIndex: 1000
};
}
return {
bottom: "0",
transition: "bottom 0.3s ease",
position: 'fixed',
zIndex: 1000
};
};
// 渲染单个验证码框
const renderCodeBox = (index) => {
const digit = code[index] || "";
const isActive = index === code.length && isInputFocused;
const isFilled = digit !== "";
return (
<div
key={index}
className={`code-box ${isActive ? 'active' : ''} ${isFilled ? 'filled' : ''}`}
>
{digit}
</div>
);
};
if (!visible) {
return null;
}
return (
<div className="code-input-mask" onClick={handleClose}>
<div
ref={containerRef}
className={`code-input-container ${visible ? 'visible' : ''}`}
style={getContainerStyle()}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button className="close-btn" onClick={handleClose}>×</button>
{/* Phone number display */}
<div className="phone-display">
<div className='title-tip'>请输入验证码</div>
<div className='tip-content'>
我们已给手机号码{phoneNumber &&<span className="phone-number">{maskPhoneNumber(phoneNumber)}</span>}发送了一个6位数的验证码,输入验证码完成补款!
</div>
</div>
{/* Code boxes area */}
<div className="code-boxes-container" onClick={handleInputFocus}>
{Array.from({ length: codeLength }).map((_, index) => renderCodeBox(index))}
</div>
{/* Hidden input field */}
<input
ref={inputRef}
type="tel"
value={code}
onChange={handleInputChange}
onFocus={() => setIsInputFocused(true)}
onBlur={handleInputBlur}
className="hidden-input"
maxLength={codeLength}
inputMode="numeric"
pattern="[0-9]*"
/>
{/* Send code button */}
<div className="send-code-btn-container">
<div
className="send-code-btn"
onClick={handleSendCode}
disabled={countdown > 0 || !phoneNumber}
>
{countdown > 0 ? `重新发送(${countdown}s)` : "重新发送验证码"}
</div>
</div>
{/* Confirm button */}
<button
className="confirm-btn"
onClick={handleConfirm}
disabled={code.length !== codeLength}
>
确定
</button>
</div>
</div>
);
};
// Define prop types
CodeInputDrawer.propTypes = {
phoneNumber: PropTypes.string,
visible: PropTypes.bool,
onClose: PropTypes.func,
onSendCode: PropTypes.func,
onConfirm: PropTypes.func,
onComplete: PropTypes.func, // Add this if you have an onComplete callback
codeLength: PropTypes.number,
countdownTime: PropTypes.number,
};
// Default props
CodeInputDrawer.defaultProps = {
visible: false,
codeLength: 6,
countdownTime: 60,
};
export default CodeInputDrawer;
3.代码样式
html
.code-input-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 9999;
animation: maskFadeIn 0.2s ease;
}
.code-input-container {
width: 100%;
max-width: 100%;
background-color: white;
border-radius: 16px 16px 0 0;
padding: 24px 20px 40px;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.15);
animation: drawerSlideUp 0.2s ease;
position: relative; /* 从absolute改为relative */
transform: translateY(100%);
transition: transform 0.3s ease;
bottom: 0;
/* 确保容器保持在键盘上方 */
&.visible {
transform: translateY(0);
}
/* 响应式设计 */
@media (min-width: 768px) {
max-width: 400px;
border-radius: 16px;
margin-bottom: 20px;
}
}
.phone-display {
font-size: 14px;
color: #666;
margin-bottom: 16px;
.title-tip{
font-size: 20px;
font-weight: 500;
color: #131523;
}
.tip-content{
font-size: 14px;
font-weight: 400;
color: rgba(19, 21, 35, 0.58);
margin-top: 10px;
}
.phone-number {
color: #3484FD;
}
}
.code-boxes-container {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
.code-box {
width: 44px;
height: 52px;
border: 1px solid #D9D9D9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
background-color: #F6F6F6; // 设置背景颜色为#F6F6F6
color: #000000; // 设置字体颜色为黑色
transition: all 0.2s ease;
caret-color: #1890ff; // 设置光标颜色为蓝色
&.active {
border-color: #1890ff;
background-color: #F6F6F6; // 保持背景颜色一致
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
// 添加蓝色竖线光标效果
position: relative;
&::after {
content: '';
position: absolute;
top: 10px;
bottom: 10px;
left: 50%;
width: 2px;
background-color: #1890ff;
animation: blink 1s infinite;
}
}
&.filled {
border-color: #1890ff;
background-color: #F6F6F6; // 保持背景颜色一致
color: #000000; // 设置字体颜色为黑色
}
}
}
// 添加光标闪烁动画
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.send-code-btn-container{
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
.send-code-btn {
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
cursor: pointer;
transition: all 0.2s ease;
&:not(:disabled) {
color: #999;
}
&:disabled {
color: #999;
cursor: not-allowed;
}
}
}
.confirm-btn {
width: 100%;
height: 44px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:not(:disabled) {
background-color: #52c41a;
color: white;
&:hover {
background-color: #73d13d;
}
&:active {
background-color: #389e0d;
}
}
&:disabled {
background-color: #f5f5f5;
color: #999;
cursor: not-allowed;
}
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border: none;
background: none;
font-size: 18px;
color: #999;
cursor: pointer;
&:hover {
color: #666;
}
}
.hidden-input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
}
/* 动画效果 */
@keyframes maskFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes drawerSlideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
4.使用组件
html
<CodeInputDrawer
phoneNumber="19948765606"
visible={showCodeInput}
onClose={() => this.setState({showCodeInput: false})}
onSendCode={(phone) => {
console.log(phone)
}}
onConfirm={(code) => {
// 验证验证码
console.log("验证码:", code);
}}
/>
