관리 메뉴

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

[iOS - SwiftUI] 1. Pagination 방법 (SwiftUI에서 페이지네이션 구현 방법, Combine) 본문

iOS 응용 (SwiftUI)

[iOS - SwiftUI] 1. Pagination 방법 (SwiftUI에서 페이지네이션 구현 방법, Combine)

jake-kim 2022. 9. 28. 22:12

1. Pagination 방법 (페이지네이션, Combine) - 기초

2. Pagination 방법 (페이지네이션, Combine) - 메인 스레드 최적화, 이미지 캐싱

SwiftUI로 구현한 Pagination

Pagination 개념

  • List에서 스크롤할 때 필요한 데이터를 계속 추가적으로 받아오는 형태
    • 대용량의 데이터를 한꺼번에 받아오면 부하가 크므로, 10개를 보여주고 그다음 스크롤이 마지막에 도달할때 그 다음 10개를 보여주는 형식
    • API를 호출할땐 page Int 값을 다르게 보내면서 데이터를 가져오는 방법

SwiftUI 에서의 Pagination 구현 아이디어

  • List의 마지막 부분에 로딩뷰를 따로 추가하고, 그 부분이 onAppear 될때 페이지네이션 수행
List {
  ForEach(items) { item in
  }
  if !items.isEmpty {
    SomeLoadingView()
      .onAppear {
        // 페이지네이션 수행
      }
  }
}

Pagination 구현 시 잘못된 방법

  • Row값들이 onAppear 될때마다 해당 아이템을 체크하여 마지막 아이템일때 페이지네이션 하는 방법은 안좋은 방법
    • 만약 MVVM모델, ViewModel을 사용한다면, viewModel의 상태 값을 보내는 것인데, 이 상태는 @Published 형태일 것이고 @Published 형태를 set하면 Main thread를 사용하므로 사용할때 버벅이는 현상이 발생
  • MVVM 관련 개념은 이전 포스팅 글 참고

Pagination 코드 구현 - API, 모델

  • 사용할 API: Unsplash Document
  • 사이트에 접속 > 앱 등록 > Client key 복사해놓기
  • Photo 모델 정의
import Foundation
import SwiftUI

protocol ModelType: Codable, Equatable, Identifiable { }

struct Photo: ModelType {
  struct URLs: ModelType {
    let regular: String
    var id: String { self.regular }
  }
  
  let id: String
  let urls: URLs
  let width: CGFloat
  let height: CGFloat
}
  • API 정의
    • API는 비동기 작업이므로 Combine을 사용
      * Combine 개념 참고
    • Unsplash Document 에 접속 > 앱 등록 > Client key 복사후 아래 "your_client_key"에 붙여넣기
API.swift

import Foundation
import Combine

enum APIError: Error, LocalizedError {
  case unknown
  case some(reason: String)
  
  var errorDescription: String? {
    switch self {
    case .unknown:
      return "Unknown"
    case let .some(reason):
      return reason
    }
  }
}

enum API {
  private static let token = "your_client_key"
  private static let photoURL = "https://api.unsplash.com/photos"
  
  static func fetchPhotos(page: Int) -> AnyPublisher<Data, Error> {
    // TODO
  }
}
  • fetchPhotos 구현
    • 필요한 queryItem, header를 채우고 URLSession.DataTaskPublisher(request:session:)을 이용하여 Publisher 생성
  static func fetchPhotos(page: Int) -> AnyPublisher<Data, Error> {
    var urlComponents = URLComponents(string: photoURL)!
    urlComponents.queryItems = [
      .init(name: "page", value: "\(page)"),
      .init(name: "per_page", value: "\(10)"),
      .init(name: "order_by", value: "latest")
    ]
    var request = URLRequest(url: urlComponents.url!)
    
    request.allHTTPHeaderFields = ["Authorization": "Client-ID \(token)"]
    
    return URLSession.DataTaskPublisher(request: request, session: .shared)
    	.eraseToAnyPublisher()
  }
  • Publisher에 tryMap과 mapError를 통해 error처리 코드 추가
    return URLSession.DataTaskPublisher(request: request, session: .shared)
      .tryMap { data, response in
        guard
          let httpResponse = response as? HTTPURLResponse,
            200..<300 ~= httpResponse.statusCode
        else { throw APIError.some(reason: "Invalid httpResponse") }
        return data
      }
      .mapError { error in
        if let error = error as? APIError {
          return error
        } else {
          return APIError.some(reason: error.localizedDescription)
        }
      }
      .eraseToAnyPublisher()

Pagination 코드 구현 - 뷰

  • 필요한 @State 프로퍼티 4가지 준비
    • page: 페이지네이션에 사용될 상태값
    • isAppear: 뷰가 보여질때 최초 한번 초기 데이터들을 불러오기 위해서 사용할 플래그값
    • photos: 페이지네이션 하면서 계속 축적될 데이터 값
    • cancellables: Publisher들의 생명주기 관리할 프로퍼티
import SwiftUI
import Combine

struct ContentView: View {
  @State var page = 0
  @State var isAppear = false
  @State var photos = [Photo]()
  @State var cancellables = Set<AnyCancellable>()
  
  var body: some View {
  }
}
  • body 구조
    • getList(items:): 페이지네이션 되면 계속 업뎃될 뷰
    • loadMorePhotos(): API를 찌르고 데이터를 가져오는 메소드
  var body: some View {
    NavigationView {
      getList(items: $photos)
    }
    .onAppear {
      guard !isAppear else { return }
      isAppear = true
      loadMorePhotos()
    }
  }
  • 가장 간단한 loadMorePhotos() 부터 구현
    • page를 하나 높여주고, API호출을 통해 photos 값 업데이트
  private func loadMorePhotos() {
    page += 1
    self.cancellables = Set<AnyCancellable>()
    API.fetchPhotos(page: page)
      .sink(
        receiveCompletion: {
          switch $0 {
          case .finished:
            break
          case let .failure(error):
            print(error.localizedDescription)
          }
        }, receiveValue: { data in
          guard let photos = try? JSONDecoder().decode([Photo].self, from: data) else { return }
          self.photos += photos
        }
      )
      .store(in: &self.cancellables)
  }
  • getRowView(photo:) 구현
    • getList(item:)에서 불려질 뷰이며, 각 List마다 row에 사용될 뷰
    • AsyncImage를 통해 url에 해당되는 이미지를 가져오는 것
    • 핵심) frame값 정해주기: frame값을 정해주지 않으면 리스트를 스크롤할때 height값이 정해지지 않았으므로 버벅거리는 현상이 존재
  @ViewBuilder
  private func getRowView(photo: Photo) -> some View {
    AsyncImage(
      url: URL(string: photo.urls.regular),
      content: { image in
        image
          .resizable()
          .aspectRatio(contentMode: .fit)
          .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * photo.height / photo.width)
      },
      placeholder: { ProgressView() }
    )
  }
  • getList(items:) 구현
    • 핵심) List 맨 마지막에 ProgressView()를 추가하고 이 뷰의 onAppear {}가 호출되면 페이지네이션 수행 (다음 데이터 로드) 
    • List를 구현하는 형태는 좌우 패딩을 없애고, Disclosure Indicator를 없애기 위해 구현된 형태 (자세한 내용은 이전 포스팅 글 참고)
  @ViewBuilder
  private func getList(items: Binding<[Photo]>) -> some View {
    List {
      ForEach(items.wrappedValue, id: \.id) { photo in
        ZStack {
          NavigationLink(
            destination: {
              // EmptyView는 테스트를 위해 넣은것이고, 별도의 DetailView() 정의하여 이곳에 사용할것
              EmptyView()
            },
            label: {
            // 아래 HStack 하위에 getRowView(photo:)가 label 역할을 하므로, 여기는 EmptyView() 사용 필수
              EmptyView()
            }
          )
          .opacity(0)
          .buttonStyle(PlainButtonStyle())
          
          HStack {
            getRowView(photo: photo)
          }
        }
        .listRowInsets(.init())
        .padding(EdgeInsets(top: 0, leading: 0, bottom: 4, trailing: 0))
      }
      if !items.isEmpty {
        HStack {
          Spacer()
          ProgressView()
            .onAppear {
              print("여기")
              loadMorePhotos()
            }
          Spacer()
        }
      }
    }
    .listStyle(PlainListStyle())
    .navigationTitle("photo")
  }

페이지네이션 완성

보완점) - 다음 포스팅 글 참고 Pagination 방법 (페이지네이션, Combine) - 메인 스레드 최적화, 이미지 캐싱

  • 위처럼 구현되면 스크롤 할 때 버벅이는 현상이 존재
    • main thread에서 url을 가지고 이미지를 불러오는 코드가 있기 때문 (AsyncImage)
    • Background Thread에서 url을 가지고 이미지를 불러오도록 수정 필요
  • Image Caching 적용을 통해 조금 더 빠른 로딩이 되도록 구현
  • View쪽에서 url을 받아서 처리하지 않고, UIImage 형태를 받아서 바로 UI를 그릴 수 있도록 모델을 새로 정의

* 전체 코드: https://github.com/JK0369/ExPagination-SwiftUI

Comments