如何为关联输入框的React状态值变更添加动画效果?
React 表单输入框与滑块的同步动画实现
问题背景
开发React表单时,每个字段同时配有文本输入框和范围滑块,要求两者同步更新,但其中一个输入值变更时,另一个通过动画过渡到目标值:
- 文本输入框输入新值时,文本框立即更新,滑块在200ms内平滑过渡到目标值
- 拖动滑块时,滑块实时更新,文本框同步平滑过渡到对应值
尝试过两种方案但都有问题:
- 使用
gsap.to()直接修改状态时出现异常 - 自定义
useAnimatedStateHook(基于setInterval)实现混乱,存在快速拖动滑块失效、文本输入时滑块动画错误的问题
现有Hook的问题分析
你写的useAnimatedState存在几个核心问题:
- 未处理动画中断:快速操作输入框时,前一次的
setInterval未被清除,多个动画同时执行导致状态混乱 - 闭包陷阱:interval回调中依赖的
state/otherState是组件渲染时的旧值,无法正确获取最新状态 - 差值计算错误:动画步长的判断逻辑错误(比如
step < state - otherState),导致动画无法正确推进 - 无引用保存intervalId:组件重新渲染时会丢失interval的引用,无法及时清除动画
解决方案:改进的自定义Hook(基于requestAnimationFrame)
下面是修复后的useAnimatedState Hook,解决了上述问题,支持平滑的双向动画同步:
import { useState, useRef, useEffect } from 'react'; function useAnimatedState(initialValue, duration = 200) { // 存储最终目标值(文本/滑块操作后立即更新的状态) const [targetValue, setTargetValue] = useState(Number(initialValue)); // 存储动画过渡中的实时值(用于绑定到需要动画的输入框) const [animatedValue, setAnimatedValue] = useState(Number(initialValue)); // 保存动画帧ID,用于中断动画 const animationFrameRef = useRef(null); // 记录动画开始时间和起始值 const animationMetaRef = useRef({ startTime: null, startValue: null, }); // 执行动画的核心函数 const animate = (timestamp) => { if (!animationMetaRef.current.startTime) { animationMetaRef.current.startTime = timestamp; } // 计算动画进度(0到1) const progress = Math.min( (timestamp - animationMetaRef.current.startTime) / duration, 1 ); // 使用线性插值计算当前值 const currentValue = animationMetaRef.current.startValue + (targetValue - animationMetaRef.current.startValue) * progress; setAnimatedValue(Math.round(currentValue)); // 未完成则继续动画 if (progress < 1) { animationFrameRef.current = requestAnimationFrame(animate); } else { // 动画结束后重置元数据 animationMetaRef.current = { startTime: null, startValue: null }; } }; // 当目标值变化时启动动画 useEffect(() => { if (animatedValue !== targetValue) { // 先清除之前的动画 if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } // 设置动画起始值 animationMetaRef.current.startValue = animatedValue; animationMetaRef.current.startTime = null; // 启动新动画 animationFrameRef.current = requestAnimationFrame(animate); } // 组件卸载时清除动画 return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [targetValue, animatedValue]); // 对外暴露的设置函数:区分操作类型(text/slider) const setAnimatedState = (type, value) => { const numValue = Number(value); // 文本输入时,直接更新目标值,动画值会自动过渡 if (type === 'text') { setTargetValue(numValue); } // 滑块拖动时,直接更新动画值(实时反馈),同时同步目标值 else if (type === 'slider') { setAnimatedValue(numValue); setTargetValue(numValue); } }; // 返回文本框绑定值(目标值,立即更新)、滑块绑定值(动画值,过渡更新)、设置函数 return [targetValue, animatedValue, setAnimatedState]; }
使用示例
function FormField() { const [textValue, sliderValue, setAnimatedState] = useAnimatedState(1); return ( <div className="field"> <input type="number" value={textValue} onChange={(e) => setAnimatedState('text', e.target.value)} min="1" max="10" /> <input type="range" value={sliderValue} onChange={(e) => setAnimatedState('slider', e.target.value)} min="1" max="10" /> </div> ); }
方案说明
- 状态分离:用
targetValue存储最终目标值(文本框绑定),animatedValue存储过渡中的实时值(滑块绑定) - 动画控制:使用
requestAnimationFrame实现更流畅的动画,避免setInterval的精度问题 - 中断处理:每次启动新动画前清除旧动画,解决快速操作时的状态混乱
- 线性插值:通过计算动画进度实现平滑过渡,支持自定义动画时长
可选方案:结合GSAP实现
如果你想继续使用GSAP,只需调整状态更新逻辑,避免直接修改状态,而是通过GSAP驱动动画值:
import { useState, useRef, useEffect } from 'react'; import gsap from 'gsap'; function useGSAPAnimatedState(initialValue, duration = 200) { const [targetValue, setTargetValue] = useState(Number(initialValue)); const [animatedValue, setAnimatedValue] = useState(Number(initialValue)); const animationRef = useRef(null); useEffect(() => { if (animatedValue !== targetValue) { // 清除之前的动画 if (animationRef.current) { animationRef.current.kill(); } // 使用GSAP驱动动画值 animationRef.current = gsap.to({}, { duration: duration / 1000, startValue: animatedValue, endValue: targetValue, onUpdate: () => { setAnimatedValue(Math.round(animationRef.current.vars.endValue)); }, }); } return () => { if (animationRef.current) { animationRef.current.kill(); } }; }, [targetValue, animatedValue]); const setAnimatedState = (type, value) => { const numValue = Number(value); if (type === 'text') { setTargetValue(numValue); } else if (type === 'slider') { setAnimatedValue(numValue); setTargetValue(numValue); } }; return [targetValue, animatedValue, setAnimatedState]; }
内容的提问来源于stack exchange,提问作者Theo Johnson
相关产品推荐
相关产品推荐

