Pagination이란 한번에 보여주기에는 데이터가 너무 많을 때 데이터를 여러 페이지로 나누어 로드해서 보여주기 위해 사용합니다.
웹으로 구현하는 게시판이나 검색 결과의 경우 아래 구글의 예시처럼 하나의 페이지를 별도의 화면으로 보여주는 경우가 많습니다.
하지만 모바일 환경의 경우, 특히 리스트 혹은 Grid View로 보여주는 경우 무한 스크롤의 방식으로 구현을 합니다.
무한스크롤은 아래 캡쳐처럼 리스트의 아래 부분에 도달했을 때 다시 새로운 데이터를 로드해서 리스트에 추가하는 기능을 의미합니다. 오른쪽에 있는 스크롤바를 주목해서 보면 아래에 도달하면 계속 데이터가 추가되면서 다시 위로 튀어오르는 것을 볼 수 있습니다.
페이지네이션을 구현하기 위해서 필요한 객체들을 구현하겠습니다. 첫 번째는 데이터 model입니다. ForEach 문에 쓰기 위해서 Identifiable 프로토콜을 준수했습니다.
두 번째는 DataService입니다. 해당 클래스는 서버에서 데이터를 받아오는 클래스라고 가정하겠습니다. 내부에 100개의 데이터를 가지고 있고 fetchData라는 API를 제공합니다. 해당 API는 nextIndex와 dataPerPage의 두 Int를 받고 데이터의 배열과 지금 반환하는 페이지가 마지막 페이지인지 아닌지에 Bool 값을 반환합니다.
//✅ 데이터 Model 정의
struct Datum: Identifiable {
let id = UUID()
let text: String
init(_ int: Int) {
self.text = "\(int)"
}
}
//✅ 서버에서 data를 가져오는 클래스
class DataService {
static let shared = DataService()
// 0 ~ 99까지 숫자가 들어있는 DB
private let database: [Datum] = {
var database = [Datum]()
(0..<100).forEach { i in
database.append(Datum(i))
}
return database
}()
//✅ 다음 페이지의 시작점과 페이지의 크기를 받아서 데이터와 마지막 페이지 여부를 리턴해주는 함수
func fetchData(nextIndex: Int, dataPerPage: Int) -> (data: [Datum], isLastPage: Bool) {
let data = Array(database[nextIndex..<(nextIndex + dataPerPage)])
// 다음 index가 database의 마지막 index보다 크면 true
let isLastPage = nextIndex + dataPerPage > database.count - 1 ? true : false
return (data: data, isLastPage: isLastPage)
}
}
뷰모델에는 실제 뷰에서 활용할 데이터 외에 3가지 속성을 더 가지고 있습니다. (설명은 아래 코드 참고) 그리고 서버에서 데이터를 추가로 가져오는 함수를 선언합니다.
class PaginationViewModel: ObservableObject {
// VStack에 보여줄 데이터
@Published var data = [Datum]()
private let dataPerPage = 10 //👉 page 별 데이터
private var nextIndex = 0 //👉 다음 page의 첫 index
var isLastPage: Bool = false //👉 현재 page가 마지막 페이지인지 여부
// view model을 init하면 데이터를 fetch 해온다.
init() {
fetchData()
}
// 데이터베이스에서 데이터를 가져오는 함수
func fetchData() {
guard isLastPage == false else { return } //👉 현재 페이지가 마지막 페이지라면 API 호출 하지 않음.
// DB에서 새로운 데이터와 isLastPage 값을 가져온다.
let fetchedData = DataService.shared.fetchData(nextIndex: nextIndex, dataPerPage: dataPerPage)
// 배열에 새로 들어온 데이터 추가
data.append(contentsOf: fetchedData.data)
// 다음 index와 마지막 페이지인지 여부 업데이트
nextIndex += dataPerPage
isLastPage = fetchedData.isLastPage
}
}
첫번째 구현 방법입니다. 해당 방법은 VStack 내부에 ForEach를 통해서 Text 객체들을 구현합니다. 그리고 Text 객체들이 화면에 보일 때마다 onAppear 안에 클로저를 수행합니다.
onAppear에 전달하는 클로저에서는 일단 현재 Text에 전달되는 데이터의 index를 구합니다. 그리고 해당 index가 마지막이라면 추가적으로 데이터를 가져오는 함수를 실행합니다.
import SwiftUI
struct Pagination: View {
@ObservedObject var viewModel = PaginationViewModel()
var body: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.data) { datum in
Text(datum.text)
.frame(height: 100)
.font(.system(size: 30))
.onAppear {
// 현재 보여진 datum의 index 값을 구하기
guard let index = viewModel.data.firstIndex(where: { $0.id == datum.id }) else { return }
// 해당 index가 마지막 index라면 데이터 추가
if index == viewModel.data.count - 1 {
viewModel.fetchData()
}
}
}
}
}
}
}
이번에는 서버에서 데이터를 받아오는 시간동안 사용자에게 ProgressView를 보여줄 수 있도록 구현해봅시다.
먼저 ForEach문 아래에 ProgressView를 선언합니다. (단 isLastPage가 false일 때, 즉 아직 추가 페이지가 있을 때만 보여지도록 합니다.) 이렇게 하면 data 만큼의 Text가 다 보여지고 나면 ProgressView가 보여지면서 사용자는 추가 데이터가 로딩 중임을 알 수 있게 됩니다.
ProgressView가 보여지는 동안 뷰모델에서는 추가적인 데이터를 fetch 해올 수 있게되고 viewModel.data에 데이터가 추가되면 화면이 re-render되면서 VStack에 데이터가 추가되고 ProgressView는 다시 Text들 아래로 밀려나서 보이지 않게 됩니다.
(아래 코드에서는 실행할 때 ProgressView가 잘 보이도록 asyncAfter를 통해서 1초 후에 fetchData를 실행하도록 했습니다. 서버에서 데이터 불러올 때 1초 정도 걸리는 상황을 가정한 것입니다.)
struct Pagination: View {
@ObservedObject var viewModel = PaginationViewModel()
var body: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.data) { datum in
Text(datum.text)
.frame(height: 100)
.font(.system(size: 30))
}
if !viewModel.isLastPage { //👉 추가적인 데이터가 있을 때만 보이도록
ProgressView()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
viewModel.fetchData()
}
}
}
}
}
}
}
구현된 모습을 보면 데이터가 마지막까지 보이고 나면 ProgressView가 보이고 나서 1초 후에 View가 다시 로드되는 것을 볼 수 있습니다.
그리고 더 이상 데이터가 없다면 ProgressView도 더 이상 보이지 않습니다!