오늘은 ScrollView와 LazyVStack을 활용하여 SwiftUI에서 무한 스크롤을 구현해보려고 한다.
사실, LazyVStack이 조금 생소할 수 있다.
LazyVStack은 말 그대로 Lazy하게 VStack을 그린다는 느낌으로,
VStack으로 보여줄 항목이 실제로 UI에 보여질 때 렌더링을 진행하는 View이다.
Apple Developer Documentation
developer.apple.com
그렇다면 기존에 사용하던 VStack과는 어떤 차이가 있을까?
평소에 사용하던 VStack은 뷰가 보여질 때(onAppear) 모든 항목을 렌더링한다.
그렇기에 ScrollView + VStack 조합으로 List를 나타낸다면 초기에 많은 리소스를 소모하게 된다.
적은 개수의 간단한 항목들을 표시할 경우에는 상관이 없지만,
많은 개수의 항목들을 표시할 경우에는 적절하지 못한 방법일 것이다.
가령, 중고거래 플랫폼 앱이 있는데 앱을 켰을 때 특정 카테고리에 있는 모든 항목을 가져온다고 해보자.
엄청난 리소스를 소모하게 될 것이다. (사용자의 셀룰러 데이터나, CPU 혹은 RAM과 같은 자원이 될 수 있다.)
그래서 초기에는 10개, 혹은 20개와 같이 적은 개수의 데이터만 보여주고
마지막 항목이 보여지면 데이터를 추가하는 방식인 무한 스크롤 방식으로 구현하는 것이 좋다.
실제로 두 가지 방식으로 구현한 결과를 보면 그 차이를 명확하게 알 수 있다.
onAppear에 print를 달아주고 테스트를 진행했다.
평소에 사용하던 VStack은 View가 보여질 때 모든 항목이 렌더링 되는 것을 알 수 있다. (실제로는 보여지지 않더라도)
LazyVStack은 View가 보여질 때 모든 항목이 렌더링 되지 않고, 실제로 보여지는 것만 렌더링 되는 것을 알 수 있다.
또한 자주 쓰는 List 역시 Lazy하게 동작한다. (List를 애용하도록 하자)
그럼 이제 본론으로 들어가서, 실제 코드를 보면서 무한스크롤을 어떤 식으로 구현했는지 알아보자.
우선 파일 구조는 이렇게 잡았고, MVVM 패턴으로 구현했다.
1. Model
우선 Model의 경우, API를 호출했을 때 받아오는 Json의 구조를 그대로 구현해줬다.
import Foundation
struct Lecture: Codable {
let pagination: Pagination
let results: [Result]
}
struct Pagination: Codable {
let count: Int
let numPages: Int
let next: URL
enum CodingKeys: String, CodingKey {
case count
case numPages = "num_pages"
case next
}
}
struct Result: Codable, Identifiable {
let id: UUID = UUID() // List를 사용하기 위해 identifiable 추가
let name: String
let media: Media
let teachers: String
let shortDesc: String
let startDisplay: String
enum CodingKeys: String, CodingKey {
case name
case teachers
case media
case shortDesc = "short_description"
case startDisplay = "start_display"
}
}
struct Media: Codable {
let image: ImagePerSize
}
struct ImagePerSize: Codable {
let raw: URL
let small: URL
let large: URL
}
사용한 API에 대한 정보는 아래 링크에서 확인할 수 있다.
국가평생교육진흥원_K-MOOC_강좌정보API
한국형 온라인 공개강좌(K-MOOC)의 강좌 목록 API 및 강좌 세부 정보 API 제공
www.data.go.kr
2. ViewModel
ViewModel에서는 Lecture를 담고 있는 리스트를 @Published로 정의했다.
또한 앱을 처음 켰을 때 강좌를 가져오는 메소드와 스크롤을 끝까지 내렸을 때 추가적으로 강좌를 가져오는 메소드를 정의했다.
import Foundation
class LectureViewModel: ObservableObject {
@Published var items: [Result] = []
private var nextURL: String = ""
private var APIKEY: String = ""
init() {}
// 앱을 처음 켰을 때 강좌를 가져오기 위한 메소드
func getLecturesOnServer() async throws {
let responseData: Lecture = try await WebService.shared.loadJson("https://apis.data.go.kr/B552881/kmooc/courseList?serviceKey=\(APIKEY)&Page=1&Org=FUNMOOC&Mobile=1")
DispatchQueue.main.async {
self.items = responseData.results
self.nextURL = "\(responseData.pagination.next)"
}
}
// 스크롤을 끝까지 내렸을 때 추가적으로 강좌를 가져오기 위한 메소드
func getLecturesOnServerAtFinishedScroll() async throws {
let responseData: Lecture = try await WebService.shared.loadJson(nextURL)
// 기존 강좌 목록에 새로 가져온 강좌 목록을 추가
DispatchQueue.main.async {
self.items += responseData.results
self.nextURL = "\(responseData.pagination.next)"
}
}
}
프로퍼티 중에 nextURL의 경우, json에서 다음 강좌 목록에 해당하는 URL을 넘겨주는데 이를 활용했다.
3. WebService
WebService에서는 API를 호출할 때 사용하는 메소드를 정의해줬다.
import Foundation
class WebService {
static let shared = WebService()
// json을 T타입의 데이터로 디코딩하는 메소드
func loadJson<T: Decodable>(_ url: String) async throws -> T {
do {
let url = URL(string: url)!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(T.self, from: data)
} catch {
fatalError("Unable to parse data : \(error)")
}
}
}
타입에 상관없이 해당 클래스와 메소드를 사용할 수 있도록 제네릭을 적용했다.
4. View
ViewModel에서 @Published로 정의한 items를 활용했고,
앱을 실행했을 때는 items에 어떠한 데이터도 들어있지 않기에, 이 때는 ProgressBar가 보여지도록 했다.
또한 동시에 API를 호출하여 강좌 목록을 가져오게 하였고
강좌 목록을 가져오는데 성공하면 items에 데이터가 들어있게 되고, 이후에는 ProgressBar가 사라지도록 했다.
ViewModel, 아니 ObservableObject를 활용하면 이러한 형태의 앱을 정말 손쉽게 구현할 수 있다. (SwiftUI 짱짱!! 😀)
import SwiftUI
struct LectureListView: View {
@EnvironmentObject var lectureVM: LectureViewModel
@State private var num: Int = 0
var body: some View {
if lectureVM.items.isEmpty {
ProgressView()
// 앱을 처음 실행했을 때만 ProgressView를 보여줌
.task {
do {
try await lectureVM.getLecturesOnServer()
} catch (let error) {
print("Unable to get data : \(error)")
}
}
} else {
ScrollView {
LazyVStack {
ForEach(lectureVM.items) { item in
LectureItemView(lecture: item)
.onAppear {
guard let index = lectureVM.items.firstIndex(where: { $0.id == item.id }) else { return }
if index % 10 == 9 {
Task {
do {
try await lectureVM.getLecturesOnServerAtFinishedScroll()
} catch (let error) {
print("Unable to get data : \(error)")
}
}
}
}
}
}
}
.navigationTitle("K-MOOC 강좌 목록")
}
}
}
위에서 설명했던 것처럼 LazyVStack은 특정 아이템이 UI에 그려질 타이밍에 렌더링을 진행한다.
이를 활용해서 불러온 강좌 목록에서 마지막 강좌가 보여지면,
새롭게 강좌 목록을 불러오도록 구현했다.
강좌 목록을 보여주는 ScrollView에서 하나의 강좌를 보여주는 View인, LectureItemView는 이렇게 구현했다.
import SwiftUI
struct LectureItemView: View {
var lecture: Result
var body: some View {
HStack {
AsyncImage(url: lecture.media.image.small) { image in
image
.resizable()
} placeholder: {
ProgressView()
.frame(width: 140)
}
.scaledToFill()
.frame(width: 140)
.clipped()
.cornerRadius(8)
VStack(alignment: .leading) {
Text(lecture.name)
.font(.body)
.fontWeight(.bold)
Spacer()
Text(lecture.teachers)
.font(.footnote)
.lineLimit(1)
Spacer()
Text(lecture.startDisplay)
.font(.footnote)
}
.padding(4)
Spacer()
}
.padding(8)
}
}
실제 구현 결과를 보자!
마지막 강좌가 보여질 때 API를 호출하여 10개의 강좌를 더 가져오는 것을 볼 수 있다!
'💻 개발 > iOS' 카테고리의 다른 글
[iOS / SwiftUI] MapKit, 실시간으로 도로명 주소 변환하기 (2) | 2022.12.20 |
---|---|
[iOS / SwiftUI] OnAppear, OnDisappear는 언제 호출될까? (0) | 2022.12.01 |
[iOS / Swift] Swift 문자열 정복하기 (aka 'Character') (0) | 2022.11.03 |
[iOS / SwiftUI] 다양한 상태 프로퍼티들을 알아보자! (0) | 2022.10.24 |
[iOS / SwiftUI] 키보드가 사라지지 않아요...😩 (0) | 2022.10.21 |