[UIKit] Infinite ScrollView

Junyoung Park·2022년 12월 25일
1

UIKit

목록 보기
133/142
post-thumbnail

How to Create Infinite Scroll in UITableView )

Swift: Infinite Scroll & Pagination Tableview (Xcode 11, iOS) - 2020

Infinite ScrollView

구현 목표

  • 기존 스크롤 뷰에 등록된 데이터 이상을 스크롤할 경우 서버 데이터 요청 및 테이블 뷰 UI 리로드

구현 태스크

  • 테이블 뷰 UI 구현
  • 깃허브 유저 데이터 API 구현
  • 서버 데이터 요청을 판별하는 로직 구현
  • 서버 데이터 시 스피너 뷰 추가 및 데이터 패치 완료 시 스피너 삭제 로직 구현

핵심 코드

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를 통해 다음에 불러 올 유저 아이디를 점진적으로 추가
  • API는 싱글턴 패턴으로 구현
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)
    }
  • 뷰 컨트롤러가 뷰 모델의 퍼블리셔를 구독하는 함수
  • 뷰 모델의 데이터가 새롭게 변경될 때마다 테이블 뷰를 리로드함으로써 갱신 자동화 → UI를 그리는 리소스 이슈가 있으므로 데이터 소스를 일반에서 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()
    }
}
  • 토큰을 통해 인증하지 않는다면 API 이용 가용량이 적기 때문에 헤더에 토큰 값을 실어 인증
  • 싱글턴 패턴으로 구현

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()
            })
    }
}
  • 사실 진정한 의미에서의 MVVM 스타일대로라면 (적어도 내가 알고 있는 선상에서는) 인풋/아웃풋을 나누는 게 보다 효율적이겠지만, 구현 속도의 문제상 그렇게 하지는 않았다.
  • 인풋을 보자면 유저의 페이지네이션 유무 / 아웃풋을 보자면 페이지네이션 진행 중의 유무 등으로 구분할 수 있을 것이다.
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에서 불필요한 데이터 다운로드 태스크를 중도 취소 가능

구현 화면

profile
JUST DO IT

0개의 댓글