You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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每次输入都会触发重复逻辑。以下是彻底的优化方案:

优化步骤

  1. 移除buttonLayoutPersistence状态,完全用Animated.Value控制按钮位置,避免双状态冲突。
  2. 用useEffect监听validExpenseItem变化,仅在状态切换时触发动画,而非每次输入都执行。
  3. 简化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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 02:26:05