feat: 모바일 일정 검색에 가상 스크롤 적용
- @tanstack/react-virtual useVirtualizer 적용 - 동적 높이 지원 (measureElement, data-index) - SEARCH_LIMIT 10 → 20으로 증가 - 검색 결과가 많아도 DOM에는 화면에 보이는 요소만 렌더링
This commit is contained in:
parent
b35dab5eea
commit
660acd0007
1 changed files with 68 additions and 31 deletions
|
|
@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
|
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules';
|
import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules';
|
||||||
|
|
||||||
// 모바일 일정 페이지
|
// 모바일 일정 페이지
|
||||||
|
|
@ -26,7 +27,9 @@ function MobileSchedule() {
|
||||||
setCalendarViewDate(newDate);
|
setCalendarViewDate(newDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEARCH_LIMIT = 10;
|
const SEARCH_LIMIT = 20; // 페이지당 20개
|
||||||
|
const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정)
|
||||||
|
const scrollContainerRef = useRef(null); // 가상 스크롤 컨테이너
|
||||||
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
|
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
|
||||||
|
|
||||||
// 검색 무한 스크롤
|
// 검색 무한 스크롤
|
||||||
|
|
@ -55,6 +58,14 @@ function MobileSchedule() {
|
||||||
return searchData.pages.flatMap(page => page.schedules);
|
return searchData.pages.flatMap(page => page.schedules);
|
||||||
}, [searchData]);
|
}, [searchData]);
|
||||||
|
|
||||||
|
// 가상 스크롤 설정 (검색 모드에서만 활성화, 동적 높이 지원)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: isSearchMode && searchTerm ? searchResults.length : 0,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
|
||||||
|
overscan: 5, // 버퍼 아이템 수
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
|
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|
@ -436,12 +447,11 @@ function MobileSchedule() {
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 컨텐츠 영역 */}
|
{/* 컨텐츠 영역 */}
|
||||||
<div className="mobile-content" ref={contentRef}>
|
<div className="mobile-content" ref={isSearchMode && searchTerm ? scrollContainerRef : contentRef}>
|
||||||
<div className="px-4 pt-4 pb-4">
|
<div className="px-4 pt-4 pb-4">
|
||||||
{isSearchMode && searchTerm ? (
|
{isSearchMode && searchTerm ? (
|
||||||
// 검색 결과
|
// 검색 결과 - 가상 스크롤
|
||||||
<div className="space-y-3">
|
searchLoading ? (
|
||||||
{searchLoading ? (
|
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -451,14 +461,42 @@ function MobileSchedule() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{searchResults.map((schedule, index) => (
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const schedule = searchResults[virtualItem.index];
|
||||||
|
if (!schedule) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={virtualItem.index < searchResults.length - 1 ? "pb-3" : ""}>
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
key={`${schedule.id}-search-${index}`}
|
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
categoryColor={getCategoryColor(schedule.category_id)}
|
categoryColor={getCategoryColor(schedule.category_id)}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* 무한 스크롤 트리거 */}
|
||||||
<div ref={loadMoreRef} className="py-4">
|
<div ref={loadMoreRef} className="py-4">
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
|
|
@ -467,8 +505,7 @@ function MobileSchedule() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)
|
||||||
</div>
|
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue