feat:移动端表头吸附效果

This commit is contained in:
hzm
2026-03-08 11:36:49 +08:00
parent 86e479c21a
commit 89d938a6c3
3 changed files with 210 additions and 110 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
flexRender, flexRender,
@@ -275,8 +276,10 @@ export default function MobileFundTable({
const [settingModalOpen, setSettingModalOpen] = useState(false); const [settingModalOpen, setSettingModalOpen] = useState(false);
const tableContainerRef = useRef(null); const tableContainerRef = useRef(null);
const portalHeaderRef = useRef(null);
const [tableContainerWidth, setTableContainerWidth] = useState(0); const [tableContainerWidth, setTableContainerWidth] = useState(0);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [showPortalHeader, setShowPortalHeader] = useState(false);
useEffect(() => { useEffect(() => {
const el = tableContainerRef.current; const el = tableContainerRef.current;
@@ -289,16 +292,63 @@ export default function MobileFundTable({
}, []); }, []);
useEffect(() => { useEffect(() => {
const el = tableContainerRef.current; if (typeof window === 'undefined') return;
if (!el) return;
const handleScroll = () => { const handleVerticalScroll = () => {
setIsScrolled(el.scrollLeft > 0); console.log('scrollY', window.scrollY);
setShowPortalHeader(window.scrollY >= 100);
}; };
handleScroll();
el.addEventListener('scroll', handleScroll, { passive: true }); handleVerticalScroll();
return () => el.removeEventListener('scroll', handleScroll); window.addEventListener('scroll', handleVerticalScroll, { passive: true });
return () => window.removeEventListener('scroll', handleVerticalScroll);
}, []); }, []);
useEffect(() => {
const tableEl = tableContainerRef.current;
if (!tableEl) return;
const handleScroll = () => {
setIsScrolled(tableEl.scrollLeft > 0);
};
handleScroll();
tableEl.addEventListener('scroll', handleScroll, { passive: true });
return () => {
tableEl.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const tableEl = tableContainerRef.current;
const portalEl = portalHeaderRef.current;
console.log('portalEl', portalEl)
if (!tableEl || !portalEl) return;
const syncScrollToPortal = () => {
console.log('tableEl.scrollLeft', tableEl.scrollLeft)
portalEl.scrollLeft = tableEl.scrollLeft;
};
const syncScrollToTable = () => {
tableEl.scrollLeft = portalEl.scrollLeft;
};
syncScrollToPortal();
const handleTableScroll = () => syncScrollToPortal();
const handlePortalScroll = () => syncScrollToTable();
tableEl.addEventListener('scroll', handleTableScroll, { passive: true });
// portalEl.addEventListener('scroll', handlePortalScroll, { passive: true });
return () => {
tableEl.removeEventListener('scroll', handleTableScroll);
// portalEl.removeEventListener('scroll', handlePortalScroll);
};
}, [showPortalHeader]);
const NAME_CELL_WIDTH = 140; const NAME_CELL_WIDTH = 140;
const GAP = 12; const GAP = 12;
const LAST_COLUMN_EXTRA = 12; const LAST_COLUMN_EXTRA = 12;
@@ -742,116 +792,149 @@ export default function MobileFundTable({
return 'text-right'; return 'text-right';
}; };
return ( const renderTableHeader = ()=>{
<div className="mobile-fund-table" ref={tableContainerRef}> if(!headerGroup) return null;
return (
<div <div
className="mobile-fund-table-scroll" className="table-header-row mobile-fund-table-header"
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined} style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
> >
{headerGroup && ( {headerGroup.headers.map((header, headerIndex) => {
const columnId = header.column.id;
const pinClass = getPinClass(columnId, true);
const alignClass = getAlignClass(columnId);
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
return (
<div
key={header.id}
className={`table-header-cell ${alignClass} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
);
})}
</div>
)
}
const renderContent = (onlyShowHeader) => {
if (onlyShowHeader) {
return (
<div style={{position: 'fixed', top: 175}} className="mobile-fund-table mobile-fund-table-portal-header" ref={portalHeaderRef}>
<div <div
className="table-header-row mobile-fund-table-header" className="mobile-fund-table-scroll"
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined} style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
> >
{headerGroup.headers.map((header, headerIndex) => { {renderTableHeader()}
const columnId = header.column.id; </div>
const pinClass = getPinClass(columnId, true); </div>
const alignClass = getAlignClass(columnId); );
const isLastColumn = headerIndex === headerGroup.headers.length - 1; }
return (
<div return (
key={header.id} <div className="mobile-fund-table" ref={tableContainerRef}>
className={`table-header-cell ${alignClass} ${pinClass}`} <div
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined} className="mobile-fund-table-scroll"
> style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
{header.isPlaceholder >
? null {renderTableHeader()}
: flexRender(header.column.columnDef.header, header.getContext())}
</div> {!onlyShowHeader && (
); <DndContext
})} sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={data.map((item) => item.code)}
strategy={verticalListSortingStrategy}
>
<AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row) => (
<SortableRow
key={row.original.code || row.id}
row={row}
isTableDragging={!!activeId}
disabled={sortBy !== 'default'}
>
{(setActivatorNodeRef, listeners) => (
<div
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
className="table-row"
style={{
background: 'var(--bg)',
position: 'relative',
zIndex: 1,
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
}}
{...(sortBy === 'default' ? listeners : {})}
>
{row.getVisibleCells().map((cell, cellIndex) => {
const columnId = cell.column.id;
const pinClass = getPinClass(columnId, false);
const alignClass = getAlignClass(columnId);
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
return (
<div
key={cell.id}
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
)}
</SortableRow>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
)}
</div>
{table.getRowModel().rows.length === 0 && !onlyShowHeader && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
</div>
</div> </div>
)} )}
<DndContext {!onlyShowHeader && (
sensors={sensors} <MobileSettingModal
collisionDetection={closestCenter} open={settingModalOpen}
onDragStart={handleDragStart} onClose={() => setSettingModalOpen(false)}
onDragEnd={handleDragEnd} columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
onDragCancel={handleDragCancel} columnVisibility={mobileColumnVisibility}
modifiers={[restrictToVerticalAxis, restrictToParentElement]} onColumnReorder={(newOrder) => {
> setMobileColumnOrder(newOrder);
<SortableContext }}
items={data.map((item) => item.code)} onToggleColumnVisibility={handleToggleMobileColumnVisibility}
strategy={verticalListSortingStrategy} onResetColumnOrder={handleResetMobileColumnOrder}
> onResetColumnVisibility={handleResetMobileColumnVisibility}
<AnimatePresence mode="popLayout"> showFullFundName={showFullFundName}
{table.getRowModel().rows.map((row) => ( onToggleShowFullFundName={handleToggleShowFullFundName}
<SortableRow />
key={row.original.code || row.id} )}
row={row}
isTableDragging={!!activeId} {!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
disabled={sortBy !== 'default'}
>
{(setActivatorNodeRef, listeners) => (
<div
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
className="table-row"
style={{
background: 'var(--bg)',
position: 'relative',
zIndex: 1,
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
}}
{...(sortBy === 'default' ? listeners : {})}
>
{row.getVisibleCells().map((cell, cellIndex) => {
const columnId = cell.column.id;
const pinClass = getPinClass(columnId, false);
const alignClass = getAlignClass(columnId);
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
return (
<div
key={cell.id}
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
)}
</SortableRow>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
</div> </div>
);
};
{table.getRowModel().rows.length === 0 && ( return (
<div className="table-row empty-row"> <>
<div className="table-cell" style={{ textAlign: 'center' }}> {renderContent()}
<span className="muted">暂无数据</span> </>
</div>
</div>
)}
<MobileSettingModal
open={settingModalOpen}
onClose={() => setSettingModalOpen(false)}
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
columnVisibility={mobileColumnVisibility}
onColumnReorder={(newOrder) => {
setMobileColumnOrder(newOrder);
}}
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
onResetColumnOrder={handleResetMobileColumnOrder}
onResetColumnVisibility={handleResetMobileColumnVisibility}
showFullFundName={showFullFundName}
onToggleShowFullFundName={handleToggleShowFullFundName}
/>
</div>
); );
} }

View File

@@ -1535,6 +1535,22 @@ input[type="number"] {
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */ /* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
} }
.mobile-fund-table-portal-header {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
left: 12px;
right: 12px;
box-sizing: border-box;
}
.mobile-fund-table-portal-header::-webkit-scrollbar {
display: none;
}
.mobile-fund-table-portal-header .mobile-fund-table-scroll {
padding: 0;
}
.mobile-fund-table .table-header-row { .mobile-fund-table .table-header-row {
display: grid; display: grid;
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */ /* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */

View File

@@ -427,6 +427,7 @@ export default function HomePage() {
// 动态计算 Navbar 和 FilterBar 高度 // 动态计算 Navbar 和 FilterBar 高度
const navbarRef = useRef(null); const navbarRef = useRef(null);
const filterBarRef = useRef(null); const filterBarRef = useRef(null);
const containerRef = useRef(null);
const [navbarHeight, setNavbarHeight] = useState(0); const [navbarHeight, setNavbarHeight] = useState(0);
const [filterBarHeight, setFilterBarHeight] = useState(0); const [filterBarHeight, setFilterBarHeight] = useState(0);
// 主题初始固定为 dark避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复 // 主题初始固定为 dark避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
@@ -3598,7 +3599,7 @@ export default function HomePage() {
}; };
return ( return (
<div className="container content" style={{ width: containerWidth }}> <div ref={containerRef} className="container content" style={{ width: containerWidth }}>
<AnimatePresence> <AnimatePresence>
{showThemeTransition && ( {showThemeTransition && (
<motion.div <motion.div