👉🏻 가로 무한 스크롤 컴포넌트에 상세화면 추가한 내용입니다.
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

