[Swift] RandomUser API Detail Screen

๐Ÿ‘‰๐Ÿป ๊ฐ€๋กœ ๋ฌดํ•œ ์Šคํฌ๋กค ์ปดํฌ๋„ŒํŠธ์— ์ƒ์„ธํ™”๋ฉด ์ถ”๊ฐ€ํ•œ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.
This is the content of adding a detail screen to the horizontal infinite scroll component.

๐Ÿ‘‰๐Ÿป ์ด์ „ ํฌ์ŠคํŠธ์—์„œ ์•„๋ž˜์˜ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜์‹œ๋ฉด๋ฉ๋‹ˆ๋‹ค.
You can modify the code below from the previous post.

๐Ÿ‘‰๐ŸปUserDetailView์ฝ”๋“œ๋Š” ์ƒˆ๋กœ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
Add new UserDetailView code.

๐Ÿ‘‰๐Ÿป์ฝ”๋“œ / Code

โœ”๏ธ UserDetailView

import SwiftUI

struct UserDetailView: View {
    let user: RandomUser  // RandomUser ๋ชจ๋ธ์ด๋ผ๊ณ  ๊ฐ€์ •
    
    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                // ํฐ ํ”„๋กœํ•„ ์‚ฌ์ง„ / profile picture
                AsyncImage(url: URL(string: user.picture.large)) { phase in
                    switch phase {
                    case .success(let image):
                        image
                            .resizable()
                            .scaledToFit()
                            .frame(width: 180, height: 180)
                            .clipShape(Circle())
                            .shadow(radius: 8)
                    case .empty, .failure:
                        ProgressView()
                            .frame(width: 180, height: 180)
                    @unknown default:
                        EmptyView()
                    }
                }
                
                // ์ด๋ฆ„,?? ->nil์ด๋ฉด ์˜ค๋ฅธ์ชฝ ๊ฐ’์„ ์‚ฌ์šฉ
                // name, ?? -> if nil, use the right value
                Text(String("\(user.name.title ?? "") \(user.name.first) \(user.name.last)"))
                    .font(.title)
                    .fontWeight(.bold)
                
                // ๊ธฐ๋ณธ ์ •๋ณด / basic information
                VStack(alignment: .leading, spacing: 12) {
                    DetailRow(icon: "person.fill", title: "์„ฑ๋ณ„", value: user.gender.capitalized)
                    DetailRow(icon: "calendar", title: "๋‚˜์ด", value: "\(user.dob.age)์„ธ")
                    DetailRow(icon: "envelope.fill", title: "์ด๋ฉ”์ผ", value: user.email)
                    DetailRow(icon: "phone.fill", title: "์ „ํ™”๋ฒˆํ˜ธ", value: user.phone)
                    DetailRow(icon: "mobilephone", title: "ํœด๋Œ€ํฐ", value: user.cell)
                }
                .padding(.horizontal)
                
                // ์ฃผ์†Œ / address
                VStack(alignment: .leading, spacing: 8) {
                    Text("์ฃผ์†Œ")
                        .font(.title3)
                        .fontWeight(.semibold)
                    
                        // street ์žˆ๋Š” ๊ฒฝ์šฐ๋งŒ ํ‘œ์‹œ (์˜ต์…”๋„ ์ฒ˜๋ฆฌ)
                        // Display only when there is a street (optional handling)
                        if let street = user.location?.street {
                            Text("\(street.number) \(street.name)")
                        } else {
                            Text("๊ฑฐ๋ฆฌ ์ •๋ณด ์—†์Œ")
                        }
                        
                        Text("\(user.location?.city ?? ""), \(user.location?.state ?? "")")
                            .foregroundStyle(.secondary)
                        
                        Text(user.location?.country ?? "๊ตญ๊ฐ€ ์ •๋ณด ์—†์Œ")
                            .foregroundStyle(.secondary)
                    
                    //Text("\(user.location.country) \(user.location.postcode)")
                }
                .padding()
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(Color(.systemGray6))
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .padding(.horizontal)
            }
            .padding(.vertical, 32)
        }
        .navigationTitle("\(user.name.first) \(user.name.last)")
        .navigationBarTitleDisplayMode(.inline)
    }
}

// basic information row
struct DetailRow: View {
    let icon: String
    let title: String
    let value: String
    
    var body: some View {
        HStack {
            Image(systemName: icon)
                .frame(width: 24)
                .foregroundStyle(.blue)
            
            Text(title)
                .foregroundStyle(.secondary)
            
            Spacer()
            
            Text(value)
        }
    }
}

โœ”๏ธRandomUserModel

struct RandomUser: Codable, Identifiable, Equatable, Hashable { // hashaable์ถ”๊ฐ€ / add hashable
 
    let login: Login
    let name: Name
    let picture: Picture
    let gender: String
    
    // ์ถ”๊ฐ€ / add
    let dob: Dob
    let email: String
    let phone: String
    let cell: String
    let location: Location?
    
    // Identifiable
    var id: String { login.uuid }
    
    // MARK: - ๋‚ด๋ถ€ ๊ตฌ์กฐ์ฒด
    struct Login: Codable, Equatable, Hashable {
        let uuid: String
        // ํ•„์š”ํ•˜๋ฉด username, password ๋“ฑ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ
        // You can add username, password, etc. if needed.
    }
    
    struct Name: Codable, Equatable, Hashable{
        let title: String?   // "Mr", "Mrs" ...
        let first: String
        let last: String
    }
    
    struct Picture: Codable, Equatable, Hashable{
        let large: String
        let medium: String?
        let thumbnail: String?
    }
    // dob ๊ตฌ์กฐ์ฒด ์ถ”๊ฐ€ (๊ฐ€์žฅ ํ”ํ•œ ํ˜•ํƒœ) / add Dob struct
    struct Dob: Codable, Equatable, Hashable {
        let date: String     // ์˜ˆ: "1990-05-14T..." (ISO 8601 ํ˜•์‹)
        let age: Int
    }
    // add location
    struct Location: Codable, Hashable {
            let street: Street
            let city: String
            let state: String
            let country: String
//            let postcode: StringOrInt // String ๋˜๋Š” Int ์˜ฌ ์ˆ˜ ์žˆ์Œ
//            let coordinates: Coordinates
//            let timezone: Timezone
            
            struct Street: Codable, Hashable {
                let number: Int
                let name: String
            }
            // ... ๋‚˜๋จธ์ง€
        }
}

โœ”๏ธHorizontalUserScrollView

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
                        NavigationLink(value: user) {
                        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
                        // NavigationLink ๋ฐ”๊นฅ
                        .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")  // ์ „์ฒด ๊ต์ฒด (์ดˆ๊ธฐ ๋กœ๋“œ ์ „์šฉ)
                }
            }
        }
        // ์Šคํƒ์ด๋™ UserDetailView
        .navigationDestination(for: RandomUser.self) { user in
                    UserDetailView(user: user)
        }
    }
}

๐Ÿ‘‰๐Ÿป์Šคํฌ๋ฆฐ ์ƒท / ScreenShot

Leave a Reply