React Native:如何让动画组件在初始动画后保持固定位置
问题描述
我有一个<TextInput>组件,当用户输入内容时,后方会弹出一个按钮。用React Native的Animated库实现时,每次输入文本,按钮动画都会来回“弹动”。
我需要实现:按钮在初始动画完成后保持在目标位置,同时仍能监听文本框是否为空来显示/隐藏按钮。
原始代码
import { View, TextInput, StyleSheet, Animated, TouchableOpacity } from "react-native"; import { useState } from "react"; import CurrencyInput from 'react-native-currency-input'; import { Ionicons } from "@expo/vector-icons"; type expenseItemData = { item:string; costAndSign:string; cost:number | null; } type Props = { sendItem: ({ item, cost, costAndSign }: expenseItemData) => void; } const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const AddAndConfirmExpense: React.FC<Props> = ({sendItem}) => { const [animation] = useState(new Animated.Value(-58)); const [expenseValue, setExpenseValue] = useState<number>(0.00); const [expenseItem, setExpenseItem] = useState<string>(""); const [expenseValueAndSign, SetExpenseValueAndSign] = useState<string>(""); const [buttonLayoutPersistence, setButtonLayoutPersistence] = useState({ bottom:0 }); const [validExpenseItem, setValidExpenseItem] = useState<boolean>(false); const onChangeExpenseItem = (text: string) => { setExpenseItem(text); if (text.trim().length === 0) { setValidExpenseItem(false); hideButton(); setButtonLayoutPersistence({bottom:0}) return; } if (validExpenseItem) { setButtonLayoutPersistence({bottom:-48}) return }; showButton(); setValidExpenseItem(true); }; const onButtonPress = () => { const newData:expenseItemData = { item:expenseItem, costAndSign:expenseValueAndSign, cost:expenseValue } sendItem(newData); }; const setAreaDynamicStyling = () => { if (validExpenseItem) { return { borderTopRightRadius:5, borderTopLeftRadius:5, backgroundColor:"#f5f5f5" } } return {borderRadius:5}; }; const setButtonDynamicStyling = () => { if (!validExpenseItem) return {borderRadius:5} return {borderBottomLeftRadius: 5,borderBottomRightRadius:5} }; const animatedStyle = {transform: [{translateY:animation}],}; const showButton = () => { Animated.timing(animation, { toValue: -10, duration: 1000, useNativeDriver: true, }).start(); } const hideButton = () => { Animated.timing(animation, { toValue: -58, duration: 500, useNativeDriver: true, }).start(); } return ( <View> <View style={validExpenseItem ? [styles.inputsContainer, setAreaDynamicStyling()] : [styles.inputsContainer,styles.shadowProp,setAreaDynamicStyling()]}> <TextInput style={styles.textInputArea} placeholder='Item' placeholderTextColor="#aaaaaa" onChangeText={(text) => {onChangeExpenseItem(text)}} value={expenseItem} underlineColorAndroid="transparent" autoCapitalize="none" /> <View style={styles.verticalLine}/> <CurrencyInput style={styles.currencyInputArea} value={expenseValue} onChangeValue={setExpenseValue} prefix="£" delimiter="," separator="." precision={2} minValue={0} onChangeText={(formattedValue) => { SetExpenseValueAndSign(formattedValue) }} /> </View> <AnimatedTouchable onPress={()=>{onButtonPress()}} style={[{flex:1, zIndex:-1},animatedStyle]}> <View style={[styles.confirmInputContainer, setButtonDynamicStyling(), buttonLayoutPersistence]}> <Ionicons name="checkmark-circle-outline" size={24} color="white" /> </View> </AnimatedTouchable> </View> ) } const styles = StyleSheet.create({ confirmInputContainer:{ backgroundColor:"#7f96ff", height: 48, flexDirection:"row", paddingVertical:10, justifyContent:"center", }, inputsContainer:{ backgroundColor:"white", height: 48, flexDirection:"row", paddingVertical:10, marginVertical:10, justifyContent:"space-between", }, shadowProp: { shadowColor: '#353935', shadowOffset: {width: -2, height: 4}, shadowOpacity: 0.2, shadowRadius: 4, }, textInputArea:{ width:"60%", maxWidth:"60%", marginLeft:20, }, verticalLine:{ height: "100%", width: 1, backgroundColor: "#909090", marginHorizontal:5, }, currencyInputArea:{ maxWidth:"20%", width:"20%", marginRight:20 }, }) export default AddAndConfirmExpense;
编辑补充的尝试
我已经添加了以下代码:
const [animationActive, setAnimationActive] = useState(false); useEffect(() => { if (!animationActive && validExpenseItem) { setButtonLayoutPersistence({bottom:-48}) }; if (!animationActive && !validExpenseItem) { setButtonLayoutPersistence({bottom:0}) } },[animationActive])
并修改了显示/隐藏按钮的函数:
const showButton = () => { if (animationActive) return; setAnimationActive(true) Animated.timing(animation, { toValue: -10, duration: 500, useNativeDriver: true, }).start( () => { setAnimationActive(false) } ); } const hideButton = () => { if (animationActive) return; setAnimationActive(true) Animated.timing(animation, { toValue: -58, duration: 500, useNativeDriver: true, }).start( () => { setAnimationActive(false) } ); }
同时更新了onChangeExpenseItem函数:
const onChangeExpenseItem = (text: string) => { setExpenseItem(text); if (text.trim().length === 0) { setValidExpenseItem(false); hideButton(); setButtonLayoutPersistence({bottom:0}) return; } if (!validExpenseItem && !animationActive) { setValidExpenseItem(true); showButton(); return }; };
现在情况略有改善,但仍需要更优的解决方案。
优化解决方案
核心问题在于同时混用Animated动画值和buttonLayoutPersistence状态控制按钮位置,导致状态冲突,且onChangeExpenseItem每次输入都会触发重复逻辑。以下是彻底的优化方案:
优化步骤
- 移除
buttonLayoutPersistence状态,完全用Animated.Value控制按钮位置,避免双状态冲突。 - 用
useEffect监听validExpenseItem变化,仅在状态切换时触发动画,而非每次输入都执行。 - 简化
onChangeExpenseItem逻辑,只在文本从空变非空/非空变空时更新状态。
修改后的完整代码
import { View, TextInput, StyleSheet, Animated, TouchableOpacity } from "react-native"; import { useState, useEffect } from "react"; import CurrencyInput from 'react-native-currency-input'; import { Ionicons } from "@expo/vector-icons"; type expenseItemData = { item:string; costAndSign:string; cost:number | null; } type Props = { sendItem: ({ item, cost, costAndSign }: expenseItemData) => void; } const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const AddAndConfirmExpense: React.FC<Props> = ({sendItem}) => { const [animation] = useState(new Animated.Value(-58)); // 初始隐藏位置 const [expenseValue, setExpenseValue] = useState<number>(0.00); const [expenseItem, setExpenseItem] = useState<string>(""); const [expenseValueAndSign, SetExpenseValueAndSign] = useState<string>(""); const [validExpenseItem, setValidExpenseItem] = useState<boolean>(false); const [animationActive, setAnimationActive] = useState(false); // 监听文本状态变化,自动触发动画 useEffect(() => { validExpenseItem ? showButton() : hideButton(); }, [validExpenseItem]); const onChangeExpenseItem = (text: string) => { setExpenseItem(text); // 仅在文本空/非空状态切换时更新validExpenseItem const isNonEmpty = text.trim().length > 0; if (isNonEmpty !== validExpenseItem) { setValidExpenseItem(isNonEmpty); } }; const onButtonPress = () => { const newData:expenseItemData = { item:expenseItem, costAndSign:expenseValueAndSign, cost:expenseValue } sendItem(newData); }; const setAreaDynamicStyling = () => { return validExpenseItem ? { borderTopRightRadius:5, borderTopLeftRadius:5, backgroundColor:"#f5f5f5" } : { borderRadius:5 }; }; const setButtonDynamicStyling = () => { return validExpenseItem ? { borderBottomLeftRadius:5, borderBottomRightRadius:5 } : { borderRadius:5 }; }; const animatedStyle = { transform: [{ translateY: animation }] }; const showButton = () => { if (animationActive) return; setAnimationActive(true); Animated.timing(animation, { toValue: -10, duration: 500, useNativeDriver: true, }).start(() => setAnimationActive(false)); }; const hideButton = () => { if (animationActive) return; setAnimationActive(true); Animated.timing(animation, { toValue: -58, duration: 500, useNativeDriver: true, }).start(() => setAnimationActive(false)); }; return ( <View> <View style={[ styles.inputsContainer, !validExpenseItem && styles.shadowProp, setAreaDynamicStyling() ]}> <TextInput style={styles.textInputArea} placeholder='Item' placeholderTextColor="#aaaaaa" onChangeText={onChangeExpenseItem} value={expenseItem} underlineColorAndroid="transparent" autoCapitalize="none" /> <View style={styles.verticalLine}/> <CurrencyInput style={styles.currencyInputArea} value={expenseValue} onChangeValue={setExpenseValue} prefix="£" delimiter="," separator="." precision={2} minValue={0} onChangeText={SetExpenseValueAndSign} /> </View> <AnimatedTouchable onPress={onButtonPress} style={[{flex:1, zIndex:-1}, animatedStyle]}> <View style={[styles.confirmInputContainer, setButtonDynamicStyling()]}> <Ionicons name="checkmark-circle-outline" size={24} color="white" /> </View> </AnimatedTouchable> </View> ) } const styles = StyleSheet.create({ confirmInputContainer:{ backgroundColor:"#7f96ff", height: 48, flexDirection:"row", paddingVertical:10, justifyContent:"center", }, inputsContainer:{ backgroundColor:"white", height: 48, flexDirection:"row", paddingVertical:10, marginVertical:10, justifyContent:"space-between", }, shadowProp: { shadowColor: '#353935', shadowOffset: {width: -2, height: 4}, shadowOpacity: 0.2, shadowRadius: 4, }, textInputArea:{ width:"60%", maxWidth:"60%", marginLeft:20, }, verticalLine:{ height: "100%", width: 1, backgroundColor: "#909090", marginHorizontal:5, }, currencyInputArea:{ maxWidth:"20%", width:"20%", marginRight:20, }, }) export default AddAndConfirmExpense;
优化说明
- 移除了冗余的
buttonLayoutPersistence状态,完全依赖Animated.Value控制按钮位置,避免状态冲突。 - 通过
useEffect监听文本状态变化,仅在空/非空切换时触发动画,不会每次输入都执行。 - 简化了样式逻辑的写法,代码更简洁易读。
- 保留了动画锁
animationActive,防止动画叠加触发。
内容的提问来源于stack exchange,提问作者Fergus Johnson
相关产品推荐
相关产品推荐

