본문 바로가기

Develop/UIKit

[UIKit] UITableView + Pagination 구현

Pagination

 

Pagination(페이지 매기기) 또는 Paging(페이징) 이란 문서를 전자 페이지 또는 인쇄된 페이지 등 개별 페이지로 나누는 프로세스이다.

우리가 사용하는 웹페이지에서 쉽게 Pagination이 구현한 것을 볼 수 있다. 아래 더보기 같은 버튼이 예시 중 하나이다.

네이버 스포츠 야구 뉴스

 

이번에는 UIKit과 SwiftUI에서 사용하는 방식을 포스팅 하려 한다. 내가 이번에는 Github API를 사용하여 구현해볼 것이다.

아래 API 문서를 참고하자

https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#list-users

 

REST API endpoints for users - GitHub Docs

Status: 200 { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_

docs.github.com


 

avatar_url(프로필 사진), login(닉네임), html_url(Github 주소)를 표시하기 위한 간단하게 UI를 구현하였다.

 

 


 

 이미지를 가져오기 위한 이미지 캐싱 클래스 ImageLoader 를 추가했다. ImageLoader 내용은 아래 포스팅 참고

 

[UIKit] TableView에서 URL 이미지 캐싱 및 로딩 최적화 - ImageLoader + ImageCache 구현하기

UITableView나 UICollectionView에서 cell에 이미지를 넣을 때 url로 다운로드하여 이미지를 적용하면 잘못된 셀에 이미지가 추가되거나 앱이 백그라운드로 갔다오면 이미지를 전부 재로딩을 해야하는 문

kimkhuna99.tistory.com



일단 이미지는 나온다. 이제 페이징을 구현해보자. ViewModel 에 아래와 같이 구현했다.

import Foundation
import Combine
import UIKit


@MainActor
final class GithubViewModel: ObservableObject {
    
    @Published private(set) var users: [User] = []
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var isPaging: Bool = false
    
    @Published private(set) var error: Error?
    
    private let service: GithubService
    private var nextSince: Int? = nil                   // 마지막으로 받은 id
    private var hasMore = true
    private var currentTask: Task<Void, Never>?
    private let imageLoader = ImageLoader()
    
    init(service: GithubService = GithubService()) {
        self.service = service
    }
    
    func loadInitial(perPage: Int = 30) {
        guard !isLoading else { return }
        currentTask?.cancel()
        isLoading = true
        error = nil
        nextSince = nil
        
        currentTask = Task { [weak self] in
            guard let self = self else { return }
            do {
                let first = try await service.fetchUsers(since: nil, perPage: perPage)
                self.users = first
                self.nextSince = first.last?.id
                self.hasMore = !first.isEmpty
            } catch {
                self.error = error
            }
            
            self.isLoading = false
        }
    }
    
    func load(currentIndex: Int, perPage: Int = 30, prefetchThreshold: Int = 5) {
        guard hasMore, !isPaging, !isLoading else { return }
        guard currentIndex >= users.count - prefetchThreshold else { return }
        
        isPaging = true
        currentTask?.cancel()
        currentTask = Task { [weak self] in
            guard let self = self else { return }
            do {
                let more = try await service.fetchUsers(since: self.nextSince, perPage: perPage)
                // 중복 방지
                let existing = Set(self.users.map(\.id))
                let filtered = more.filter { !existing.contains($0.id) }
                self.users.append(contentsOf: filtered)
                self.nextSince = self.users.last?.id
                self.hasMore = !filtered.isEmpty
            } catch {
                self.error = error
            }
            isPaging = false
        }
    }
    
    func refresh(perPage: Int = 30) {
        loadInitial(perPage: perPage)
    }
    
    func cancel() {
        currentTask?.cancel()
    }
    // MARK: - Image
    func imagePublisher(for user: User) -> AnyPublisher<UIImage, Never> {
        guard let url = URL(string: user.avatar_url) else {
            let fallback = UIImage(systemName: "person") ?? UIImage()
            return Just(fallback).eraseToAnyPublisher()
        }
        
        return imageLoader.loadImage(from: url)
            .map { $0 ?? UIImage(systemName: "person") ?? UIImage() }
            .eraseToAnyPublisher()
    }
}

처음 loadInitial()로 로드 후에 Paging 하게 되면 load()를 사용한다.


extension GithubViewController: UITableViewDataSourcePrefetching {
    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        let maxIndex = indexPaths.map(\.row).max() ?? 0
        viewModel.load(currentIndex: maxIndex, perPage: 30)
    }
}

 

ViewController 에서는 UITableViewDataSourcePrefetching 을 사용하여 스크롤을 추적한다.

 

최종적으로 결과는 아래와 같이 스크롤 시 UI가 업데이트되는 것을 볼 수 있다.

 

자세한 코드는 아래 Github에 올려 놓았다.

 

GitHub - kyeonghunkim0/Pagination_UIKit

Contribute to kyeonghunkim0/Pagination_UIKit development by creating an account on GitHub.

github.com

 

728x90