Swift: Infinite Scroll & Pagination Tableview (Xcode 11, iOS) - 2020
final class InfiniteScrollViewModel {
let users: CurrentValueSubject<[UserModel], Never> = .init([])
let isPaging: CurrentValueSubject<Bool, Never> = .init(false)
private var currentLastId: Int? = nil
private let userService = GithubAPIService.shared
init() {
fetchUsers(perPage: 30)
}
public func fetchUsers(perPage: Int = 10) {
guard !isPaging.value else { return }
var userSubscription: AnyCancellable?
isPaging.send(true)
userSubscription = userService
.fetchUsers(perPage: perPage, sinceId: currentLastId)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: NetworkService.handleCompletion(completion:), receiveValue: { [weak self] receivedUsers in
guard let self = self else { return }
var currentUsers = self.users.value
currentUsers.append(contentsOf: receivedUsers)
self.users.send(currentUsers)
self.currentLastId = currentUsers.last?.id
self.isPaging.send(false)
userSubscription?.cancel()
})
}
}
isPaging
이라는 퍼블리셔를 통해 관리 → 단순한 불리언 변수가 아니라 퍼블리셔로 한 까닭은 해당 값 변화에 따라 뷰 컨트롤러에서 유의미한 UI 변경 이벤트가 발생하기 때문fetchUsers
함수는 곧 서버에 데이터를 요청하는 함수. 뷰 모델이 이니셜라이즈되는 순간에만 30개의 데이터를 요청하고, 페이지네이션, 즉 유저가 스크롤을 최하단 부로 내려 새로운 데이터를 서버에 요청할 때에는 기본적으로 10개의 데이터만을 요청one-shot
퍼블리셔를 통해 API 데이터 요청 함수를 사용guard
를 통해 현 시점에 페이지네이션, 즉 서버 데이터 요청이 진행 중이라면 중복된 데이터를 요청하지 못하도록 제어currentLastId
를 통해 다음에 불러 올 유저 아이디를 점진적으로 추가private func bind() {
viewModel
.users
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)
viewModel
.isPaging
.receive(on: DispatchQueue.main)
.sink { [weak self] isPaging in
self?.tableView.tableFooterView = isPaging ? self?.createSpinnerFooter() : nil
}
.store(in: &cancellables)
}
Diffable
로 변경하거나 추가된 데이터만큼만 reloadItems
등을 통해 로드할 수 있음isPaging
은 유저에 의한 페이지네이션이 발생할 때에만 값이 참이 되고, 데이터를 서버에서 패치해왔다면 다시 거짓이 되는 퍼블리셔nil
화) 알림func scrollViewDidScroll(_ scrollView: UIScrollView) {
let position = scrollView.contentOffset.y
let footerPadding: CGFloat = 100
if position > (tableView.contentSize.height - footerPadding - scrollView.frame.size.height) {
viewModel.fetchUsers()
}
}
contentOffset
을 통해 어느 방향으로 얼마큼 스크롤되었는지 확인 가능import Foundation
import Combine
class NetworkService {
enum NetworkingError: LocalizedError {
case badURLResponse(url: URL?)
case unknown
var errorDescription: String? {
switch self {
case .badURLResponse(url: let url): return "[🔥] Bad Response from URL: \(url?.absoluteString ?? "")"
case .unknown: return "[⚠️] Unknown error occured"
}
}
}
static func handleURLResponse(output: URLSession.DataTaskPublisher.Output, url: URL?) throws -> Data {
guard
let response = output.response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 300 else
{ throw NetworkingError.badURLResponse(url: url) }
return output.data
}
static func download(with url: URL) -> AnyPublisher<Data, Error> {
return URLSession
.shared
.dataTaskPublisher(for: url)
.tryMap({try handleURLResponse(output: $0, url: url)})
.retry(3)
.eraseToAnyPublisher()
}
static func download(with urlRequest: URLRequest) -> AnyPublisher<Data, Error> {
return URLSession
.shared
.dataTaskPublisher(for: urlRequest)
.tryMap({try handleURLResponse(output: $0, url: urlRequest.url)})
.retry(3)
.eraseToAnyPublisher()
}
static func handleCompletion(completion: Subscribers.Completion<Error>) {
switch completion {
case .failure(let error):
print(error.localizedDescription)
case .finished: break
}
}
}
static
을 통해 곧바로 사용 가능import Foundation
import Combine
final class GithubAPIService {
static let shared = GithubAPIService()
private let baseURLString = "https://api.github.com/users"
private let token = "token [YOUR_GITHUB_TOKEN]"
private init() {}
private func getURL(perPage: Int, sinceId: Int?) -> URLRequest? {
var urlComponents = URLComponents(string: baseURLString)
let urlQueryItems: [URLQueryItem] = [
.init(name: "per_page", value: "\(perPage)"),
.init(name: "since", value: sinceId?.description ?? "")
]
urlComponents?.queryItems = urlQueryItems
guard let url = urlComponents?.url else { return nil }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
urlRequest.setValue(token, forHTTPHeaderField: "Authorization")
return urlRequest
}
public func fetchUsers(perPage: Int = 30, sinceId: Int? = nil) -> AnyPublisher<[UserModel], Error> {
guard let url = getURL(perPage: perPage, sinceId: sinceId) else {
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
}
return NetworkService
.download(with: url)
.decode(type: [UserModel].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
import Foundation
struct UserModel: Codable, Identifiable {
let id: Int
let name: String
let avatarURL: String
enum CodingKeys: String, CodingKey {
case id
case name = "login"
case avatarURL = "avatar_url"
}
}
Codable
을 따르는 구조체import Combine
import Foundation
final class InfiniteScrollViewModel {
let users: CurrentValueSubject<[UserModel], Never> = .init([])
let isPaging: CurrentValueSubject<Bool, Never> = .init(false)
private var currentLastId: Int? = nil
private let userService = GithubAPIService.shared
init() {
fetchUsers(perPage: 30)
}
public func fetchUsers(perPage: Int = 10) {
guard !isPaging.value else { return }
var userSubscription: AnyCancellable?
isPaging.send(true)
userSubscription = userService
.fetchUsers(perPage: perPage, sinceId: currentLastId)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: NetworkService.handleCompletion(completion:), receiveValue: { [weak self] receivedUsers in
guard let self = self else { return }
var currentUsers = self.users.value
currentUsers.append(contentsOf: receivedUsers)
self.users.send(currentUsers)
self.currentLastId = currentUsers.last?.id
self.isPaging.send(false)
userSubscription?.cancel()
})
}
}
final class InfiniteScrollViewController: UIViewController {
private let viewModel = InfiniteScrollViewModel()
private var cancellables = Set<AnyCancellable>()
private lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.delegate = self
tableView.dataSource = self
tableView.register(InfiniteTableViewCell.self, forCellReuseIdentifier: InfiniteTableViewCell.identifier)
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
setUI()
bind()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
tableView.frame = view.bounds
}
private func setUI() {
view.backgroundColor = .systemBackground
title = "Infinite ScrollView"
navigationItem.largeTitleDisplayMode = .always
navigationController?.navigationBar.prefersLargeTitles = true
view.addSubview(tableView)
}
private func bind() {
viewModel
.users
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)
viewModel
.isPaging
.receive(on: DispatchQueue.main)
.sink { [weak self] isPaging in
self?.tableView.tableFooterView = isPaging ? self?.createSpinnerFooter() : nil
}
.store(in: &cancellables)
}
}
extension InfiniteScrollViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let position = scrollView.contentOffset.y
let footerPadding: CGFloat = 100
if position > (tableView.contentSize.height - footerPadding - scrollView.frame.size.height) {
viewModel.fetchUsers()
}
}
}
extension InfiniteScrollViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.users.value.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: InfiniteTableViewCell.identifier, for: indexPath) as? InfiniteTableViewCell else { fatalError() }
let model = viewModel.users.value[indexPath.row]
cell.configure(with: model)
return cell
}
private func createSpinnerFooter() -> UIView {
let footerPadding: CGFloat = 100
let footer = UIView(frame: .init(x: 0, y: 0, width: view.frame.size.width, height: footerPadding))
let spinner = UIActivityIndicatorView()
footer.addSubview(spinner)
spinner.center = footer.center
spinner.startAnimating()
return footer
}
}
Diffable
로 변경 가능final class InfiniteTableViewCell: UITableViewCell {
static let identifier = "InfiniteTableViewCell"
private let avatarImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true
imageView.image = UIImage(systemName: "person.circle")
return imageView
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.text = "Name"
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .label
label.textAlignment = .center
label.numberOfLines = 0
return label
}()
private let idLabel: UILabel = {
let label = UILabel()
label.text = "100"
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .systemGray
label.textAlignment = .center
return label
}()
private var imageSubscription: AnyCancellable?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUI()
}
override func layoutSubviews() {
super.layoutSubviews()
applyConstraints()
}
override func prepareForReuse() {
super.prepareForReuse()
nameLabel.text = nil
idLabel.text = nil
avatarImageView.image = nil
avatarImageView.image = UIImage(systemName: "person.circle")
imageSubscription?.cancel()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setUI() {
contentView.addSubview(avatarImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(idLabel)
}
private func applyConstraints() {
let nameLabelConstraints = [
nameLabel.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 10),
nameLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 10),
nameLabel.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor, constant: -10),
nameLabel.trailingAnchor.constraint(equalTo: idLabel.leadingAnchor, constant: -10)
]
NSLayoutConstraint.activate(nameLabelConstraints)
let avatarImageViewConstraints = [
avatarImageView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 10),
avatarImageView.heightAnchor.constraint(equalToConstant: 30),
avatarImageView.widthAnchor.constraint(equalToConstant: 30),
avatarImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
]
avatarImageView.layer.cornerRadius = avatarImageView.frame.size.width / 2
NSLayoutConstraint.activate(avatarImageViewConstraints)
let idLabelConstraints = [
idLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
idLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 10),
idLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 10),
idLabel.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor, constant: -10)
]
NSLayoutConstraint.activate(idLabelConstraints)
}
func configure(with model: UserModel) {
idLabel.text = model.id.description
nameLabel.text = model.name
guard let url = URL(string: model.avatarURL) else { return }
imageSubscription = NetworkService
.download(with: url)
.compactMap({UIImage(data: $0)})
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: NetworkService.handleCompletion(completion:), receiveValue: { [weak self] image in
self?.avatarImageView.image = image
self?.imageSubscription?.cancel()
})
}
}
configure
을 통해 받아들인 데이터로 셀 UI를 그리기contentView
에 맞춰 오토 레이아웃을 했기 때문에 해당 아이디 값이 늘어난다면 그에 맞춰 셀이 늘어나는 다이나믹 구조AnyCancellables
을 전역 변수로 가지고 있기 때문에 prepareForReuse
에서 불필요한 데이터 다운로드 태스크를 중도 취소 가능