React Native Reanimated结合PanGestureHandler实现底部滑窗多档位切换异常求助
问题核心原因
- 手势结束判断逻辑错误:使用单次滑动的累计位移
translationY作为判断依据,该值仅代表本次手势从开始到结束的偏移量,并非滑窗当前的实际位置,导致滑动判断基准错误,无法正确跳转停靠位。 - 停靠位计算逻辑混乱:没有定义统一的适配全设备的停靠基准值,原有
height*2.5、height*4再乘以0.2的换算逻辑冗余且容易出错,不同屏幕尺寸下适配失效。 - 缺少滑动速度判断:未加入滑动速度的交互逻辑,不符合用户正常操作习惯,慢速滑动和快速滑动的跳转逻辑没有区分。
修复方案
首先统一定义三个基于屏幕高度的适配停靠点,修正动画样式的冗余换算,重写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
相关产品推荐
相关产品推荐

