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

React Native Reanimated结合PanGestureHandler实现底部滑窗多档位切换异常求助

问题核心原因
  1. 手势结束判断逻辑错误:使用单次滑动的累计位移translationY作为判断依据,该值仅代表本次手势从开始到结束的偏移量,并非滑窗当前的实际位置,导致滑动判断基准错误,无法正确跳转停靠位。
  2. 停靠位计算逻辑混乱:没有定义统一的适配全设备的停靠基准值,原有height*2.5、height*4再乘以0.2的换算逻辑冗余且容易出错,不同屏幕尺寸下适配失效。
  3. 缺少滑动速度判断:未加入滑动速度的交互逻辑,不符合用户正常操作习惯,慢速滑动和快速滑动的跳转逻辑没有区分。
修复方案

首先统一定义三个基于屏幕高度的适配停靠点,修正动画样式的冗余换算,重写onEnd手势结束判断逻辑,加入距离判定和速度判定。如果需要滑窗默认从底部弹出,仅需调整SNAP_POINTS的数值顺序即可。

修改后完整代码
import React, { useState, useEffect } from "react";
import { StyleSheet, useWindowDimensions, RefreshControl } from "react-native";
import MapView from "react-native-maps";
import styled from "styled-components";

import {
  PanGestureHandler,
  FlatList,
} from "react-native-gesture-handler";

import Animated, {
  useAnimatedGestureHandler,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated";

const initialRegion = {
  latitudeDelta: 15,
  longitudeDelta: 15,
  latitude: 60.1098678,
  longitude: 24.7385084,
};

const api =
  "http://open-api.myhelsinki.fi/v1/events/?distance_filter=60.1699%2C24.9384%2C10&language_filter=en&limit=50";

export default function App() {
  const { height } = useWindowDimensions();
  // 定义三个停靠点,可根据需求自行调整数值
  const SNAP_POINTS = {
    TOP: 0, // 初始位:滑窗顶部对齐屏幕顶部
    MIDDLE: height * 0.5, // 半屏位:滑窗顶部位于屏幕中部
    BOTTOM: height * 0.9, // 全展开位:滑窗几乎占满屏幕,可自行调整占比
  }
  const top = useSharedValue(SNAP_POINTS.TOP); // 初始值设为顶部停靠位
  const [event, setEvent] = useState([]);
  const [loading, setLoading] = useState(false);
  // 数据请求逻辑
  const fetchData = async () => {
    try {
      setLoading(true);
      const response = await fetch(api);
      const data = await response.json();
      setEvent(data.data);
      setLoading(false);
    } catch (error) {
      console.log("error", error);
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  // 移除冗余的*0.2换算,直接使用top值控制位置
  const animatedStyle = useAnimatedStyle(() => {
    return {
      top: top.value,
      bottom: 0,
    };
  });

  const gestureHandler = useAnimatedGestureHandler(
    {
      onStart(_, context) {
        context.translateY = top.value;
      },
      onActive(event, context) {
        top.value = context.translateY + event.translationY;
      },
      onEnd(event, _) {
        const currentTop = top.value;
        const velocityY = event.velocityY;

        // 快速向下滑动,直接切换到下一级停靠位
        if (velocityY > 500) {
          if (currentTop < SNAP_POINTS.MIDDLE) {
            top.value = withSpring(SNAP_POINTS.MIDDLE);
          } else if (currentTop < SNAP_POINTS.BOTTOM) {
            top.value = withSpring(SNAP_POINTS.BOTTOM);
          }
          return;
        }

        // 快速向上滑动,直接切换到上一级停靠位
        if (velocityY < -500) {
          if (currentTop > SNAP_POINTS.MIDDLE) {
            top.value = withSpring(SNAP_POINTS.MIDDLE);
          } else if (currentTop > SNAP_POINTS.TOP) {
            top.value = withSpring(SNAP_POINTS.TOP);
          }
          return;
        }

        // 慢速滑动,判断当前位置离哪个停靠点最近就停靠到哪里
        const distToTop = Math.abs(currentTop - SNAP_POINTS.TOP);
        const distToMiddle = Math.abs(currentTop - SNAP_POINTS.MIDDLE);
        const distToBottom = Math.abs(currentTop - SNAP_POINTS.BOTTOM);
        const minDist = Math.min(distToTop, distToMiddle, distToBottom);

        if (minDist === distToTop) {
          top.value = withSpring(SNAP_POINTS.TOP);
        } else if (minDist === distToMiddle) {
          top.value = withSpring(SNAP_POINTS.MIDDLE);
        } else {
          top.value = withSpring(SNAP_POINTS.BOTTOM);
        }
      },
    },
    [top]
  );

  return (
    <>
      <MapView style={styles.mapStyle} initialRegion={initialRegion} />
      <PanGestureHandler onGestureEvent={gestureHandler}>
        <Animated.View style={[styles.container, animatedStyle]}>
          <Title>I am scroll sheet</Title>
          <HeroFlatList
            data={event}
            refreshControl={
              <RefreshControl
                enabled={true}
                refreshing={loading}
                onRefresh={fetchData}
              />
            }
            keyExtractor={(_, index) => index.toString()}
            renderItem={({ item, index }) => {
              const image = item?.description.images?.map((img) => img.url) || [];
              const startDate = item?.event_dates?.starting_day;
              return (
                <EventContainer key={index}>
                  <EventImage
                    source={{
                      uri:
                        image[0] ||
                        "https://res.cloudinary.com/drewzxzgc/image/upload/v1631085536/zma1beozwbdc8zqwfhdu.jpg",
                    }}
                  />
                  <DescriptionContainer>
                    <Title ellipsizeMode="tail" numberOfLines={1}>
                      {item?.name?.en}
                    </Title>
                    <DescriptionText>
                      {item?.description?.intro || "No description available"}
                    </DescriptionText>
                    <DateText>{startDate}</DateText>
                  </DescriptionContainer>
                </EventContainer>
              );
            }}
          />
        </Animated.View>
      </PanGestureHandler>
    </>
  );
}

const styles = StyleSheet.create({
  container: {
    position: "absolute",
    left: 0,
    right: 0,
    top: 0,
    backgroundColor: "white",
    shadowOffset: {
      height: -6,
      width: 0,
    },
    shadowOpacity: 0.1,
    shadowRadius: 5,
    borderTopEndRadius: 15,
    borderTopLeftRadius: 15,
  },
  mapStyle: {
    flex: 1,
  },
});

const HeroFlatList = styled(FlatList).attrs({
  contentContainerStyle: {
    flexGrow: 1,
  },
})`
  padding: 12px;
`;

const Title = styled.Text`
  font-size: 16px;
  font-weight: 700;
  margin-bottom: 10px;
  align-self: center;
  padding: 10px;
`;

const DescriptionText = styled.Text`
  font-size: 14px;
  opacity: 0.7;
`;

const DateText = styled.Text`
  font-size: 14px;
  opacity: 0.8;
  color: #0099cc;
`;

const EventImage = styled.Image`
  width: 70px;
  height: 70px;
  border-radius: 70px;
  margin-right: 20px;
`;

const DescriptionContainer = styled.View`
  width: 200px;
`;

const EventContainer = styled(Animated.View)`
  flex-direction: row;
  padding: 20px;
  margin-bottom: 10px;
  border-radius: 20px;
  background-color: #fff;
  shadow-color: #000;
  shadow-opacity: 0.3;
  shadow-radius: 20px;
  shadow-offset: 0 10px;
`;

内容的提问来源于stack exchange,提问作者user12494839

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.01 21:39:03