feat(character-detail, gallery): 캐릭터 상세 갤러리 - 이미지 리스트 API 연동
CharacterDetailView에 갤러리 탭을 추가하고, 갤러리 화면/상태 관리/네트워킹을 구현했습니다. 소유/미소유 UI, 페이지네이션, 이미지 뷰어, 오류 토스트를 포함합니다. TODO: 이미지 구매 API 연동
This commit is contained in:
@@ -11,6 +11,7 @@ import Moya
|
|||||||
enum CharacterApi {
|
enum CharacterApi {
|
||||||
case getCharacterHome
|
case getCharacterHome
|
||||||
case getCharacterDetail(characterId: Int)
|
case getCharacterDetail(characterId: Int)
|
||||||
|
case getCharacterImageList(characterId: Int, page: Int, size: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CharacterApi: TargetType {
|
extension CharacterApi: TargetType {
|
||||||
@@ -23,12 +24,30 @@ extension CharacterApi: TargetType {
|
|||||||
|
|
||||||
case .getCharacterDetail(let characterId):
|
case .getCharacterDetail(let characterId):
|
||||||
return "/api/chat/character/\(characterId)"
|
return "/api/chat/character/\(characterId)"
|
||||||
|
|
||||||
|
case .getCharacterImageList:
|
||||||
|
return "/api/chat/character/image/list"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var method: Moya.Method { .get }
|
var method: Moya.Method { .get }
|
||||||
|
|
||||||
var task: Moya.Task { .requestPlain }
|
var task: Moya.Task {
|
||||||
|
switch self {
|
||||||
|
case .getCharacterHome, .getCharacterDetail:
|
||||||
|
return .requestPlain
|
||||||
|
|
||||||
|
case .getCharacterImageList(let characterId, let page, let size):
|
||||||
|
return .requestParameters(
|
||||||
|
parameters: [
|
||||||
|
"characterId": characterId,
|
||||||
|
"page": page,
|
||||||
|
"size": size
|
||||||
|
],
|
||||||
|
encoding: URLEncoding.queryString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var headers: [String : String]? {
|
var headers: [String : String]? {
|
||||||
["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ struct CharacterDetailView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 갤러리 탭
|
// 갤러리 탭
|
||||||
CharacterDetailGalleryView()
|
CharacterDetailGalleryView(characterId: characterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 대화하기 버튼
|
// 대화하기 버튼
|
||||||
|
|||||||
@@ -12,4 +12,18 @@ import Moya
|
|||||||
|
|
||||||
final class CharacterDetailGalleryRepository {
|
final class CharacterDetailGalleryRepository {
|
||||||
private let characterApi = MoyaProvider<CharacterApi>()
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,48 +10,81 @@ import SwiftUI
|
|||||||
struct CharacterDetailGalleryView: View {
|
struct CharacterDetailGalleryView: View {
|
||||||
@StateObject var viewModel = CharacterDetailGalleryViewModel()
|
@StateObject var viewModel = CharacterDetailGalleryViewModel()
|
||||||
|
|
||||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 3)
|
private let columns = Array(repeating: GridItem(.flexible()), count: 3)
|
||||||
|
let characterId: Int
|
||||||
// 갤러리 데이터
|
|
||||||
private let ownedCount: Int = 104
|
|
||||||
private let totalCount: Int = 259
|
|
||||||
|
|
||||||
// 계산된 속성들
|
// 계산된 속성들
|
||||||
private var ownershipPercentage: Int {
|
|
||||||
guard totalCount > 0 else { return 0 }
|
|
||||||
return Int(round(Double(ownedCount) / Double(totalCount) * 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var progressBarWidth: CGFloat {
|
private var progressBarWidth: CGFloat {
|
||||||
let maxWidth: CGFloat = 352 // 전체 진행률 바의 최대 너비
|
let maxWidth: CGFloat = screenSize().width - 48
|
||||||
guard totalCount > 0 else { return 0 }
|
guard viewModel.totalCount > 0 else { return 0 }
|
||||||
let percentage = Double(ownedCount) / Double(totalCount)
|
let percentage = Double(viewModel.ownedCount) / Double(viewModel.totalCount)
|
||||||
return maxWidth * percentage
|
return maxWidth * percentage
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
BaseView(isLoading: $viewModel.isLoading) {
|
BaseView(isLoading: $viewModel.isLoading) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 24) {
|
||||||
// 상단 여백 24px
|
|
||||||
Spacer()
|
|
||||||
.frame(height: 24)
|
|
||||||
|
|
||||||
// 보유 정보 섹션
|
// 보유 정보 섹션
|
||||||
collectionInfoView()
|
collectionInfoView()
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
// 갤러리 그리드
|
// 갤러리 그리드
|
||||||
LazyVGrid(columns: columns, spacing: 2) {
|
ScrollView {
|
||||||
ForEach(0..<4, id: \.self) { index in
|
LazyVGrid(columns: columns, spacing: 2) {
|
||||||
galleryImageView(index: index)
|
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()
|
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) {
|
VStack(spacing: 8) {
|
||||||
// 상단 정보 (계산된 % 보유중, 정보 아이콘, 개수)
|
// 상단 정보 (계산된 % 보유중, 정보 아이콘, 개수)
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(ownershipPercentage)% 보유중")
|
Text("\(viewModel.ownershipPercentage)% 보유중")
|
||||||
.font(.custom(Font.preBold.rawValue, size: 18))
|
.font(.custom(Font.preBold.rawValue, size: 18))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("\(ownedCount)")
|
Text("\(viewModel.ownedCount)")
|
||||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||||
.foregroundColor(Color(hex: "#FDD453"))
|
.foregroundColor(Color(hex: "#FDD453"))
|
||||||
|
|
||||||
@@ -75,7 +108,7 @@ struct CharacterDetailGalleryView: View {
|
|||||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
Text("\(totalCount)개")
|
Text("\(viewModel.totalCount)개")
|
||||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
@@ -106,10 +139,10 @@ struct CharacterDetailGalleryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func galleryImageView(index: Int) -> some View {
|
private func galleryImageView(item: CharacterImageListItemResponse, index: Int) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 이미지
|
// 이미지
|
||||||
AsyncImage(url: URL(string: "https://picsum.photos/400/500")) { image in
|
AsyncImage(url: URL(string: item.imageUrl)) { image in
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
@@ -121,8 +154,8 @@ struct CharacterDetailGalleryView: View {
|
|||||||
.clipped()
|
.clipped()
|
||||||
.cornerRadius(0)
|
.cornerRadius(0)
|
||||||
|
|
||||||
// 자물쇠가 있는 이미지들 (index 2, 3)
|
// 미소유 이미지 오버레이
|
||||||
if index >= 2 {
|
if !item.isOwned {
|
||||||
// 어두운 오버레이
|
// 어두운 오버레이
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.black.opacity(0.2))
|
.fill(Color.black.opacity(0.2))
|
||||||
@@ -143,7 +176,7 @@ struct CharacterDetailGalleryView: View {
|
|||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 16)
|
.frame(width: 16)
|
||||||
|
|
||||||
Text("20")
|
Text("\(item.imagePriceCan)")
|
||||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||||
.foregroundColor(Color(hex: "#263238"))
|
.foregroundColor(Color(hex: "#263238"))
|
||||||
}
|
}
|
||||||
@@ -159,9 +192,12 @@ struct CharacterDetailGalleryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.onImageTapped(item, index: index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CharacterDetailGalleryView()
|
CharacterDetailGalleryView(characterId: 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,184 @@ final class CharacterDetailGalleryViewModel: ObservableObject {
|
|||||||
// MARK: - Published State
|
// MARK: - Published State
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var errorMessage: String = ""
|
@Published var errorMessage: String = ""
|
||||||
|
@Published var isShowPurchaseDialog = false
|
||||||
|
@Published var isShowImageViewer = false
|
||||||
@Published var isShowPopup = 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
|
// MARK: - Private
|
||||||
private let repository = CharacterDetailGalleryRepository()
|
private let repository = CharacterDetailGalleryRepository()
|
||||||
private var subscription = Set<AnyCancellable>()
|
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
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ struct CharacterImageListItemResponse: Decodable {
|
|||||||
let imageUrl: String
|
let imageUrl: String
|
||||||
let isOwned: Bool
|
let isOwned: Bool
|
||||||
let imagePriceCan: Int
|
let imagePriceCan: Int
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user