Devlog👩🏻‍💻/iOS

[iOS] CollectionView 페이징 (Pagination), LoadMore

yujjne 2024. 6. 29. 19:13

 

컬렉션뷰에서 스크롤을 이용한 페이징(Pagination)하는 방법을 정리해보자!

테이블뷰와 컬렉션뷰에는 스크롤이 내장되어 있어 UIScrollDelegate를 사용할 수 있다.

 

원하는 뷰컨트롤러에 페이지네이션을 구현하기 위해 

우선 스크롤 이벤트를 감지하는 메서드를 추가하여 사용자가 컬렉션뷰의 아래쪽에 도달했을 때 다음 페이지를 가져오도록 했다.

 

1. UICollectionViewDelegate - scrollViewDidScroll

// MARK: - UICollectionViewDelegate
extension ExerciseAlbumViewController: UICollectionViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetY = scrollView.contentOffset.y
        let contentHeight = scrollView.contentSize.height
        let height = scrollView.frame.size.height
        
        if offsetY > contentHeight - height - 100 {
            loadMoreData()
        }
    }
    
    private func loadMoreData() {
        viewModel.fetchNextPage()
    }
}

UICollectionViewDelegate에서 제공하는 scrollViewDidScroll 메서드를 사용하면 된다.

 

조건을 작성하기 위해 각 변수를 알아보면

  • offsetY: 스크롤 뷰의 수직 스크롤 위치, 즉 스크롤이 아래로 갈수록 이 값은 증가한다.
  • contentHeight: 스크롤 뷰의 전체 콘텐츠 높이
  • height: 스크롤 뷰의 높이

이 조건은 현재 스크롤 위치(offsetY) 가 컨텐츠의 전체 높이(contentHeight)에서 스크롤 뷰의 높이(height)를 뺀 값에서 100만큼 더 적을 때를 의미한다. 즉! 사용자가 컨텐츠의 거의 끝에 도달했을 때의 시점에서 추가 데이터를 불러오는 함수를 호출했다.

 

이 방식은 무한 스크롤을 구현할 때 일반적으로 사용된다. 

사용자가 스크롤을 계속 내리면, 새로운 데이터를 요청하고 로드하여 컨텐츠를 확장할 수 있다.

 

 

이전에 설정해뒀겠지만 컬렉션뷰의 델리게이트로 뷰컨트롤러를 설정하는 것도 잊지 말기! + 컬렉션뷰 데이터 바인딩까지도 해줘야 해요!

albumCollectionView.delegate = self

 

 

2. ViewModel - FetchData & NextPage

이제 뷰모델을 수정해보자!

 

뷰컨트롤러에서 사용할 데이터를 가져오는 메서드를 작성해야 한다.

필요한 프로퍼티들은 다음과 같다.

private var currentPage: Int = 0
private var isFetching = false
private var itemPerPage = 10

 

현재 페이지와 한 페이지에 불러오는 데이터의 개수를 정의했다.

또한 isFetching의 역할은 비동기 작업 중에 중복 요청을 방지하는 중요한 역할을 한다!⭐️⭐️

데이터를 가져오고 있는 동안은 true로 설정되고, 데이터 로드가 완료되면 false로 설정되어야 한다.

 

최종 페이지는 다음 페이지의 데이터가 itemPerPage의 값보다 작을 때 결정된다.

이때 isFetching 플래그는 통해 중복 요청을 막기 위해 사용된다.

데이터를 가져오는 코드를 작성해보자.

 

데이터 로드가 완료될 때마다 isFetching 을 업데이트 해주는 게 중요하다.

또한 가져온 데이터가 itemPerPage의 값보다 작을 경우, 더 이상 페이지 요청을 하지 않는 것에 집중해서 코드를 수정했다!

func getExercisePictures(page: Int) {
    guard !isFetching else { return }
    isFetching = true
    
    ExerciseAPIManager.shared.getExercisePicture(page: page) { [weak self] result in
        guard let self = self else { return }
        self.isFetching = false
        
        switch result {
        case .success(let data):
            print("이미지 데이터 fetch", data)
            DispatchQueue.main.async {
                if page == 0 {
                    self.exerciseAlbum = data
                } else {
                    self.exerciseAlbum?.pictures.append(contentsOf: data.pictures)
                }
                self.currentPage = page
                
                // 데이터가 10개 미만이면 더 이상 페이지네이션하지 않음
                if data.pictures.count < self.itemsPerPage {
                    self.isFetching = true // 다음 페이지 요청을 막음
                }
            }
        case .failure(let error):
            print("운동 이미지 fetch 실패: \(error)")
        }
    }
}

getExercisePictures의 로직을 살펴보자 🙌

  • 중복 요청 방지를 위해 함수가 호출되면 isFetching이 true로 설정된다.(중복해서 데이터 가져올 수 없게!)
  • 또한 데이터를 성공적으로 가져오면 isFetching이 false로 설정된다.
  • 조건문을 통해 첫 페이지라면 데이터를 초기화하고, 이후 페이지라면 데이터를 추가한다.
  • 최종 페이지 확인을 위해! 가져온 데이터의 개수가 itemPerPage보다 작다면 다시 isFetching를 true로 설정하여 더 이상 데이터를 요청하지 않도록 해야한다!(이걸 헷갈려서 무한 로드가 되었어요...🥲)

 

이제 다음 페이지를 가져오는 메서드도 간단히 작성할 수 있어요!

func fetchNextPage() {
    guard let exerciseAlbum = exerciseAlbum, !isFetching else { return }
    let nextPage = currentPage + 1
    
    getExercisePictures(page: nextPage)
}

중복 요청 방지를 위해 isFetching이 false인 경우에만 다음 페이지의 데이터를 요청해야한다.

 

 

예를 들어 12개의 데이터가 저장되었다고 가정하고 동작방식을 정리해보자.

1. 초기 페이지 로드:

  • 첫 페이지를 요청하면 10개의 데이터를 가져온다.
  • currentPage는 0으로 설정됩니다.
  • isFetching은 false로 설정됩니다.

2. 첫 페이지 이후 데이터 로드:

  • 사용자가 스크롤하면 fetchNextPage()가 호출
  • nextPage는 1로 설정 & getExercisePictures(page: 1)가 호출
  • -> 2개의 데이터 추가로 가져옴
  • self.exerciseAlbum?.pictures.append(contentsOf: data.pictures): 기존 데이터에 2개의 데이터가 추가

가져온 데이터의 개수가 2개이므로 data.pictures.count < self.itemsPerPage가 참이 되어 isFetching이 다시 true로 설정된다. 또한 더 이상 페이지네이션 요청을 하지 않는다!

 

비동기 작업이 완료되면 isFetching 플래그를 해제해주는 것 잊지 말기..~

 

 

ViewModel 전체 코드

import Foundation
import Combine

class ExerciseAlbumViewModel: ObservableObject {
    
    @Published var exerciseAlbum: ExerciseAlbum?
    
    private var currentPage: Int = 0
    private var isFetching = false
    private var itemPerPage = 10
    
    var exercisePictures: [ExerciseListModel] {
        return exerciseAlbum?.pictures ?? []
    }
    
    var cancellables = Set<AnyCancellable>()
    
    init() {
        // 초기 데이터 가져오기 (필요한 경우)
        getExercisePictures(page: currentPage)
    }
    
    func getExercisePictures(page: Int) {
        guard !isFetching else { return }
        isFetching = true
        
        ExerciseAPIManager.shared.getExercisePicture(page: page) { [weak self] result in
            guard let self = self else { return }
            self.isFetching = false
            
            switch result {
            case .success(let data):
                print("이미지 데이터 fetch", data)
                DispatchQueue.main.async {
                    if page == 0 {
                        self.exerciseAlbum = data
                    } else {
                        self.exerciseAlbum?.pictures.append(contentsOf: data.pictures)
                    }
                    self.currentPage = page
                    
                    // 데이터가 10개 미만이면 더 이상 페이지네이션하지 않음
                    if data.pictures.count < self.itemPerPage {
                        self.isFetching = true // 다음 페이지 요청을 막음
                    }
                }
            case .failure(let error):
                print("운동 이미지 fetch 실패: \(error)")
            }
        }
    }
    
    func fetchNextPage() {
        guard let exerciseAlbum = exerciseAlbum, !isFetching else { return }
        let nextPage = currentPage + 1
        getExercisePictures(page: nextPage)
    }
}