我正在使用 Firebase 后端创建一个 Expo React Native 项目。
我想向我的用户显示帖子,以便他们可以像 Instagram 卷轴一样垂直滚动。
所以我使用了来自react-native的Flatlist。
问题:我正在从我的 firebase 中获取平面列表的内容,它由一个视频和一些显示在帖子顶部的 Ui 交互元素组成。
但由于某种原因,当用户滚动时,我无法在用户屏幕中保留单个帖子。
每次发生滚动时,前一篇文章底部的某些部分在用户的视口中可见。而且这个重叠部分随着每一篇文章的发布而增加。
滚动浏览 5-6 个帖子后,2 个视频同时呈现在用户视口中。
当前的代码非常未经优化且混乱,但这一个问题正在造成糟糕的体验。
import React, { useEffect, useState } from 'react';
import { View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import Scroller from '../components/Scroller';
import { getCurrentUserKeywords, getPost } from '../redux/actions/user';
const ScrollScreen = () => {
const { posts } = useSelector(store => store.user);
const [change, setChange] = useState(false);
const [filteredPosts, setFilteredPosts] = useState([]);
const dispatch = useDispatch();
useEffect(() => {
(
async function () {
dispatch(getPost());
}
)()
}, [])
useEffect(() => {
console.log('change')
setChange(prev => !prev);
}, [posts])
useEffect(() => {
const filterPostsByKeywords = async () => {
const keywords = await getCurrentUserKeywords();
// Fetch the current user's keywords
const filtered = posts.filter(post =>
post.hashtags.some(hashtag => keywords.includes(hashtag))
// each post has a 'hashtags' array
);
setFilteredPosts(filtered);
console.log(`Filtered posts length: ${filtered.length}`);
};
if (posts.length > 0) {
filterPostsByKeywords();
}
}, [posts]);
return (
<View>
<Scroller posts={filteredPosts} change={change} profile={false} />
</View>
)
}
export default ScrollScreen
import { useNavigation } from '@react-navigation/native';
import React, { useEffect, useRef, useState } from 'react';
import { Dimensions, FlatList, StyleSheet, View } from 'react-native';
import { useSelector } from 'react-redux';
import useMaterialNavBarHeight from '../hooks/useMaterialNavBarHeight';
import PostSingle from './post';
export default function Scroller({ posts: allPosts, change, profile, currentIndex }) {
const [posts, setPosts] = useState(allPosts);
const isScrollTab = useRef(true);
const mediaRefs = useRef([]);
const storeCellRef = useRef([]);
const currentVideoRes = useRef(null);
const navigation = useNavigation();
const { change: pageChange } = useSelector(store => store.user);
useEffect(() => {
setPosts(allPosts);
}, [allPosts, change]);
useEffect(() => {
console.log('mount');
}, [pageChange]);
useEffect(() => {
const handleScreenChange = () => {
if (currentVideoRes.current) currentVideoRes.current.stop();
isScrollTab.current = false;
};
const handleFocus = () => {
isScrollTab.current = true;
if (currentVideoRes.current) currentVideoRes.current.play();
};
const unsubscribeBlur = navigation.addListener('blur', handleScreenChange);
const unsubscribeFocus = navigation.addListener('focus', handleFocus);
return () => {
unsubscribeFocus();
unsubscribeBlur();
};
}, [navigation]);
const onViewableItemsChanged = useRef(({ changed }) => {
changed.forEach(element => {
const cell = mediaRefs.current[element.key];
if (cell) {
if (element.isViewable && isScrollTab.current) {
// Stop all videos
for (let index = 0; index < storeCellRef.current.length; index++) {
const cell = storeCellRef.current[index];
cell.stop();
}
// Clear the array
storeCellRef.current = [];
// Play the current video
cell.play();
currentVideoRes.current = cell;
storeCellRef.current.push(cell);
} else {
cell.stop();
// Remove the cell from the array
storeCellRef.current = storeCellRef.current.filter(c => c !== cell);
}
}
});
});
const feedItemHeight = Dimensions.get('window').height - useMaterialNavBarHeight(profile);
const [followedUsers, setFollowedUsers] = useState(new Set());
const renderItem = ({ item, index }) => {
return (
<View style={{ height: feedItemHeight, backgroundColor: 'black' }}>
<PostSingle followedUsers={followedUsers}
setFollowedUsers={setFollowedUsers} item={item} key={index} ref={ref => (mediaRefs.current[item.id] = ref)} />
</View>
);
};
const handleEndReach = () => {
setPosts(prevPosts => [...prevPosts, ...allPosts]);
}
return (
<FlatList
windowSize={4}
data={posts}
initialScrollIndex={currentIndex}
getItemLayout={(data, index) => (
{ length: feedItemHeight, offset: feedItemHeight * index, index }
)}
renderItem={renderItem}
initialNumToRender={1}
maxToRenderPerBatch={2}
removeClippedSubviews
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}
pagingEnabled
decelerationRate={'normal'}
onViewableItemsChanged={onViewableItemsChanged.current} /*
to fix the audio leak bug
*/
keyExtractor={item => item.id || String(item.index)}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReach}
/>
);
}
const styles = StyleSheet.create({
container: { flex: 1 }
});
import { ResizeMode, Video } from "expo-av";
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
FlatList,
Image,
Linking,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
// import { useUser } from '../../hooks/useUser'
// import PostSingleOverlay from './overlay'
import { Feather } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { LinearGradient } from "expo-linear-gradient";
import { Avatar } from "react-native-paper";
import index from "uuid-random";
import { COLORS } from "../../constants";
import tw from "../../customtwrnc";
import {
LikePost,
checkFollow,
checkLike,
followUser,
getUserById,
} from "../../redux/actions/user";
import CommentModel from "./CommentModel";
import styles from "./styles";
const renderItem = ({ item }) => (
<TouchableOpacity>
<View style={tw`mx-1`}>
<Text style={tw`text-sm text-[${COLORS.secondary}] font-montserrat`}>
{item}
</Text>
</View>
</TouchableOpacity>
);
export const PostSingle = forwardRef(({ item, ...props }, parentRef) => {
const video = React.useRef(null);
const timeoutref = React.useRef(null);
const [isLoading, setIsLoading] = React.useState(true);
const [user, setUser] = useState(null);
const [longPressTimer, setLongPressTimer] = useState(null);
const [videoStop, setVideoStop] = useState(false);
const [isLike, setIsLike] = useState(false);
const [likesCount, setLikesCount] = useState(0);
const [likeLoading, setLikeLoading] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
const bottomSheetModalRef = useRef(null);
const navigation = useNavigation();
const [mute, setMute] = useState(false);
const [showPopup, setShowPopup] = useState(false);
const [imageFailedToLoad, setImageFailedToLoad] = useState(false);
const [isCommentModalVisible, setIsCommentModalVisible] = useState(false);
useImperativeHandle(parentRef, () => ({
play,
unload,
stop,
}));
useEffect(() => {
return () => {
console.log("unloading");
unload();
};
}, []);
const handleLoadStart = () => {
setIsLoading(true);
};
const handleReadyForDisplay = () => {
setIsLoading(false);
};
const play = async () => {
if (video.current == null) {
return;
}
// if video is already playing return
const status = await video.current.getStatusAsync();
if (status?.isPlaying) {
return;
}
try {
await video.current.playAsync();
} catch (e) {
console.log("error", e.message);
}
};
const stop = async () => {
if (video.current == null) {
return;
}
// if video is already stopped return
const status = await video.current.getStatusAsync();
if (!status?.isPlaying) {
return;
}
try {
await video.current.stopAsync();
} catch (e) {
console.log(e);
}
};
const unload = async () => {
if (video.current == null) {
return;
}
try {
await video.current.unloadAsync();
} catch (e) {
console.log(e);
}
};
const handleOpen = (link) => {
Linking.openURL(link);
};
const handlePressIn = () => {
const timer = setTimeout(async () => {
await video.current.pauseAsync();
setVideoStop(true);
}, 1000);
setLongPressTimer(timer);
};
const handlePressOut = async () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
}
await video.current.playAsync();
setVideoStop(false);
};
useEffect(() => {
(async function () {
const user = await getUserById(item.creator);
const like = await checkLike(item.id);
setUser(user);
setIsLike(like);
})();
}, []);
useEffect(() => {
setLikesCount(item.likesCount);
}, [item]);
const HandleLike = async (id) => {
if (likeLoading) {
return;
}
setLikeLoading(true);
if (isLike) {
setIsLike(false);
setLikesCount((prev) => prev - 1);
} else {
setIsLike(true);
setLikesCount((prev) => prev + 1);
}
await LikePost(id);
setLikeLoading(false);
};
const OpenComments = () => {
console.log("opening...");
// bottomSheetModalRef.current.open();
setIsCommentModalVisible(!isCommentModalVisible);
};
const closeModal = () => {
setIsCommentModalVisible(!isCommentModalVisible);
};
const handleProfile = () => {
stop();
navigation.navigate("profile", { uid: user.uid });
};
const handleMuteToggle = async () => {
if (video.current) {
video.current.setIsMutedAsync(!mute);
}
setMute((prev) => !prev);
setShowPopup(true);
if (timeoutref.current) {
clearTimeout(timeoutref);
}
timeoutref.current = setTimeout(() => {
setShowPopup(false);
}, 500);
console.log("muting...");
};
const handleMuteToggleLong = () => {
setShowPopup(false);
video.current.setIsMutedAsync(false);
};
const [loading, setLoading] = useState(false);
const { followedUsers, setFollowedUsers } = props;
const isFollowing = followedUsers.has(item.creator);
useEffect(() => {
(async function () {
if (user) {
const ans = await checkFollow(user.uid);
if (ans) {
followedUsers.add(user.uid);
} else {
followedUsers.delete(user.uid);
}
setFollowedUsers(new Set(followedUsers));
}
})();
}, [user]);
const handleFollow = async () => {
if (loading) {
console.log("loading...");
return;
}
setLoading(true);
const isCurrentlyFollowing = followedUsers.has(user.uid);
await followUser(user.uid, isCurrentlyFollowing);
if (isCurrentlyFollowing) {
followedUsers.delete(user.uid);
} else {
followedUsers.add(user.uid);
}
setFollowedUsers(new Set(followedUsers));
setLoading(false);
};
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
return (
<>
{isLoading && (
<View
style={{
justifyContent: "center",
alignItems: "center",
backgroundColor: COLORS.primary,
zIndex: 10,
position: "absolute",
bottom: 0,
top: 0,
right: 0,
left: 0,
}}
>
<ActivityIndicator size={79} color={COLORS.secondary} />
</View>
)}
{showPopup && (
<TouchableWithoutFeedback onPress={handleMuteToggle}>
<View
style={{
justifyContent: "center",
alignItems: "center",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 10,
position: "absolute",
bottom: 0,
top: 0,
right: 0,
left: 0,
}}
>
<View style={tw`p-2 bg-black/50 rounded-full`}>
<Feather
name={mute ? "volume-x" : "volume-2"}
size={30}
color={"white"}
/>
</View>
</View>
</TouchableWithoutFeedback>
)}
<TouchableWithoutFeedback
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handleMuteToggle}
onLongPress={handleMuteToggleLong}
>
<View
style={tw`absolute bottom-0 left-0 right-0 top-0 justify-end z-2`}
>
{!videoStop && (
<>
<View style={tw`py-2 px-4 flex gap-2 items-end`}>
<View style={tw`flex gap-0 items-center`}>
<TouchableOpacity onPress={handleProfile}>
{user?.photoURL &&
isValidUrl(user?.photoURL) &&
!imageFailedToLoad ? (
<Image
source={{ uri: user?.photoURL }}
style={{
width: 35,
height: 35,
resizeMode: "contain",
marginBottom: 5,
borderRadius: 9999,
marginBottom: 8,
}}
onError={() => setImageFailedToLoad(true)}
/>
) : (
<Avatar.Icon
size={36}
backgroundColor={COLORS.secondary}
icon={"account"}
/>
)}
</TouchableOpacity>
</View>
<View style={tw`flex gap-0 items-center`}>
<TouchableOpacity onPress={() => HandleLike(item.id)}>
<Image
source={
isLike
? require("../../../assets/heartfill.png")
: require("../../../assets/heart.png")
}
style={{
width: 31,
resizeMode: "contain",
marginBottom: 5,
}}
/>
</TouchableOpacity>
<Text style={tw`text-white text-sm font-montserrat`}>
{likesCount}
</Text>
</View>
<View style={tw`flex gap-0 items-center`}>
<TouchableOpacity onPress={OpenComments}>
<Image
source={require("../../../assets/comment.png")}
style={{
width: 34,
resizeMode: "contain",
marginBottom: 5,
}}
/>
</TouchableOpacity>
<Text style={tw`text-white text-sm font-montserrat`}>
{item.commentsCount}
</Text>
</View>
<View style={tw`flex gap-0 items-center`}>
<Image
source={require("../../../assets/share.png")}
style={{
width: 34,
resizeMode: "contain",
marginBottom: 5,
}}
/>
<Text style={tw`text-white text-sm font-montserrat`}>
Share
</Text>
</View>
</View>
<View style={tw`px-4 flex-row justify-start items-center gap-3 `}>
<Text style={tw`text-white text-lg font-montserrat`}>
@{user?.username}
</Text>
<TouchableOpacity
style={tw`py-1 px-2 rounded-md border border-white flex flex-row items-center gap-1 ${
isFollowing ? `bg-transparent text-white` : ""
}`}
onPress={handleFollow}
>
<Text style={tw`text-white text-base font-montserrat `}>
{isFollowing ? "Unfollow" : "Follow"}
</Text>
</TouchableOpacity>
</View>
<View style={tw`py-2 px-4`}>
<FlatList
key={index}
data={item.hashtags}
renderItem={renderItem}
keyExtractor={(item, index) => item.key || String(index)}
horizontal={true} // Set horizontal to true
/>
</View>
<View
style={tw`flex flex-row items-center gap-2 border-t border-b border-[${COLORS.secondary}] pl-4`}
>
<Text
style={tw`text-sm text-[${COLORS.secondary}] font-montserrat`}
>
News
</Text>
<Text
style={tw`text-[10px] text-white flex-1 font-montserrat`}
numberOfLines={3}
>
{item.description}
</Text>
<TouchableOpacity onPress={() => handleOpen(item.newslink)}>
<LinearGradient
colors={["#53C8D8", "#668AF7"]}
style={tw`py-2`}
>
<View style={tw`w-8 flex justify-center items-center`}>
<Feather
name={"chevron-right"}
size={30}
color={"white"}
/>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
</>
)}
{isCommentModalVisible && (
<CommentModel
isVisible={isCommentModalVisible}
onClose={closeModal}
post={item}
/>
)}
</View>
</TouchableWithoutFeedback>
<View style={tw`flex-1 flex py-2 bg-black`}>
<Video
ref={video}
style={styles.container}
source={{
uri: item.media[0],
}}
useNativeControls={false}
resizeMode={ResizeMode.COVER}
shouldPlay={false}
isLooping
usePoster
onLoadStart={handleLoadStart}
posterSource={{ uri: item.media[1] }}
posterStyle={{ resizeMode: "cover", height: "100%" }}
onReadyForDisplay={handleReadyForDisplay}
/>
</View>
</>
);
});
export default PostSingle;
我不明白代码中的问题出在哪里。
这可能是由于我使用的绝对样式。
但这是一个非常奇怪的问题。
我已经尝试了边距和填充调整,但没有尝试其他任何调整。
注意:它仅在我滚动时发生,并且随着每次滚动而增加
您应该能够使用scrollToIndex,因为您通过getItemLayout获得了平面列表中每个项目的大小,您可以使用滚动到特定索引 scrollToIndex 语法如下
scrollToIndex: (params: {
index: number;
animated?: boolean;
viewOffset?: number;
viewPosition?: number;
});
您可以了解更多信息https://reactnative.dev/docs/flatlist#scrolltoindex