[swift]RandomUser Api – 무한스크롤,새로고침 로딩/Infinite Scroll,refresh loading

👉🏻 아래는 RandomUser API를 활용한 무한 스크롤을 구현한 내용입니다.
Below is an implementation of infinite scrolling using the RandomUser API.

👉🏻@MainActor 를 사용하는이유
Why use @MainActor

SwiftUI UI 업데이트는 반드시 메인 스레드에서 이뤄져야 합니다.
SwiftUI UI updates must always be done on the main thread.

 @Published로 선언된 값들이 변경되면 UI를 업데이트합니다.
Updates the UI when values ​​declared with @Published change.

 뷰의 업데이트가 백그라운드 스레드에서 변경되면 런타임 경고 + UI 버그 발생 가능합니다.
If the view’s update is made on a background thread, runtime warnings + UI bugs may occur.

 @MainActor를 클래스에 붙이면 이 클래스 안의 모든 프로퍼티 접근과 변경이 자동으로 메인 스레드에서 실행됩니다.
When you annotate a class with @MainActor, all property accesses and changes within that class will automatically be executed on the main thread.

👉🏻 다른 내용들은 이전 포스트내용과 코드 주석을 참조 하시면 될 것 같습니다.
For other details, please refer to the previous post and code comments.

👉🏻 코드 / Code

✔️ContentView

import SwiftUI

struct ContentView: View {
    
    @StateObject private var viewModel = RandomUserViewModel()
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 20) {
                
                ForEach(viewModel.users.indices, id: \.self) { index in
                    let user = viewModel.users[index]
                    
                    VStack {
                        // 유저네임
                        Text("\(user.name.first) \(user.name.last)")
                            .font(.headline)
                        
                        // 이미지
                        AsyncImage(url: URL(string: user.picture.large)) { image in
                            image.resizable()
                                .scaledToFit()
                                .frame(width: 140, height: 140)
                                .clipShape(Circle())
                        } placeholder: {
                            ProgressView()
                        }
                    }
                    .onAppear { // 무한 스크롤 감지
                        // 마지막 셀에서 무한스크롤 호출
                        if index == viewModel.users.count - 1 {
                            viewModel.fetchMoreUsers()
                        }
                    }
                }// ForEach
                
                // @Published
                if viewModel.isLoadingMore {
                    ProgressView("불러오는 중… / Loading...")
                        .padding()
                }
                
            }
            .padding()
        }
        .refreshable {
            // 새로고침 / refresh
            await viewModel.refreshUsers()
        }
        .onAppear {
            // 최초 데이터 로드 / First data load
            if viewModel.users.isEmpty {
                viewModel.fetchMoreUsers()
            }
        }
    }  

}

#Preview {
    ContentView()
}

✔️RandomUserInfiniteApp

import SwiftUI

@main
struct RandomUserInfiniteApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

✔️RandomUserModel

import Foundation

// Codable을 쓰면 JSON ↔ Struct 변환이 자동
// If you use Codable, JSON ↔ Struct conversion is automatic.

struct RandomUserResponse: Codable { 
    let results: [RandomUser]
}

struct RandomUser: Codable {
    let name: Name
    let picture: Picture
    
    struct Name: Codable {
        let first: String
        let last: String
    }
    
    struct Picture: Codable {
        let large: String
    }
}

✔️RandomUserViewModel

import Foundation
import Combine

@MainActor
class RandomUserViewModel: ObservableObject {
    @Published var users: [RandomUser] = []
    @Published var errorMessage: String?
    
    @Published var isLoadingMore = false       // 무한스크롤 로딩 / infinite scroll loading
    @Published var isRefreshing = false        // 새로고침 로딩 / refresh loading
    
    private var debounceTask: Task<Void, Never>?   // 무한스크롤 디바운스용 / For infinite scroll debounce
    
    // MARK: - 무한스크롤 / infinite scroll
    func fetchMoreUsers() {
        
        // 이미 새로고침 중이면 무한스크롤 금지 
        // Disable infinite scrolling if already refreshing

        guard !isRefreshing else { return }
        
        // 디바운싱: 기존 작업 취소
        // Debounce: Cancel existing work

        debounceTask?.cancel()
        
        debounceTask = Task {
            try? await Task.sleep(nanoseconds: 400_000_000) // 0.4초 디바운스 / 0.4 second debounce
            
            // 이미 로딩 중이라면(fetch 중이면) 함수 실행하지 말고 바로 종료해라.
            // If it is already loading (fetching), 
            //do not execute the function and exit immediately.

            guard !isLoadingMore else { return }
            isLoadingMore = true
            
            do {
                 // 절대 nil이 아닌경우
                 // if it is never nil

                let url = URL(string: "https://randomuser.me/api/?results=10")!  
                let (data, _) = try await URLSession.shared.data(from: url)
                let decoded = try JSONDecoder().decode(RandomUserResponse.self, from: data)
                
                self.users.append(contentsOf: decoded.results)
                self.isLoadingMore = false
                
            } catch {
                self.errorMessage = error.localizedDescription
                self.isLoadingMore = false
            }
        }
    }
    
    
    // MARK: - 새로고침 / refresh
    func refreshUsers() async {

        guard !isLoadingMore else { return }

        do {
            let url = URL(string: "https://randomuser.me/api/?results=20")!
            let (data, _) = try await URLSession.shared.data(from: url)
            let decoded = try JSONDecoder().decode(RandomUserResponse.self, from: data)

            self.users.removeAll()
            self.users = decoded.results
            self.isRefreshing = false 

        } catch {
            self.errorMessage = error.localizedDescription
            self.isRefreshing = false 
        }
    }

👉🏻 스크린 샷 / ScreenShot

Leave a Reply