[Swift]랜덤유저API가로 무한스크롤/Random User API Horizontal Infinite Scroll Example

👉🏻 Randomuser API를 사용해서 가로 무한 스크롤을 구현한 컴포넌트 입니다.
This is a component that implements horizontal infinite scrolling using the Randomuser API.

👉🏻 코드설명은 주석을 참조하세요 그리고 부족한 설명은 아래의 포스트를 참조하시면 됩니다.
Please refer to the comments for code explanations, and for any missing explanations, please refer to the post below.

👉🏻 코드 / Code

✔️ContentView.swift


import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 30) {
                    
                    // 섹션 1: 인기 사용자
                    // Section 1: Popular Users
                    HorizontalUserScrollView(
                        title: "인기 사용자",
                        url:"https://randomuser.me/api/?results=20",
                        viewModel: RandomUserViewModel()
                    )
                    
                    // 섹션 2: 새로운 사용자
                    // Section 2: New Users
                    HorizontalUserScrollView(
                        title: "새로운 사용자",
                        url:"https://randomuser.me/api/?results=20",
                        viewModel: RandomUserViewModel()
                    )
                    
                    // 섹션 3: 추천 사용자
                    // Section 3: Recommended Users
                    HorizontalUserScrollView(
                        title: "오늘의 추천",
                        url:"https://randomuser.me/api/?results=20",
                        viewModel: RandomUserViewModel()
                    )

                }
                .padding(.vertical)
            }
            .navigationTitle("Horizontal Infinite Scroll")
            .refreshable {
                // 전체 새로고침 원하면 여기서 모든 ViewModel 리프레시
                // 현재는 각 섹션이 독립적이므로 pull-to-refresh는 생략하거나 커스텀 처리
                
                // If you want a full refresh, refresh all ViewModels here.
                // Currently, each section is independent, so either omit pull-to-refresh or handle it customally.
            }
        }
    }
}

#Preview {
    ContentView()
}

 

✔️ HorizaontalUserScrollView

import SwiftUI

// 가로 무한 스크롤 컴포넌트
// Horizontal infinite scroll component
struct HorizontalUserScrollView: View {
    let title: String
    let url: String
    @StateObject var viewModel: RandomUserViewModel
    
    var body: some View {
        VStack(alignment: .leading) {
            // 섹션 제목 / section title
            Text(title)
                .font(.title2)
                .fontWeight(.bold)
                .padding(.horizontal)
            
            ScrollView(.horizontal, showsIndicators: false) {
                LazyHStack(spacing: 20) {
                    ForEach(viewModel.users) { user in
                        VStack {
                            Text("\(user.name.first) \(user.name.last)")
                                .font(.headline)
                                .multilineTextAlignment(.center)
                            
                            AsyncImage(url: URL(string: user.picture.large)) { phase in
                                switch phase {
                                case .success(let image):
                                    image
                                        .resizable()
                                        .scaledToFit()
                                        .frame(width: 120, height: 120)
                                        .clipShape(Circle())
                                case .empty, .failure:
                                    ProgressView()
                                        .frame(width: 120, height: 120)
                                @unknown default:
                                    EmptyView()
                                }
                            }
                        }
                        .frame(width: 160)
                        .padding()
                        .background(Color(.secondarySystemBackground))
                        .cornerRadius(16)
                        .shadow(radius: 3)
                        // 마지막 사용자 카드가 나타날 때 추가 로드
                        // Load additional cards when the last user card appears
                        .onAppear {
                            if user == viewModel.users.last {
                                viewModel.fetchMoreUsers(url:url)  // 추가 로드 (append)
                                // viewModel.fetchMoreUsers(url: "https://randomuser.me/api/?results=20")  // 추가 로드 (append)
                            }
                        }
                    }
                    
                    // 로딩 인디케이터 / loading indicator
                    if viewModel.isLoadingMore {
                        ProgressView("불러오는 중…")
                            .frame(width: 160, height: 240)
                            .padding()
                    }
                }
                .padding(.horizontal)
            }
        }
        // 섹션이 화면에 나타날 때 초기 데이터 로드 (한 번만!)
        // Load initial data when the section appears on screen (only once!)
        .onAppear {
            if viewModel.users.isEmpty {
                Task {
                    // 전체 교체 (초기 로드 전용)
                    // Full replacement (initial load only)
                    await viewModel.loadInitialUsers(url:url)
                    // await viewModel.loadInitialUsers(url:"https://randomuser.me/api/?results=20")  // 전체 교체 (초기 로드 전용)
                }
            }
        }
    }
}

✔️ RandomUserModel

import Foundation

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

struct RandomUser: Codable, Identifiable, Equatable {

    let login: Login
    
    let name: Name
    let picture: Picture
    
    // Identifiable
    var id: String { login.uuid }
    
    // MARK: - 내부 구조체
    struct Login: Codable, Equatable {
        let uuid: String
        // 필요하면 username, password 등 추가 가능
        // You can add username, password, etc. if needed.
    }
    
    struct Name: Codable, Equatable {
        let title: String?   // "Mr", "Mrs" ...
        let first: String
        let last: String
    }
    
    struct Picture: Codable, Equatable {
        let large: String
        let medium: String?
        let thumbnail: String?
    }
}

✔️ RandomUserRowInfiniteApp

import SwiftUI

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

✔️ RandomUserViewModel


import Foundation
import Combine

@MainActor
class RandomUserViewModel: ObservableObject {
    @Published var users: [RandomUser] = []
    @Published var errorMessage: String?
    @Published var isLoadingMore = false
    
    private var debounceTask: Task<Void, Never>?
    
    // 처음 데이터 로드용 (뷰가 나타날 때 호출)
    // For initial data load (called when the view appears)
    func loadInitialUsers(url: String = "https://randomuser.me/api/?results=20") async {
        guard users.isEmpty else { return } // 이미 데이터 있으면 중복 로드 방지
        
        isLoadingMore = true
        await fetchUsers(from: url, append: false)
        isLoadingMore = false
    }
    
    // 양쪽 끝에서 추가 로드 (좌우 무한 스크롤용)
    // Additional loads from both ends (for infinite left and right scrolling)
    func fetchMoreUsers(url: String = "https://randomuser.me/api/?results=10") {
        debounceTask?.cancel()
        
        debounceTask = Task {
            try? await Task.sleep(nanoseconds: 300_000_000) // 0.3초 디바운스 /0.3 second debounce
            
            guard !isLoadingMore else { return }
            isLoadingMore = true
            
            await fetchUsers(from: url, append: true)
            
            isLoadingMore = false
        }
    }
    
    // 공통 네트워크 메서드 (이전과 동일) /
    // Common network methods (same as before)
    private func fetchUsers(from urlString: String, append: Bool) async {
        guard let url = URL(string: urlString) else {
            self.errorMessage = "잘못된 URL입니다."
            return
        }
        
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let decoded = try JSONDecoder().decode(RandomUserResponse.self, from: data)
            
            if append {
                self.users.append(contentsOf: decoded.results)
            } else {
                self.users = decoded.results
            }
        } catch {
            self.errorMessage = error.localizedDescription
        }
    }
}
   

👉🏻 스크린 샷 / ScreenShot

Leave a Reply