@@ -11,6 +11,7 @@ import SnapKit
1111import Combine
1212import Moya
1313import GoogleMobileAds
14+ import Kingfisher
1415
1516protocol ScrapStateDelegate : AnyObject {
1617 func didUpdateScrapState( publicCourseId: Int , isScrapped: Bool )
@@ -38,7 +39,10 @@ final class CourseDiscoveryVC: UIViewController {
3839 private var isEnd : Bool = false
3940 private var pageNo : Int = 1
4041 private var sort = " date "
41- private var isDataLoaded = false
42+ private var isFetchingData = false
43+
44+ /// 남은 아이템이 이 수 이하일 때 다음 페이지 프리페치 시작
45+ private let prefetchThreshold = 6
4246
4347 // MARK: - Native Ad Properties
4448
@@ -124,6 +128,7 @@ extension CourseDiscoveryVC {
124128 private func setDelegate( ) {
125129 mapCollectionView. delegate = self
126130 mapCollectionView. dataSource = self
131+ mapCollectionView. prefetchDataSource = self
127132 emptyView. delegate = self
128133 }
129134
@@ -211,8 +216,9 @@ extension CourseDiscoveryVC {
211216 }
212217
213218 func refresh( ) {
214- print ( " ✅ refresh ✅ " )
219+ print ( " refresh " )
215220 pageNo = 1
221+ isFetchingData = false
216222 self . courseList = [ ]
217223 self . getCourseData ( pageNo: pageNo)
218224 }
@@ -442,6 +448,31 @@ extension CourseDiscoveryVC: UICollectionViewDelegateFlowLayout {
442448 navigationController? . pushViewController ( courseDetailVC, animated: true )
443449 }
444450 }
451+
452+ func collectionView( _ collectionView: UICollectionView , willDisplay cell: UICollectionViewCell , forItemAt indexPath: IndexPath ) {
453+ guard indexPath. section == Section . courseList else { return }
454+
455+ let totalItems = totalItemCount ( )
456+ let remainingItems = totalItems - indexPath. item - 1
457+
458+ // 남은 아이템이 threshold 이하이면 다음 페이지 로드
459+ if remainingItems <= prefetchThreshold {
460+ loadNextPageIfNeeded ( )
461+ }
462+ }
463+
464+ /// 다음 페이지 로드 조건을 확인하고 로드
465+ private func loadNextPageIfNeeded( ) {
466+ // 이미 로딩 중이면 무시
467+ guard !isFetchingData else { return }
468+ // 마지막 페이지면 무시
469+ guard pageNo < totalPageNum else { return }
470+ // 현재 페이지의 데이터가 다 안 왔으면 무시
471+ guard courseList. count >= pageNo * serverResponseNumber else { return }
472+
473+ pageNo += 1
474+ getCourseData ( pageNo: pageNo)
475+ }
445476
446477 // 외부에서 Marathon Cell에서 받아오는 indexPath를 처리 합니다.
447478 private func setMarathonCourseSelection( at indexPath: IndexPath ) {
@@ -461,35 +492,9 @@ extension CourseDiscoveryVC: UICollectionViewDelegateFlowLayout {
461492
462493extension CourseDiscoveryVC : UIScrollViewDelegate {
463494 func scrollViewDidScroll( _ scrollView: UIScrollView ) {
464- performPagination ( )
465495 changeButtonStyleOnScroll ( )
466496 }
467497
468- private func performPagination( ) {
469- let contentOffsetY = mapCollectionView. contentOffset. y // 우리가 보는 화면
470- let collectionViewHeight = mapCollectionView. contentSize. height // 전체 사이즈
471- let paginationY = mapCollectionView. bounds. size. height // 유저 화면의 가장 아래 y축 이라고 생각
472-
473- /// 페이지네이션 중단 코드
474- if contentOffsetY > collectionViewHeight - paginationY {
475- if courseList. count < pageNo * serverResponseNumber {
476- // 페이지 끝에 도달하면 현재 페이지에 더 이상 데이터가 없음을 의미
477- // 새로온 데이터의 갯수가 원래 서버에서 응답에서 온 갯수보다 작으면 페이지네이션 금지
478- return
479- }
480-
481- if pageNo < totalPageNum {
482- if !isDataLoaded {
483- isDataLoaded = true
484- print ( " 🫠 \( pageNo) " )
485- self . pageNo += 1
486- getCourseData ( pageNo: pageNo)
487- isDataLoaded = false
488- }
489- }
490- }
491- }
492-
493498 private func changeButtonStyleOnScroll( ) {
494499 let contentOffsetY = mapCollectionView. contentOffset. y
495500 let scrollThreshold = mapCollectionView. bounds. size. height * 0.1 // 10% 스크롤 했으면 UI 변경
@@ -519,6 +524,36 @@ extension CourseDiscoveryVC: UIScrollViewDelegate {
519524 }
520525}
521526
527+ // MARK: - UICollectionViewDataSourcePrefetching
528+
529+ extension CourseDiscoveryVC : UICollectionViewDataSourcePrefetching {
530+ func collectionView( _ collectionView: UICollectionView , prefetchItemsAt indexPaths: [ IndexPath ] ) {
531+ let imageURLs = indexPaths. compactMap { indexPath -> URL ? in
532+ guard indexPath. section == Section . courseList else { return nil }
533+ guard !isAdPosition( at: indexPath. item) else { return nil }
534+ let realIndex = courseIndex ( for: indexPath. item)
535+ guard realIndex < courseList. count else { return nil }
536+ return URL ( string: courseList [ realIndex] . image)
537+ }
538+
539+ guard !imageURLs. isEmpty else { return }
540+ ImagePrefetcher ( urls: imageURLs) . start ( )
541+ }
542+
543+ func collectionView( _ collectionView: UICollectionView , cancelPrefetchingForItemsAt indexPaths: [ IndexPath ] ) {
544+ let imageURLs = indexPaths. compactMap { indexPath -> URL ? in
545+ guard indexPath. section == Section . courseList else { return nil }
546+ guard !isAdPosition( at: indexPath. item) else { return nil }
547+ let realIndex = courseIndex ( for: indexPath. item)
548+ guard realIndex < courseList. count else { return nil }
549+ return URL ( string: courseList [ realIndex] . image)
550+ }
551+
552+ guard !imageURLs. isEmpty else { return }
553+ ImagePrefetcher ( urls: imageURLs) . stop ( )
554+ }
555+ }
556+
522557// MARK: - CourseListCVCDelegate
523558
524559extension CourseDiscoveryVC : CourseListCVCDelegate {
@@ -607,41 +642,86 @@ extension CourseDiscoveryVC: GADAdLoaderDelegate, GADNativeAdLoaderDelegate {
607642
608643extension CourseDiscoveryVC {
609644 private func getCourseData( pageNo: Int ) {
610- LoadingIndicator . showLoading ( ) // 항상 0.7초 늦게 로딩이 되어 버림 0.5초를 넣은 이유는 pagination을 구현할때 한번에 다 받아오지 않게 하기 위함
611- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.7 ) { [ self ] in
612- publicCourseProvider. request ( . getCourseData( pageNo: pageNo, sort: sort) ) { response in
645+ isFetchingData = true
646+
647+ // 첫 페이지 로드 시에만 로딩 인디케이터 표시 (페이지네이션은 백그라운드 로딩)
648+ let isFirstPage = ( pageNo == 1 )
649+ if isFirstPage {
650+ LoadingIndicator . showLoading ( )
651+ }
652+
653+ publicCourseProvider. request ( . getCourseData( pageNo: pageNo, sort: sort) ) { [ weak self] response in
654+ guard let self = self else { return }
655+
656+ if isFirstPage {
613657 LoadingIndicator . hideLoading ( )
614- print ( " ‼️ sort= \( self . sort) ‼️ \n " )
615- switch response {
616- case . success( let result) :
617- let status = result. statusCode
618- if 200 ..< 300 ~= status {
619- do {
620- let responseDto = try result. map ( BaseResponse< PickedMapListResponseDto> . self )
621- guard let data = responseDto. data else { return }
622-
623- guard let totalPageNum = data. totalPageSize, let isEnd = data. isEnd else { return }
624- self . totalPageNum = totalPageNum
625- self . isEnd = isEnd
626-
627- self . courseList. append ( contentsOf: data. publicCourses)
658+ }
659+ self . isFetchingData = false
660+
661+ switch response {
662+ case . success( let result) :
663+ let status = result. statusCode
664+ if 200 ..< 300 ~= status {
665+ do {
666+ let responseDto = try result. map ( BaseResponse< PickedMapListResponseDto> . self )
667+ guard let data = responseDto. data else { return }
668+
669+ guard let totalPageNum = data. totalPageSize, let isEnd = data. isEnd else { return }
670+ self . totalPageNum = totalPageNum
671+ self . isEnd = isEnd
672+
673+ let newCourses = data. publicCourses
674+
675+ if isFirstPage {
676+ // 첫 페이지(초기 로드 또는 정렬 변경): reloadData 사용
677+ self . courseList = newCourses
628678 self . mapCollectionView. reloadData ( )
629- print ( " pageNo= \( pageNo) , isEnd= \( self . isEnd) , totalPageNum= \( self . totalPageNum) " )
630- } catch {
631- print ( error. localizedDescription)
679+ self . emptyView. isHidden = !newCourses. isEmpty
680+ } else {
681+ // 페이지네이션: performBatchUpdates + insertItems 사용
682+ self . insertNewCourses ( newCourses)
632683 }
684+
685+ print ( " pageNo= \( pageNo) , isEnd= \( self . isEnd) , totalPageNum= \( self . totalPageNum) " )
686+ } catch {
687+ print ( error. localizedDescription)
633688 }
634- if status >= 400 {
635- print ( " 400 error " )
636- self . showNetworkFailureToast ( )
637- }
638- case . failure( let error) :
639- print ( error. localizedDescription)
689+ }
690+ if status >= 400 {
691+ print ( " 400 error " )
640692 self . showNetworkFailureToast ( )
641693 }
694+ case . failure( let error) :
695+ print ( error. localizedDescription)
696+ self . showNetworkFailureToast ( )
642697 }
643698 }
644699 }
700+
701+ /// 새 코스를 기존 리스트에 추가하면서 광고 셀도 함께 삽입하는 incremental update
702+ private func insertNewCourses( _ newCourses: [ PublicCourse ] ) {
703+ guard !newCourses. isEmpty else { return }
704+
705+ // 삽입 전 상태 (광고 포함 전체 아이템 수)
706+ let oldTotalItemCount = totalItemCount ( )
707+
708+ // courseList에 새 데이터 추가
709+ courseList. append ( contentsOf: newCourses)
710+
711+ // 삽입 후 상태 (광고 포함 전체 아이템 수)
712+ let newTotalItemCount = totalItemCount ( )
713+
714+ // 새로 삽입할 IndexPath 계산 (코스 셀 + 광고 셀 모두 포함)
715+ let insertedIndexPaths = ( oldTotalItemCount..< newTotalItemCount) . map {
716+ IndexPath ( item: $0, section: Section . courseList)
717+ }
718+
719+ guard !insertedIndexPaths. isEmpty else { return }
720+
721+ mapCollectionView. performBatchUpdates ( {
722+ self . mapCollectionView. insertItems ( at: insertedIndexPaths)
723+ } , completion: nil )
724+ }
645725
646726 private func scrapCourse( publicCourseId: Int , scrapTF: Bool ) {
647727 LoadingIndicator . showLoading ( )
@@ -676,9 +756,8 @@ extension CourseDiscoveryVC: ListEmptyViewDelegate {
676756
677757extension CourseDiscoveryVC : TitleCollectionViewCellDelegate {
678758 func didTapSortButton( ordering: String ) {
679- // 기존의 getCourseData 함수 호출을 getSortedCourseData로 변경
680759 pageNo = 1
681- print ( " ‼️ \( ordering ) ‼️ 터치 하셨습니다. 0.7초 후에 ‼️ \( ordering ) ‼️ 으로 정렬이 되는 데이터가 불러 옵니다. " )
760+ isFetchingData = false
682761 sort = ordering
683762 self . courseList. removeAll ( )
684763 getCourseData ( pageNo: pageNo)
@@ -693,3 +772,4 @@ extension CourseDiscoveryVC: TitleCollectionViewCellDelegate {
693772 }
694773 }
695774}
775+
0 commit comments