feat: 实现纵向模式和平铺模式的双向无限滚动
问题描述:
- 纵向模式下,用户向上滑动触发懒加载后,向下滑动无法回到之前的内容
- 原因:纵向模式未启用累积模式,且缺少向上滚动加载上一页的功能
解决方案:
实现类似社交媒体的双向无限滚动机制:
- 向下滚动到 60% 时自动加载下一页(新内容)
- 向上滚动到顶部 10% 时自动加载上一页(旧内容)
- 加载上一页后自动调整滚动位置,保持用户视图不跳动
技术实现:
1. usePagination.js
- 将 VERTICAL 模式加入累积模式判断 (line 57)
- 实现 loadPrevPage 方法,支持加载上一页 (lines 285-306)
- 导出 loadPrevPage 供组件使用 (line 364)
2. VirtualizedFourRowGrid.js
- 添加 loadPrevPage prop 和 previousScrollHeight ref
- 合并双向滚动检测逻辑 (lines 67-102):
* 向下滚动: scrollPercentage > 0.6 触发 loadNextPage
* 向上滚动: scrollTop < clientHeight * 0.1 触发 loadPrevPage
- 实现滚动位置保持机制 (lines 133-161):
* 记录加载前的 scrollHeight
* 加载完成后计算高度差
* 调整 scrollTop += heightDifference 保持视图位置
3. DynamicNewsCard.js
- 从 usePagination 获取 loadPrevPage
- 传递给 EventScrollList 组件
4. EventScrollList.js
- 接收并传递 loadPrevPage 到 VirtualizedFourRowGrid
- 四排模式和纵向模式均支持双向滚动
影响范围:
- 纵向模式 (vertical mode)
- 平铺模式 (four-row mode)
测试建议:
1. 切换到纵向模式
2. 向下滚动观察是否自动加载下一页
3. 向上滚动到顶部观察是否:
- 自动加载上一页
- 滚动位置保持不变,内容不跳动
4. 切换到平铺模式验证双向滚动同样生效
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -107,7 +107,8 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
isAccumulateMode, // 新增:是否累积模式
|
isAccumulateMode, // 新增:是否累积模式
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handleModeToggle,
|
handleModeToggle,
|
||||||
loadNextPage // 新增:加载下一页
|
loadNextPage, // 新增:加载下一页
|
||||||
|
loadPrevPage // 新增:加载上一页
|
||||||
} = usePagination({
|
} = usePagination({
|
||||||
allCachedEvents,
|
allCachedEvents,
|
||||||
total,
|
total,
|
||||||
@@ -244,6 +245,7 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
displayEvents={displayEvents} // 新增:累积显示的事件列表
|
displayEvents={displayEvents} // 新增:累积显示的事件列表
|
||||||
isAccumulateMode={isAccumulateMode} // 新增:是否累积模式
|
isAccumulateMode={isAccumulateMode} // 新增:是否累积模式
|
||||||
loadNextPage={loadNextPage} // 新增:加载下一页
|
loadNextPage={loadNextPage} // 新增:加载下一页
|
||||||
|
loadPrevPage={loadPrevPage} // 新增:加载上一页
|
||||||
onFourRowEventClick={handleFourRowEventClick} // 新增:四排模式事件点击
|
onFourRowEventClick={handleFourRowEventClick} // 新增:四排模式事件点击
|
||||||
selectedEvent={selectedEvent}
|
selectedEvent={selectedEvent}
|
||||||
onEventSelect={setSelectedEvent}
|
onEventSelect={setSelectedEvent}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const EventScrollList = ({
|
|||||||
displayEvents, // 累积显示的事件列表(四排模式用)
|
displayEvents, // 累积显示的事件列表(四排模式用)
|
||||||
isAccumulateMode, // 是否累积模式
|
isAccumulateMode, // 是否累积模式
|
||||||
loadNextPage, // 加载下一页(无限滚动)
|
loadNextPage, // 加载下一页(无限滚动)
|
||||||
|
loadPrevPage, // 加载上一页(双向无限滚动)
|
||||||
onFourRowEventClick, // 四排模式事件点击回调(打开弹窗)
|
onFourRowEventClick, // 四排模式事件点击回调(打开弹窗)
|
||||||
selectedEvent,
|
selectedEvent,
|
||||||
onEventSelect,
|
onEventSelect,
|
||||||
@@ -270,7 +271,7 @@ const EventScrollList = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 模式3: 四排网格模式 - 使用虚拟滚动 + 无限滚动 */}
|
{/* 模式3: 四排网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
||||||
{mode === 'four-row' && (
|
{mode === 'four-row' && (
|
||||||
<VirtualizedFourRowGrid
|
<VirtualizedFourRowGrid
|
||||||
events={displayEvents || events} // 使用累积列表(如果有)
|
events={displayEvents || events} // 使用累积列表(如果有)
|
||||||
@@ -281,6 +282,7 @@ const EventScrollList = ({
|
|||||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
loadNextPage={loadNextPage} // 加载下一页
|
loadNextPage={loadNextPage} // 加载下一页
|
||||||
|
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
|
||||||
hasMore={hasMore} // 是否还有更多数据
|
hasMore={hasMore} // 是否还有更多数据
|
||||||
loading={loading} // 加载状态
|
loading={loading} // 加载状态
|
||||||
/>
|
/>
|
||||||
@@ -289,7 +291,7 @@ const EventScrollList = ({
|
|||||||
{/* 模式4: 纵向分栏模式 - 横向布局(时间在左,卡片在右) */}
|
{/* 模式4: 纵向分栏模式 - 横向布局(时间在左,卡片在右) */}
|
||||||
{mode === 'vertical' && (
|
{mode === 'vertical' && (
|
||||||
<Grid templateColumns="1fr 2fr" gap={6} minH="500px" maxH="800px">
|
<Grid templateColumns="1fr 2fr" gap={6} minH="500px" maxH="800px">
|
||||||
{/* 左侧:事件列表 (33.3%) - 使用虚拟滚动 + 无限滚动 */}
|
{/* 左侧:事件列表 (33.3%) - 使用虚拟滚动 + 双向无限滚动 */}
|
||||||
<GridItem>
|
<GridItem>
|
||||||
<VirtualizedFourRowGrid
|
<VirtualizedFourRowGrid
|
||||||
events={displayEvents || events} // 使用累积列表
|
events={displayEvents || events} // 使用累积列表
|
||||||
@@ -301,7 +303,8 @@ const EventScrollList = ({
|
|||||||
onToggleFollow={onToggleFollow}
|
onToggleFollow={onToggleFollow}
|
||||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
loadNextPage={loadNextPage} // 支持无限滚动
|
loadNextPage={loadNextPage} // 支持向下无限滚动
|
||||||
|
loadPrevPage={loadPrevPage} // 支持向上无限滚动(双向滚动)
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ const VirtualizedFourRowGrid = ({
|
|||||||
getTimelineBoxStyle,
|
getTimelineBoxStyle,
|
||||||
borderColor,
|
borderColor,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
|
loadPrevPage, // 新增:加载上一页
|
||||||
hasMore,
|
hasMore,
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
const parentRef = useRef(null);
|
const parentRef = useRef(null);
|
||||||
const isLoadingMore = useRef(false); // 防止重复加载
|
const isLoadingMore = useRef(false); // 防止重复加载
|
||||||
|
const previousScrollHeight = useRef(0); // 记录加载前的滚动高度(用于位置保持)
|
||||||
|
|
||||||
// 滚动条颜色(主题适配)
|
// 滚动条颜色(主题适配)
|
||||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||||
@@ -62,30 +64,42 @@ const VirtualizedFourRowGrid = ({
|
|||||||
overscan: 2, // 预加载2行(上下各1行)
|
overscan: 2, // 预加载2行(上下各1行)
|
||||||
});
|
});
|
||||||
|
|
||||||
// 无限滚动逻辑 - 监听滚动事件,到达底部时加载下一页
|
// 双向无限滚动逻辑 - 监听滚动事件,到达底部加载下一页,到达顶部加载上一页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollElement = parentRef.current;
|
const scrollElement = parentRef.current;
|
||||||
if (!scrollElement || !loadNextPage) return;
|
if (!scrollElement) return;
|
||||||
|
|
||||||
const handleScroll = async () => {
|
const handleScroll = async () => {
|
||||||
// 防止重复触发
|
// 防止重复触发
|
||||||
if (isLoadingMore.current || !hasMore || loading) return;
|
if (isLoadingMore.current || loading) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
// 滚动到 60% 时开始加载下一页(降低阈值,更早触发)
|
// 向下滚动:滚动到 60% 时开始加载下一页
|
||||||
if (scrollPercentage > 0.6) {
|
if (loadNextPage && hasMore && scrollPercentage > 0.6) {
|
||||||
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
console.log('%c📜 [双向滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||||||
isLoadingMore.current = true;
|
isLoadingMore.current = true;
|
||||||
await loadNextPage();
|
await loadNextPage();
|
||||||
isLoadingMore.current = false;
|
isLoadingMore.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 向上滚动:滚动到顶部 10% 以内时加载上一页
|
||||||
|
if (loadPrevPage && scrollTop < clientHeight * 0.1) {
|
||||||
|
console.log('%c📜 [双向滚动] 到达顶部,加载上一页', 'color: #10B981; font-weight: bold;');
|
||||||
|
isLoadingMore.current = true;
|
||||||
|
|
||||||
|
// 记录加载前的滚动高度(用于位置保持)
|
||||||
|
previousScrollHeight.current = scrollHeight;
|
||||||
|
|
||||||
|
await loadPrevPage();
|
||||||
|
isLoadingMore.current = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollElement.addEventListener('scroll', handleScroll);
|
scrollElement.addEventListener('scroll', handleScroll);
|
||||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||||
}, [loadNextPage, hasMore, loading]);
|
}, [loadNextPage, loadPrevPage, hasMore, loading]);
|
||||||
|
|
||||||
// 主动检测内容高度 - 如果内容不足以填满容器,主动加载下一页
|
// 主动检测内容高度 - 如果内容不足以填满容器,主动加载下一页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,6 +130,36 @@ const VirtualizedFourRowGrid = ({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [events.length, hasMore, loading, loadNextPage]);
|
}, [events.length, hasMore, loading, loadNextPage]);
|
||||||
|
|
||||||
|
// 滚动位置保持 - 加载上一页后,调整 scrollTop 使用户看到的内容位置不变
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = parentRef.current;
|
||||||
|
if (!scrollElement || previousScrollHeight.current === 0) return;
|
||||||
|
|
||||||
|
// 延迟执行,确保虚拟滚动已重新渲染并测量了新高度
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const currentScrollHeight = scrollElement.scrollHeight;
|
||||||
|
const heightDifference = currentScrollHeight - previousScrollHeight.current;
|
||||||
|
|
||||||
|
// 如果高度增加了(说明上一页数据已加载),调整滚动位置
|
||||||
|
if (heightDifference > 0) {
|
||||||
|
console.log('%c📜 [位置保持] 调整滚动位置', 'color: #10B981; font-weight: bold;', {
|
||||||
|
previousHeight: previousScrollHeight.current,
|
||||||
|
currentHeight: currentScrollHeight,
|
||||||
|
heightDifference,
|
||||||
|
newScrollTop: scrollElement.scrollTop + heightDifference
|
||||||
|
});
|
||||||
|
|
||||||
|
// 调整 scrollTop,使用户看到的内容位置不变
|
||||||
|
scrollElement.scrollTop += heightDifference;
|
||||||
|
|
||||||
|
// 重置记录
|
||||||
|
previousScrollHeight.current = 0;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [events.length]); // 监听 events 变化,加载上一页后会增加 events 数量
|
||||||
|
|
||||||
// 底部加载指示器
|
// 底部加载指示器
|
||||||
const renderLoadingIndicator = () => {
|
const renderLoadingIndicator = () => {
|
||||||
if (!hasMore) {
|
if (!hasMore) {
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
|||||||
// 检查是否还有更多数据
|
// 检查是否还有更多数据
|
||||||
const hasMore = cachedCount < total;
|
const hasMore = cachedCount < total;
|
||||||
|
|
||||||
// 判断是否使用累积模式(四排模式)
|
// 判断是否使用累积模式(四排模式 + 纵向模式)
|
||||||
const isAccumulateMode = mode === DISPLAY_MODES.FOUR_ROW;
|
const isAccumulateMode = mode === DISPLAY_MODES.FOUR_ROW || mode === DISPLAY_MODES.VERTICAL;
|
||||||
|
|
||||||
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
||||||
const currentPageEvents = useMemo(() => {
|
const currentPageEvents = useMemo(() => {
|
||||||
@@ -282,6 +282,29 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
|||||||
}
|
}
|
||||||
}, [currentPage, totalPages, loadingPage, handlePageChange]);
|
}, [currentPage, totalPages, loadingPage, handlePageChange]);
|
||||||
|
|
||||||
|
// 加载上一页(用于双向无限滚动)
|
||||||
|
const loadPrevPage = useCallback(async () => {
|
||||||
|
if (currentPage <= 1 || loadingPage !== null) {
|
||||||
|
logger.debug('DynamicNewsCard', '无法加载上一页', {
|
||||||
|
currentPage,
|
||||||
|
loadingPage,
|
||||||
|
reason: currentPage <= 1 ? '已是第一页' : '正在加载中'
|
||||||
|
});
|
||||||
|
return Promise.resolve(false); // 已经是第一页或正在加载
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPage = currentPage - 1;
|
||||||
|
logger.debug('DynamicNewsCard', '懒加载:加载上一页', { currentPage, prevPage });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlePageChange(prevPage);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DynamicNewsCard', '懒加载上一页失败', error, { prevPage });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [currentPage, loadingPage, handlePageChange]);
|
||||||
|
|
||||||
// 模式切换处理
|
// 模式切换处理
|
||||||
const handleModeToggle = useCallback((newMode) => {
|
const handleModeToggle = useCallback((newMode) => {
|
||||||
if (newMode === mode) return;
|
if (newMode === mode) return;
|
||||||
@@ -337,6 +360,7 @@ export const usePagination = ({ allCachedEvents, total, cachedCount, dispatch, t
|
|||||||
// 方法
|
// 方法
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handleModeToggle,
|
handleModeToggle,
|
||||||
loadNextPage // 新增:加载下一页(用于无限滚动)
|
loadNextPage, // 新增:加载下一页(用于无限滚动)
|
||||||
|
loadPrevPage // 新增:加载上一页(用于双向无限滚动)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user