feat: 모바일 일정 검색에 가상 스크롤 적용

- @tanstack/react-virtual useVirtualizer 적용
- 동적 높이 지원 (measureElement, data-index)
- SEARCH_LIMIT 10 → 20으로 증가
- 검색 결과가 많아도 DOM에는 화면에 보이는 요소만 렌더링
This commit is contained in:
caadiq 2026-01-10 10:10:36 +09:00
parent b35dab5eea
commit 660acd0007

View file

@ -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,39 +447,65 @@ 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> ) : searchResults.length === 0 ? (
) : searchResults.length === 0 ? ( <div className="text-center py-8 text-gray-400">
<div className="text-center py-8 text-gray-400"> 검색 결과가 없습니다
검색 결과가 없습니다 </div>
</div> ) : (
) : ( <>
<> <div
{searchResults.map((schedule, index) => ( style={{
<ScheduleCard height: `${virtualizer.getTotalSize()}px`,
key={`${schedule.id}-search-${index}`} width: '100%',
schedule={schedule} position: 'relative',
categoryColor={getCategoryColor(schedule.category_id)} }}
categories={categories} >
/> {virtualizer.getVirtualItems().map((virtualItem) => {
))} const schedule = searchResults[virtualItem.index];
<div ref={loadMoreRef} className="py-4"> if (!schedule) return null;
{isFetchingNextPage && (
<div className="flex justify-center"> return (
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" /> <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
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categories={categories}
/>
</div>
</div> </div>
)} );
</div> })}
</> </div>
)} {/* 무한 스크롤 트리거 */}
</div> <div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</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" />