관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 5. GraphQL - Apollo의 fetch 예제 (Pagination) 본문

iOS framework

[iOS - swift] 5. GraphQL - Apollo의 fetch 예제 (Pagination)

jake-kim 2022. 3. 4. 22:17

1. GraphQL - 개념

2. GraphQL - Apollo 사용 준비

3. GraphQL - Apollo 모델 생성 (generate model)

4. GraphQL - Apollo의 request pipeline, Interceptor, token, header

5. GraphQL - Apollo의 fetch 예제  (Pagination)

Apollo 준비

query LaunchList {
  launches {
    hasMore
    cursor
    launches {
      id
      site
      mission {
        name
        missionPatch(size: SMALL)
      }
    }
  }
}

  • Apollo 접근 singleton 정의
import Apollo

class Network {
  static let shared = Network()
  let apollo = ApolloClient(url: URL(string: "https://apollo-fullstack-tutorial.herokuapp.com/graphql")!)
  
  private init() {}
}

TableView에 얻은 데이터 뿌리기

pod 'Kingfisher'
pod 'SnapKit'
  • placeholder 이미지 존재

placeholder

  • tableView에 아래처럼 데이터를 뿌리도록 구현

  • Apollo는 graphQL을 다루고 있으므로, graphQL은 보통 전체 에러를 내려주기보단 부분 에러를 내려주기 때문에 .success의 response에서 error처리도 수행
  • .failure에 해당되는 case는 network error를 의미 
    • 단순히 데이터를 가져오는 것이기 때문에 fetch(query:) 호출
// ViewController.swift

private var dataSource = [LaunchListQuery.Data.Launch.Launch]()

// in viewDidLoad

    Network.shared.apollo
      .fetch(query: LaunchListQuery()) { [weak self] result in
        guard let ss = self else { return }
        defer { ss.tableView.reloadData() }
        
        switch result {
        case .success(let graphQLResult):
          if let launchConnection = graphQLResult.data?.launches {
            ss.dataSource.append(contentsOf: launchConnection.launches.compactMap { $0 })
          }
          if let errors = graphQLResult.errors {
            let message = errors
              .map { $0.localizedDescription }
              .joined(separator: "\n")
//            print(message)
          }
        case .failure(let error):
          print(error)
        }
      }
  • 나머지 UI를 포함한 전체 코드
// ViewController.swift

import UIKit
import SnapKit
import Kingfisher

final class ViewController: UIViewController {
  private let tableView: UITableView = {
    let view = UITableView()
    return view
  }()
  
  private var dataSource = [LaunchListQuery.Data.Launch.Launch]()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    self.title = "리스트"
    self.view.addSubview(self.tableView)
    self.tableView.register(LaunchListCell.self, forCellReuseIdentifier: "LaunchListCell")
    self.tableView.dataSource = self
    self.tableView.snp.makeConstraints {
      $0.edges.equalToSuperview()
    }
    
    Network.shared.apollo
      .fetch(query: LaunchListQuery()) { [weak self] result in
        guard let ss = self else { return }
        defer { ss.tableView.reloadData() }
        
        switch result {
        case .success(let graphQLResult):
          if let launchConnection = graphQLResult.data?.launches {
            ss.dataSource.append(contentsOf: launchConnection.launches.compactMap { $0 })
          }
          if let errors = graphQLResult.errors {
            let message = errors
              .map { $0.localizedDescription }
              .joined(separator: "\n")
//            print(message)
          }
        case .failure(let error):
          print(error)
        }
      }
  }
}

extension ViewController: UITableViewDataSource {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    self.dataSource.count
  }
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "LaunchListCell", for: indexPath) as! LaunchListCell
    let data = self.dataSource[indexPath.row]
    cell.prepare(
      imageUrlString: data.mission?.missionPatch,
      preferredSize: LaunchListCell.Constants.imageSize,
      title: data.mission?.name,
      desc: data.site
    )
    return cell
  }
}

final class LaunchListCell: UITableViewCell {
  enum Constants {
    static let imageSize = CGSize(width: 40, height: 40)
  }
  
  private let thumbnailImageView: UIImageView = {
    let view = UIImageView()
    view.contentMode = .scaleAspectFill
    return view
  }()
  private let titleLabel: UILabel = {
    let label = UILabel()
    label.font = .systemFont(ofSize: 14)
    label.textColor = .label
    return label
  }()
  private let descLabel: UILabel = {
    let label = UILabel()
    label.font = .systemFont(ofSize: 12)
    label.textColor = .secondaryLabel
    return label
  }()
  
  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    
    self.contentView.addSubview(self.thumbnailImageView)
    self.contentView.addSubview(self.titleLabel)
    self.contentView.addSubview(self.descLabel)
    
    self.thumbnailImageView.snp.makeConstraints {
      $0.top.left.equalToSuperview()
      $0.size.equalTo(Constants.imageSize)
      $0.bottom.lessThanOrEqualToSuperview()
    }
    self.titleLabel.snp.makeConstraints {
      $0.top.equalTo(self.thumbnailImageView)
      $0.left.equalTo(self.thumbnailImageView.snp.right)
      $0.right.lessThanOrEqualTo(12)
    }
    self.descLabel.snp.makeConstraints {
      $0.top.equalTo(self.titleLabel.snp.bottom)
      $0.left.equalTo(self.thumbnailImageView.snp.right)
      $0.bottom.lessThanOrEqualToSuperview()
      $0.right.lessThanOrEqualTo(12)
    }
  }
  required init?(coder: NSCoder) { fatalError() }
  
  override func prepareForReuse() {
    super.prepareForReuse()
    self.prepare(imageUrlString: nil, preferredSize: .zero, title: nil, desc: nil)
  }
  func prepare(imageUrlString: String?, preferredSize: CGSize, title: String?, desc: String?) {
    self.thumbnailImageView.image = nil
    if
      let imageUrlString = imageUrlString,
      let url = URL(string: imageUrlString)
    {
      self.thumbnailImageView.kf.setImage(
        with: url,
        placeholder: UIImage(named: "placeholder"),
        options: [
          .processor(DownsamplingImageProcessor(size: preferredSize)),
          .progressiveJPEG(ImageProgressive(isBlur: false, isFastestScan: true, scanInterval: 0.1))
        ],
        completionHandler: { result in
          print(result)
        }
      )
    }

    self.titleLabel.text = title
    self.descLabel.text = desc
  }
}

Pagination

밑으로 스크롤 시 자동으로 다음 데이터가 로드

  • cursor (페이지 값에 해당되는 값을 저장해놓기 위해 전역에 아래 프로퍼티 선언) 
// ViewController.swift

private var lastConnection: LaunchListQuery.Data.Launch?
  • cursor값을 가지고 데이터를 조회해야 하므로, graphql 쿼리 수정
    • $cursor 라는 파라미터 추가
query LaunchList($cursor:String) {
  launches(after:$cursor) {
    hasMore
    cursor
    launches {
      id
      site
      mission {
        name
        missionPatch(size: SMALL)
      }
    }
  }
}
  • cursor값이 nil일 경우, 첫번째 페이지를 받을 수 있으므로 lastConnection이 nil이면 첫번째 페이지를 호출하고, 아니면 다음 페이지를 호출 (cursor값을 전달)하도록 구현
  override func viewDidLoad() {
    super.viewDidLoad()
    
    ...
    
    self.loadMoreLaunchesIfTheyExist()
  }
  
  private func loadMoreLaunchesIfTheyExist() {
    guard let connection = self.lastConnection else {
      self.loadMoreLaunches(from: nil)
      return
    }
    guard connection.hasMore else { return }
      
    self.loadMoreLaunches(from: connection.cursor)
  }
  private func loadMoreLaunches(from cursor: String?) {
    Network.shared.apollo.fetch(query: LaunchListQuery(cursor: cursor)) { [weak self] result in
      guard let ss = self else { return }
      defer { ss.tableView.reloadData() }
      
      switch result {
      case .success(let graphQLResult):
        if let launchConnection = graphQLResult.data?.launches {
          ss.lastConnection = launchConnection
          ss.dataSource.append(contentsOf: launchConnection.launches.compactMap { $0 })
        }
      
        if let errors = graphQLResult.errors {
          let message = errors
                          .map { $0.localizedDescription }
                          .joined(separator: "\n")
          print(message)
      }
      case .failure(let error):
        print("network error - \(error)")
      }
    }
  }
  • pagination을 위해 tableView의 scorllViewDidScroll(_:)메소드를 사용하기 위해 델리게이트 할당 및 메소드 준수 
    • scroll에 관한 Pagination 개념은 이전 Pagination 개념 포스팅 글 참고

 

 

self.tableView.delegate = self

...

extension ViewController: UITableViewDelegate {
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let contentHeight = scrollView.contentSize.height
    let yOffset = scrollView.contentOffset.y
    let heightRemainBottomHeight = contentHeight - yOffset

    let frameHeight = scrollView.frame.size.height
    if heightRemainBottomHeight < frameHeight {
      self.loadMoreLaunchesIfTheyExist()
    }
  }
}


* 전체 코드: https://github.com/JK0369/ExApolloApp

 

* 참고

https://github.com/apollographql/apollo-ios/blob/main/docs/source/tutorial/tutorial-pagination.md

https://www.apollographql.com/docs/ios/tutorial/tutorial-query-ui/

Comments