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

自定义React Native底部弹窗拖拽关闭时Footer组件未同步移动问题求助

自定义React Native底部弹窗拖拽关闭时Footer组件未同步移动问题求助

我跟着网上教程在React Native里做了个自定义底部弹窗,比普通的滚动视图底部弹窗多加了头部和尾部组件,中间放滚动视图。现在弹窗功能基本正常,但有个问题:拖拽关闭弹窗的时候,Footer组件会一直固定在弹窗底部,直到Header组件缩到它上面才动。我本来期望拖拽时Footer能和弹窗其他部分一起同步移动的。我试过把Footer包在Animated.View里,也试过单独给Footer加动画,但都没解决问题。下面是我的自定义弹窗代码,方便复现问题:

import { forwardRef, useCallback, useImperativeHandle, useState } from "react";
import { Dimensions, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { 
  runOnJS, 
  useAnimatedScrollHandler, 
  useAnimatedStyle, 
  useSharedValue, 
  withSpring, 
  withTiming, 
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import BackDrop from "../Backdrop/BackDrop";
import { 
  styledContainer, 
  styledContentContainer, 
  styledIndicator, 
  styledIndicatorContainer 
} from "./styles/BottomSheetStyles";

export const BottomSheet = forwardRef(
  (
    { 
      snapTo, 
      backgroundColor, 
      backdropColour, 
      type, 
      children, 
      headerComponent, 
      footerComponent, 
      ...props 
    }, 
    ref 
  ) => {
    const insets = useSafeAreaInsets();
    const { height } = Dimensions.get("screen");
    const percentage = parseFloat(snapTo) / 100;
    const openHeight = height - height * percentage;
    const closeHeight = height;

    const topAnimation = useSharedValue(closeHeight);
    const context = useSharedValue(0);
    const scrollBegin = useSharedValue(0);
    const scrollY = useSharedValue(0);
    const [enabledScroll, setEnableScroll] = useState(true);

    const expand = useCallback(() => {
      "worklet";
      topAnimation.value = withTiming(openHeight);
    }, [openHeight, topAnimation]);

    const close = useCallback(() => {
      "worklet";
      topAnimation.value = withTiming(closeHeight);
    }, [closeHeight, topAnimation]);

    useImperativeHandle(ref, () => ({ expand, close }), [expand, close]);

    const animationStyle = useAnimatedStyle(() => {
      const top = topAnimation.value;
      return { top };
    });

    const pan = Gesture.Pan()
      .onBegin(() => {
        context.value = topAnimation.value;
      })
      .onUpdate((event) => {
        if (event.translationY < 0) {
          topAnimation.value = withSpring(openHeight, {
            damping: 100,
            stiffness: 400,
          });
        } else {
          topAnimation.value = withSpring(event.translationY + context.value, {
            damping: 100,
            stiffness: 400,
          });
        }
      })
      .onEnd(() => {
        if (topAnimation.value > openHeight + 50) {
          topAnimation.value = withSpring(closeHeight, {
            damping: 100,
            stiffness: 400,
          });
        } else {
          topAnimation.value = withSpring(openHeight, {
            damping: 100,
            stiffness: 400,
          });
        }
      });

    const onScroll = useAnimatedScrollHandler({
      onBeginDrag: (event) => {
        scrollBegin.value = event.contentOffset.y;
      },
      onScroll: (event) => {
        scrollY.value = event.contentOffset.y;
      },
    });

    const panScroll = Gesture.Pan()
      .onBegin(() => {
        context.value = topAnimation.value;
      })
      .onUpdate((event) => {
        if (event.translationY < 0) {
          runOnJS(setEnableScroll)(true);
          topAnimation.value = withSpring(openHeight, {
            damping: 100,
            stiffness: 400,
          });
        } else if (event.translationY > 0 && scrollY.value === 0) {
          runOnJS(setEnableScroll)(false);
          topAnimation.value = withSpring(
            Math.max(
              context.value + event.translationY - scrollBegin.value,
              openHeight
            ),
            { damping: 100, stiffness: 400 }
          );
        }
      })
      .onEnd(() => {
        runOnJS(setEnableScroll)(true);
        if (topAnimation.value > openHeight + 50) {
          topAnimation.value = withSpring(closeHeight, {
            damping: 100,
            stiffness: 400,
          });
        } else {
          topAnimation.value = withSpring(openHeight, {
            damping: 100,
            stiffness: 400,
          });
        }
      });

    const scrollViewGesture = Gesture.Native();

    return (
      <>
        <BackDrop 
          topAnimation={topAnimation} 
          closeHeight={closeHeight} 
          openHeight={openHeight} 
          close={close} 
          backdropColour={backdropColour} 
        />
        <GestureDetector gesture={pan}>
          <Animated.View 
            style={[
              styledContainer, 
              animationStyle, 
              { 
                backgroundColor: backgroundColor, 
                paddingBottom: insets.bottom, 
              }, 
            ]}
          >
            <View style={styledIndicatorContainer}>
              <View style={styledIndicator} />
            </View>
            <View style={styledContentContainer}>
              <View>{headerComponent}</View>
              {type === "scroll" ? (
                <GestureDetector gesture={Gesture.Simultaneous(panScroll, scrollViewGesture)}>
                  <Animated.ScrollView
                    {...props}
                    bounces={false}
                    scrollEventThrottle={16}
                    onScroll={onScroll}
                    scrollEnabled={enabledScroll}
                  >
                    {children}
                  </Animated.ScrollView>
                </GestureDetector>
              ) : (
                children
              )}
              <View>{footerComponent}</View>
            </View>
          </Animated.View>
        </GestureDetector>
      </>
    );
  }
);

问题分析

我仔细排查了代码,发现可能的核心原因有几个:

  1. Footer组件的定位逻辑错误:如果传入的footerComponent内部使用了position: absolute; bottom: 0这类相对于屏幕的定位,那么不管弹窗如何拖拽,Footer都会钉死在屏幕底部,直到弹窗高度缩小到Header覆盖它的位置。
  2. 内容容器未使用动画组件:包裹Header、ScrollView和Footer的styledContentContainer是普通View,无法跟随父容器的动画线程实时更新布局,导致Footer的位移出现延迟或完全不同步。
  3. 弹窗容器的高度计算不匹配:如果styledContainer的样式设置了固定高度,会导致容器高度与topAnimation的变化逻辑脱节,Footer的自然布局也就无法同步。

解决方案

1. 修正Footer组件的定位方式

确保footerComponent内部不要使用绝对定位到屏幕底部,改用flex布局让它贴在弹窗内容容器的底部:

  • 给中间的ScrollView设置flex: 1,让它占据Header和Footer之外的所有空间
  • Footer组件内部设置style={{ width: '100%', flex: 0 }},自然靠在ScrollView下方

2. 将内容容器替换为Animated.View

把原来的普通View换成Animated.View,让它能跟随动画线程实时更新布局:

// 替换原来的代码行
<View style={styledContentContainer}>
// 改为
<Animated.View style={styledContentContainer}>

3. 检查并修正弹窗容器的样式

确保styledContainer的样式通过top和bottom自动计算高度,不要设置固定高度:

/* styledContainer的样式示例 */
position: absolute;
left: 0;
right: 0;
bottom: 0;
/* 不要设置固定height,靠top和bottom自动计算高度 */

这样当topAnimation变化时,容器高度会自动同步为屏幕高度 - topAnimation.value,所有子内容都会跟随位移。

4. (备选)给Footer单独添加同步动画

如果上面的方法都没有效果,可以给Footer单独添加与topAnimation关联的动画,强制它同步移动:

// 在BottomSheet组件内部添加动画样式
const footerAnimatedStyle = useAnimatedStyle(() => {
  const containerHeight = height - topAnimation.value;
  const targetHeight = height - openHeight;
  return {
    transform: [
      { translateY: withTiming(containerHeight - targetHeight, { duration: 0 }) }
    ]
  };
});

// 用Animated.View包裹Footer组件
<Animated.View style={footerAnimatedStyle}>
  {footerComponent}
</Animated.View>

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.08 03:13:09