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

React Native iOS风格可扩展玻璃态底部Sheet滚动动画问题求助

React Native iOS风格可扩展玻璃态底部Sheet滚动动画问题求助

各位React Native的大佬们救命!我卡这个底部Sheet的实现已经3天了,快熬秃了😭

先说说我要做的效果

我想做一个iOS风格的玻璃态底部Sheet:

  • 初始状态占屏幕45%高度,贴在屏幕底部
  • 用户向上拖动时,Sheet能平滑向上展开,展开过程要跟拖动距离1:1,还要有普通ScrollView那种带加速度的自然滚动手感
  • 完全展开后(要么顶到屏幕顶部,要么内容全部显示),内部的内容可以正常滚动
  • 要准确计算高度,不能底部留一大片空白,也不能过度展开

现在遇到的坑

我试了两种思路,全踩雷了:

  1. 第一种:用固定高度的视图+PanResponder上移动画

    • 动画卡顿得一批,高度跳来跳去完全不平滑
    • 根本做不出原生ScrollView那种带加速度的滚动手感,硬写的阻尼感巨生硬
    • 高度完全算不准,要么底部空一大块,要么Sheet直接顶到屏幕上面去了
    • 总感觉这个思路从根上就错了,但又不知道正确的路在哪
  2. 第二种:用普通ScrollView同时处理Sheet展开和内容滚动

    • 结果Sheet展开的时候,内部内容也跟着一起滚,完全分不开这两个阶段!用户拉一下,Sheet往上走,内容也往上滑,乱糟糟的

我当前的代码

import AppointmentHistorySheet, { Ref as AppointmentHistoryRef } from '@/components/Appointments/AppointmentHistorySheet';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router';
import React, { useCallback, useRef } from 'react';
import { Animated, Dimensions, PanResponder, Text, TouchableOpacity, View } from 'react-native';
import Employees from '../assets/icons/employees.svg';
import History from '../assets/icons/history.svg';
import Map from '../assets/icons/map.svg';
import MapBottomSheet from '../components/Appointments/MapBottomSheet';
import ScheduleAppointmentSheet from '../components/Appointments/ScheduleAppointmentSheet';
import { DescriptionSection, HeadlineSection, StoriesSection } from '../components/Home';
import ProductsSection from '../components/Home/ProductsSection';
import CachedImage from '../components/UI/CachedImage';
import PlaceholderImage from '../components/UI/PlaceholderImage';
import { useAppData } from '../contexts/AppDataContext';
import styles from './index.styles';

const { height: screenHeight, width: screenWidth } = Dimensions.get('window');

// Constants for natural expansion behavior
const INITIAL_HEIGHT = screenHeight * 0.45;
const INITIAL_TOP = screenHeight * 0.55;
const FULL_HEIGHT = screenHeight;

// Calculate content height (estimate based on your sections)
const ESTIMATED_CONTENT_HEIGHT = screenHeight * 1.8; // Increased to accommodate all content including products
const EXPANSION_RANGE = Math.max(
  screenHeight * 0.55, // Minimum expansion to reach top
  ESTIMATED_CONTENT_HEIGHT - INITIAL_HEIGHT // Additional scroll to see all content
);

const Index = () => {
  const scheduleAppointmentRef = useRef<BottomSheetModal>(null);
  const mapBottomSheetRef = useRef<BottomSheetModal>(null);
  const appointmentHistoryRef = useRef<AppointmentHistoryRef>(null);
  const { businessAssets, getMainPictureUrlFromAssets, getDescriptionImageUrlFromAssets } = useAppData();
  const router = useRouter();

  // Animation values for container transformation
  const containerHeight = useRef(new Animated.Value(INITIAL_HEIGHT)).current;
  const containerTop = useRef(new Animated.Value(INITIAL_TOP)).current;

  // Track cumulative scroll offset
  const totalScrollOffset = useRef(0);

  // Helper function to update container position
  const updateContainerPosition = (offset: number) => {
    const clampedOffset = Math.max(0, Math.min(offset, EXPANSION_RANGE));

    // Calculate expansion progress for container size
    const containerProgress = Math.min(clampedOffset / (screenHeight * 0.55), 1);
    // Calculate content scroll progress for internal content positioning
    const contentScrollProgress = Math.max(0, (clampedOffset - screenHeight * 0.55) / (EXPANSION_RANGE - screenHeight * 0.55));

    // Update container dimensions - allow container to grow beyond screen height to accommodate all content
    const maxContainerHeight = Math.max(FULL_HEIGHT, ESTIMATED_CONTENT_HEIGHT);
    const newHeight = INITIAL_HEIGHT + (containerProgress * (maxContainerHeight - INITIAL_HEIGHT));
    const newTop = INITIAL_TOP - (containerProgress * INITIAL_TOP);

    // Apply content scroll offset when container is fully expanded
    const contentOffset = contentScrollProgress * (ESTIMATED_CONTENT_HEIGHT - FULL_HEIGHT + 150);
    containerHeight.setValue(newHeight);
    containerTop.setValue(newTop - contentOffset);

    return clampedOffset;
  };

  // Pan responder for handling gestures (代码未贴全,核心是用PanResponder处理拖动)
  return (
    // 用Animated.View包裹固定高度内容,通过上移动画模拟展开
  );
};

export default Index;

求各位大佬指点迷津!

我现在完全不知道该往哪个方向走了,是继续调PanResponder的方案?还是回到ScrollView的思路重新搞?或者有没有现成的轮子能直接用?麻烦各位大佬给点具体的思路或者代码调整建议,感激不尽!


社区大佬的解答

兄弟我太懂你这种卡到崩溃的感觉了!你当前的核心问题就是实现思路走偏了,而且没把Sheet的交互阶段拆开,给你捋清楚:

1. 你的方案为什么不行?

你用「固定高度视图+上移动画」的思路,本质是在自己模拟滚动的物理特性,这相当于徒手造原生ScrollView的轮子,不仅要处理加速度、阻尼,还要手动算高度,完全没必要,而且肯定做不原生。

之前用ScrollView时的「展开和内容滚动冲突」,是因为你没把Sheet展开和内容滚动拆成两个独立的交互阶段:

  • 阶段1:Sheet还没顶到屏幕顶部 → 用户拖动的是整个Sheet的位置,内容不滚动
  • 阶段2:Sheet已经顶到顶部 → 用户继续拖动才会触发内容滚动

2. 正确的实现思路(两种方案)

方案一:自己基于ScrollView+Animated实现(适合自定义需求)

核心是用原生ScrollView的滚动事件来控制Sheet高度,同时拆分交互阶段:

import React, { useRef, useState, useLayoutEffect } from 'react';
import { Animated, ScrollView, View, Dimensions, StyleSheet } from 'react-native';
import { BlurView } from 'expo-blur';

const { height: screenHeight } = Dimensions.get('window');
const INITIAL_SHEET_HEIGHT = screenHeight * 0.45;

const CustomExpandableSheet = () => {
  const scrollRef = useRef<ScrollView>(null);
  const contentHeightRef = useRef(0);
  const scrollY = useRef(new Animated.Value(0)).current;
  const [isFullyExpanded, setIsFullyExpanded] = useState(false);

  // 监听滚动事件,拆分交互阶段
  const onScroll = Animated.event(
    [{ nativeEvent: { contentOffset: { y: scrollY } } }],
    {
      useNativeDriver: false,
      listener: (e) => {
        const offsetY = e.nativeEvent.contentOffset.y;
        // 当Sheet未完全展开,且用户向上拉(offsetY为负),强制把ScrollView拉回0,不让内容滚动
        if (!isFullyExpanded && offsetY < 0) {
          scrollRef.current?.scrollTo({ y: 0, animated: false });
        }
        // 判断Sheet是否完全顶到顶部
        const currentSheetHeight = INITIAL_SHEET_HEIGHT - offsetY;
        setIsFullyExpanded(currentSheetHeight >= screenHeight);
      },
    }
  );

  // 动态获取内容真实高度,替代估算值
  const onContentLayout = (e) => {
    contentHeightRef.current = e.nativeEvent.layout.height;
  };

  // 计算Sheet的实时高度
  const sheetHeight = scrollY.interpolate({
    inputRange: [0, screenHeight - INITIAL_SHEET_HEIGHT],
    outputRange: [INITIAL_SHEET_HEIGHT, Math.max(screenHeight, contentHeightRef.current)],
    extrapolate: 'clamp',
  });

  return (
    <View style={styles.container}>
      {/* 背景遮罩,点击可关闭Sheet */}
      <View style={styles.backdrop} />
      {/* 玻璃态Sheet容器 */}
      <Animated.View style={[styles.sheetWrap, { height: sheetHeight }]}>
        <BlurView intensity={20} style={styles.blurContainer}>
          <ScrollView
            ref={scrollRef}
            onScroll={onScroll}
            scrollEventThrottle={16} // 保证动画流畅
            contentInset={{ top: screenHeight - INITIAL_SHEET_HEIGHT }}
            contentOffset={{ y: screenHeight - INITIAL_SHEET_HEIGHT }}
            scrollIndicatorInsets={{ top: screenHeight - INITIAL_SHEET_HEIGHT }}
          >
            <View onLayout={onContentLayout} style={styles.contentWrap}>
              {/* 你的实际内容:HeadlineSection、DescriptionSection、StoriesSection、ProductsSection */}
            </View>
          </ScrollView>
        </BlurView>
      </Animated.View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    top: 0,
  },
  backdrop: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.3)',
  },
  sheetWrap: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    borderTopLeftRadius: 20,
    borderTopRightRadius: 20,
    overflow: 'hidden',
  },
  blurContainer: {
    flex: 1,
  },
  contentWrap: {
    padding: 16,
  },
});

export default CustomExpandableSheet;

这个方案的好处是完全自定义,而且复用了原生ScrollView的物理滚动特性(加速度、阻尼),高度用onLayout获取真实值,不会有空白。

方案二:用你已经在装的@gorhom/bottom-sheet直接实现(最快最省心)

你用的这个库其实已经完美支持你要的效果,而且自带所有原生特性,你之前可能没用到它的进阶配置:

import React, { useRef, useMemo, useState } from 'react';
import { BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { View, Dimensions, StyleSheet } from 'react-native';
import { BlurView } from 'expo-blur';

const { height: screenHeight } = Dimensions.get('window');

const LibraryBasedSheet = () => {
  const bottomSheetRef = useRef<BottomSheetModal>(null);
  const [contentHeight, setContentHeight] = useState(screenHeight * 1.8);

  // 动态计算snapPoints:初始45%,然后全屏,最后是内容真实高度
  const snapPoints = useMemo(() => {
    return [`45%`, '100%', `${contentHeight}px`];
  }, [contentHeight]);

  return (
    <View style={styles.container}>
      <BottomSheetModal
        ref={bottomSheetRef}
        snapPoints={snapPoints}
        initialSnapIndex={0}
        enablePanDownToClose={true}
        backgroundComponent={() => (
          <BlurView intensity={20} style={styles.blurBackground} />
        )}
      >
        <BottomSheetScrollView
          onLayout={(e) => setContentHeight(e.nativeEvent.layout.height)}
        >
          {/* 你的实际内容 */}
        </BottomSheetScrollView>
      </BottomSheetModal>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  blurBackground: {
    flex: 1,
  },
});

export default LibraryBasedSheet;

这个方案几乎不用写任何交互逻辑,库会帮你处理所有的物理滚动、阶段拆分、高度计算,你只需要把内容丢进去就行,绝对比你自己写快10倍。

3. 针对你每个问题的具体解决点

  • 动画卡顿/无加速度:用原生ScrollView或者@gorhom/bottom-sheet的内置滚动,直接复用原生物理引擎,不用自己模拟
  • 高度计算错误:用onLayout获取内容真实高度,替代估算值,Sheet的最大高度就是内容高度和屏幕高度的最大值
  • 展开和内容滚动冲突:要么自己监听滚动偏移强制拆分阶段,要么让库帮你处理,本质都是把「Sheet展开」和「内容滚动」变成两个独立的交互步骤

最后说一句

你之前卡3天的核心就是没把交互阶段拆开,而且用了错误的实现方式,换成上面的任意一种方案,应该半天就能搞定!别再死磕PanResponder+固定高度的思路了,完全没必要😎

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.07 13:34:36