feat(character-detail, gallery): 캐릭터 상세 갤러리 - 이미지 리스트 API 연동

CharacterDetailView에 갤러리 탭을 추가하고, 갤러리 화면/상태 관리/네트워킹을
구현했습니다. 소유/미소유 UI, 페이지네이션, 이미지 뷰어, 오류 토스트를 포함합니다.

TODO: 이미지 구매 API 연동
This commit is contained in:
Yu Sung
2025-09-02 03:47:41 +09:00
parent 392184fd34
commit 7c031daebf
7 changed files with 346 additions and 36 deletions

View File

@@ -109,7 +109,7 @@ struct CharacterDetailView: View {
}
} else {
//
CharacterDetailGalleryView()
CharacterDetailGalleryView(characterId: characterId)
}
//

View File

@@ -12,4 +12,18 @@ import Moya
final class CharacterDetailGalleryRepository {
private let characterApi = MoyaProvider<CharacterApi>()
func getCharacterImageList(
characterId: Int,
page: Int,
size: Int = 20
) -> AnyPublisher<Response, MoyaError> {
return characterApi.requestPublisher(
.getCharacterImageList(
characterId: characterId,
page: page,
size: size
)
)
}
}

View File

@@ -10,48 +10,81 @@ import SwiftUI
struct CharacterDetailGalleryView: View {
@StateObject var viewModel = CharacterDetailGalleryViewModel()
private let columns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 3)
//
private let ownedCount: Int = 104
private let totalCount: Int = 259
private let columns = Array(repeating: GridItem(.flexible()), count: 3)
let characterId: Int
//
private var ownershipPercentage: Int {
guard totalCount > 0 else { return 0 }
return Int(round(Double(ownedCount) / Double(totalCount) * 100))
}
private var progressBarWidth: CGFloat {
let maxWidth: CGFloat = 352 //
guard totalCount > 0 else { return 0 }
let percentage = Double(ownedCount) / Double(totalCount)
let maxWidth: CGFloat = screenSize().width - 48
guard viewModel.totalCount > 0 else { return 0 }
let percentage = Double(viewModel.ownedCount) / Double(viewModel.totalCount)
return maxWidth * percentage
}
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
// 24px
Spacer()
.frame(height: 24)
VStack(spacing: 24) {
//
collectionInfoView()
.padding(.horizontal, 24)
.padding(.bottom, 8)
//
LazyVGrid(columns: columns, spacing: 2) {
ForEach(0..<4, id: \.self) { index in
galleryImageView(index: index)
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(Array(viewModel.galleryItems.enumerated()), id: \.element.id) { index, item in
galleryImageView(item: item, index: index)
.onAppear {
viewModel.loadMoreIfNeeded(currentItem: item)
}
}
}
.padding(.horizontal, 0)
}
.padding(.horizontal, 0)
Spacer()
}
.background(Color(hex: "#131313"))
.padding(.top, 24)
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(alignment: .center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 33.3)
.font(.custom(Font.medium.rawValue, size: 12))
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
}
.onAppear {
viewModel.characterId = characterId
viewModel.loadInitialData()
}
.sheet(isPresented: $viewModel.isShowImageViewer) {
ImageViewerView(
images: viewModel.ownedImages.map { $0.imageUrl },
selectedIndex: $viewModel.selectedImageIndex
)
}
.overlay {
if viewModel.isShowPurchaseDialog {
SodaDialog(
title: "구매 확인",
desc: "선택한 이미지를 구매하시겠습니까?",
confirmButtonTitle: viewModel.selectedItemPrice,
confirmButtonAction: viewModel.onPurchaseConfirm,
cancelButtonTitle: "취소",
cancelButtonAction: viewModel.onPurchaseCancel
)
}
}
}
@@ -60,14 +93,14 @@ struct CharacterDetailGalleryView: View {
VStack(spacing: 8) {
// ( % , , )
HStack {
Text("\(ownershipPercentage)% 보유중")
Text("\(viewModel.ownershipPercentage)% 보유중")
.font(.custom(Font.preBold.rawValue, size: 18))
.foregroundColor(.white)
Spacer()
HStack(spacing: 4) {
Text("\(ownedCount)")
Text("\(viewModel.ownedCount)")
.font(.custom(Font.preRegular.rawValue, size: 16))
.foregroundColor(Color(hex: "#FDD453"))
@@ -75,7 +108,7 @@ struct CharacterDetailGalleryView: View {
.font(.custom(Font.preRegular.rawValue, size: 16))
.foregroundColor(.white)
Text("\(totalCount)")
Text("\(viewModel.totalCount)")
.font(.custom(Font.preRegular.rawValue, size: 16))
.foregroundColor(.white)
}
@@ -106,10 +139,10 @@ struct CharacterDetailGalleryView: View {
}
@ViewBuilder
private func galleryImageView(index: Int) -> some View {
private func galleryImageView(item: CharacterImageListItemResponse, index: Int) -> some View {
ZStack {
//
AsyncImage(url: URL(string: "https://picsum.photos/400/500")) { image in
AsyncImage(url: URL(string: item.imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
@@ -121,8 +154,8 @@ struct CharacterDetailGalleryView: View {
.clipped()
.cornerRadius(0)
// (index 2, 3)
if index >= 2 {
//
if !item.isOwned {
//
Rectangle()
.fill(Color.black.opacity(0.2))
@@ -143,7 +176,7 @@ struct CharacterDetailGalleryView: View {
.scaledToFit()
.frame(width: 16)
Text("20")
Text("\(item.imagePriceCan)")
.font(.custom(Font.preBold.rawValue, size: 16))
.foregroundColor(Color(hex: "#263238"))
}
@@ -159,9 +192,12 @@ struct CharacterDetailGalleryView: View {
}
}
}
.onTapGesture {
viewModel.onImageTapped(item, index: index)
}
}
}
#Preview {
CharacterDetailGalleryView()
CharacterDetailGalleryView(characterId: 1)
}

View File

@@ -13,11 +13,184 @@ final class CharacterDetailGalleryViewModel: ObservableObject {
// MARK: - Published State
@Published var isLoading: Bool = false
@Published var errorMessage: String = ""
@Published var isShowPurchaseDialog = false
@Published var isShowImageViewer = false
@Published var isShowPopup = false
// Gallery Data
@Published var totalCount: Int = 0
@Published var ownedCount: Int = 0
@Published var galleryItems: [CharacterImageListItemResponse] = []
@Published var selectedImageIndex: Int = 0
// MARK: - Private
private let repository = CharacterDetailGalleryRepository()
private var subscription = Set<AnyCancellable>()
private var currentPage = 0
private var isLoadingMore = false
private var hasMorePages = true
private var selectedItem: CharacterImageListItemResponse?
var characterId: Int = 0
// MARK: - Computed Properties
var ownershipPercentage: Int {
guard totalCount > 0 else { return 0 }
return Int(round(Double(ownedCount) / Double(totalCount) * 100))
}
var ownedImages: [CharacterImageListItemResponse] {
return galleryItems.filter { $0.isOwned }
}
var selectedItemPrice: String {
return "\(selectedItem?.imagePriceCan ?? 0)캔으로 구매"
}
// MARK: - Public Methods
func loadInitialData() {
currentPage = 0
hasMorePages = true
galleryItems.removeAll()
loadImageList()
}
func loadMoreIfNeeded(currentItem: CharacterImageListItemResponse) {
guard !isLoadingMore,
hasMorePages,
let lastItem = galleryItems.last,
lastItem.id == currentItem.id else { return }
loadMoreImages()
}
func onImageTapped(_ item: CharacterImageListItemResponse, index: Int) {
selectedItem = item
if item.isOwned {
// -
selectedImageIndex = ownedImages.firstIndex { $0.id == item.id } ?? 0
isShowImageViewer = true
} else {
// -
isShowPurchaseDialog = true
}
}
func onPurchaseConfirm() {
// TODO: API
print("구매 확인: \(selectedItem?.id ?? 0)")
isShowPurchaseDialog = false
}
func onPurchaseCancel() {
isShowPurchaseDialog = false
selectedItem = nil
}
// MARK: - Private Methods
private func loadImageList() {
guard !isLoading else { return }
isLoading = true
repository.getCharacterImageList(
characterId: characterId,
page: currentPage,
size: 20
)
.receive(on: DispatchQueue.main)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [weak self] response in
let responseData = response.data
self?.isLoading = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<CharacterImageListResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self?.handleImageListResponse(data)
} else {
if let message = decoded.message {
self?.errorMessage = message
} else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self?.isShowPopup = true
}
} catch {
ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self?.isShowPopup = true
}
}
.store(in: &subscription)
}
private func loadMoreImages() {
guard !isLoadingMore, hasMorePages else { return }
isLoadingMore = true
currentPage += 1
repository.getCharacterImageList(
characterId: characterId,
page: currentPage,
size: 20
)
.receive(on: DispatchQueue.main)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [weak self] response in
let responseData = response.data
self?.isLoading = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<CharacterImageListResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self?.handleImageListResponse(data)
} else {
if let message = decoded.message {
self?.errorMessage = message
} else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self?.isShowPopup = true
}
} catch {
ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self?.isShowPopup = true
}
}
.store(in: &subscription)
}
private func handleImageListResponse(_ response: CharacterImageListResponse) {
totalCount = response.totalCount
ownedCount = response.ownedCount
galleryItems = response.items
hasMorePages = response.items.count >= 20
}
private func handleMoreImagesResponse(_ response: CharacterImageListResponse) {
galleryItems.append(contentsOf: response.items)
hasMorePages = response.items.count >= 20
}
}

View File

@@ -16,5 +16,4 @@ struct CharacterImageListItemResponse: Decodable {
let imageUrl: String
let isOwned: Bool
let imagePriceCan: Int
}

View File

@@ -0,0 +1,69 @@
//
// ImageViewerView.swift
// SodaLive
//
// Created by klaus on 9/2/25.
//
import SwiftUI
struct ImageViewerView: View {
let images: [String]
@Binding var selectedIndex: Int
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
TabView(selection: $selectedIndex) {
ForEach(Array(images.enumerated()), id: \.offset) { index, imageUrl in
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
.tint(.white)
}
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
VStack {
HStack {
Spacer()
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.title2)
.foregroundColor(.white)
.padding()
}
}
Spacer()
//
HStack(spacing: 8) {
ForEach(0..<images.count, id: \.self) { index in
Circle()
.fill(selectedIndex == index ? Color.white : Color.white.opacity(0.3))
.frame(width: 8, height: 8)
}
}
.padding(.bottom, 50)
}
}
}
}
#Preview {
ImageViewerView(
images: ["https://picsum.photos/400/500"],
selectedIndex: .constant(0)
)
}