본문 바로가기

Develop/SwiftUI

[SwiftUI] SwiftUI로 Pagination 구현하기

저번 UIKit으로 Pagination을 구현해보았는데 이번에는 SwiftUI로 Pagination을 구현해보려고 한다.

아래는 저번 UIKit으로 구현한 내용을 포스팅한 것이다.

 

[UIKit] UITableView + Pagination 구현

Pagination Pagination(페이지 매기기) 또는 Paging(페이징) 이란 문서를 전자 페이지 또는 인쇄된 페이지 등 개별 페이지로 나누는 프로세스이다.우리가 사용하는 웹페이지에서 쉽게 Pagination이 구현한

kimkhuna99.tistory.com

 

일단 나는 아래 블로그 내용을 가지고 진행하였다.

https://medium.com/@felix.anderson1504/mvvm-in-swiftui-api-calls-pull-to-refresh-and-pagination-8520e30ed312

 

 

API Response

API Request URL 은 https://api.jikan.moe/v4/top/anime?page=1 이 API를 참고하여 만들었다.

애니메이션 순위를 나타내는 API로 추정된다.

 

Pagination

API 에서 Pagination을 위한 데이터를 제공한다.

  • last_visiable_page : Pagination의 마지막 페이지
  • has_next_page : 다음 페이지가 있는지에 대한 여부
  • current_page : 현재 표시된 페이지

 

Model

JSON 형식의 데이터를 파싱하기 위한 Model을 생성하기 위해 아래와 같이 구현하였다.

 

첫 번째로는 API Response의 모델이다. Pagination에 대한 정보와 data로 나누어져 있다.

struct JikanMoeResponse: Decodable, Equatable {
    let pagination: Pagination
    let data: [Anime]
}

 

Pagination에 대한 구조체는 아래 코드를 참고

struct Pagination: Decodable, Equatable {
    let lastVisiblePage: Int
    let hasNextPage: Bool
    let currentPage: Int
    
    enum CodingKeys: String, CodingKey {
        case lastVisiblePage = "last_visible_page"
        case hasNextPage = "has_next_page"
        case currentPage = "current_page"
    }
}

 

데이터 내에는 애니메이션의 대한 정보와 포스터 이미지 URL 등으로 API에서 제공하고 있다.

struct Anime: Identifiable, Decodable, Equatable {
    var id: Int
    let url: String
    let image: AnimeImage
    let title: String
    let episodes: Int?
    let rank: Int?
    let score: Double?
    
    enum CodingKeys: String, CodingKey {
        case id = "mal_id"
        case url = "url"
        case image = "images"
        case title = "title"
        case episodes = "episodes"
        case rank = "rank"
        case score = "score"
    }
}

 

이미지 URL에 대한 구조체도 아래를 참고

struct AnimeImage: Decodable, Equatable {
    let jpg: ImageDetails
    let webp: ImageDetails
}


struct ImageDetails: Decodable, Equatable {
    let imageUrl: String
    let smallImageUrl: String
    let largeImageUrl: String
    
    enum CodingKeys: String, CodingKey {
        case imageUrl = "image_url"
        case smallImageUrl = "small_image_url"
        case largeImageUrl = "large_image_url"
    }
}

 

ViewModel

ViewModel 내에는 Pagination 로직과 API 통신 로직으로 구현하였다.

@MainActor
class AnimeKitMainViewModel: ObservableObject {
    @Published var animes: [Anime] = []
    
    var page: Int = 1
    var lastVisiblePage: Int = -1
    
    /// API 통신
    func loadAnimes() async {
        guard let url = URL(string: "https://api.jikan.moe/v4/top/anime?page=\(page)") else { return }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            if data.isEmpty { return }
            
            let decodedData = try JSONDecoder().decode(JikanMoeResponse.self, from: data)
            
            lastVisiblePage = decodedData.pagination.lastVisiblePage // 데이터의 마지막 페이지를 할당
            animes.append(contentsOf: decodedData.data)
            
            page += 1 // 함수가 다시 실행될 때 다음 페이지를 렌더링할 수 있도록 페이지를 증가
        } catch {
            print(error)
        }
    }
    
    /// 렌더링된 요소의 ID를 기준으로 사용자가 목록의 끝에 도달했는지 확인하는 함수
    func shouldLoadPagination(id: Int) async {
        // 배열의 마지막 id를 확인하고 page 특정 요소에 도달 여부 확인
        if animes.last?.id == id && lastVisiblePage >= page {
            await loadAnimes()
        }
    }
    
    /// 새로고침
    func refresh () async {
        page = 1
        lastVisiblePage = -1
        animes.removeAll()
        
        await loadAnimes()
    }
}

 

 

View

View 내에 LazyVGrid로 구현하여 요소들이 보이게 구현하였고 GridItem 내에서 ViewModel 내에 shouldLoadPagination 함수로 Pagination 확인한다.

struct AnimeKitMainView: View {
    @StateObject var viewModel = AnimeKitMainViewModel()
    
    let columns: [GridItem] = [GridItem(.flexible()), GridItem(.flexible())]
    
    var body: some View {
        NavigationView {
            ScrollView {
                LazyVGrid(columns: columns) {
                    ForEach(viewModel.animes) { anime in
                        AnimeCard(anime: anime)
                            .onAppear {
                                Task {
                                    await viewModel.shouldLoadPagination(id: anime.id)
                                }
                            }
                            .padding(.bottom)
                    }
                }
            }
            .navigationTitle("Top Animes")
            .padding()
        }
        .onAppear {
            Task {
                await viewModel.loadAnimes()
            }
        }
        .refreshable {
            Task {
                await viewModel.refresh()
            }
        }
    }
}

 

Anime를 표시하기 위한 UI `AnimeCard` 추가

struct AnimeCard: View {
    let anime: Anime
    
    var body: some View {
        VStack(alignment: .leading) {
            // Image
            AsyncImage(url: URL(string: anime.image.jpg.imageUrl)) { image in
                image.resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 230)
            } placeholder: {
                ProgressView()
                    .progressViewStyle(CircularProgressViewStyle())
                    .frame(width: 200, height: 200)
            }
            
            // Title
            Text(anime.title)
                .lineLimit(1)
                .padding(.horizontal)
            
            // Score
            Label {
                Text(String(format: "%2.f", anime.score ?? 0))
                    .fontWeight(.light)
            } icon: {
                Image(systemName: "star.fill")
                    .foregroundColor(.yellow)
            }
            .padding(.horizontal)
        }
    }
}

 

 

결과

 

 

Pagination은 제일 많이 쓰는 기술 중 하나라서 SwiftUI와 UIKit으로 구현해보았다. 확실히 SwiftUI에서 구현하는 방식이 쉽다고 느껴졌다. 다음에는 SwiftUI 내에서 AsyncImage에서 캐싱 처리에 대하여 공부해봐야겠다. 코드는 아래 깃헙을 참고

 

 

GitHub - kyeonghunkim0/Pagination_SwifUI

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

github.com

 

 

 

 

728x90