컬렉션뷰에서 스크롤을 이용한 페이징(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)
}
}
'Devlog👩🏻💻 > iOS' 카테고리의 다른 글
[iOS] 스냅킷 updateLayoutConstraints 크래시 (equalToSuperview) (2) | 2024.06.19 |
---|---|
[Error/Xcode] 시뮬레이터 SearchBar & TextField 키보드 오류 (4) | 2024.05.07 |
[iOS/Xcode] CodeBase & SnapKit 연습하기, Storyboard 삭제 세팅 (2) | 2024.05.04 |
[iOS] Content Hugging Priority와 Content Compression Resistance Priority (4) | 2024.05.03 |
[iOS] 동기 vs 비동기, Serial vs Concurrent 이해하기 (8) | 2024.05.02 |