diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 258a72e6..e601a6be 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -141,6 +141,8 @@ 4C25262B2F3B97D6003CC5AD /* UIViewController+Find.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526292F3B97D6003CC5AD /* UIViewController+Find.swift */; }; 4C25262C2F3B97D6003CC5AD /* TabBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526272F3B97D6003CC5AD /* TabBarManager.swift */; }; 4C25262D2F3B97D6003CC5AD /* TabBarSwipeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526282F3B97D6003CC5AD /* TabBarSwipeObserver.swift */; }; + 4C25262F2F3C95DA003CC5AD /* LottieItemManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25262E2F3C95DA003CC5AD /* LottieItemManager.swift */; }; + 4C2526312F3C95EA003CC5AD /* LottieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2526302F3C95EA003CC5AD /* LottieItemView.swift */; }; 4C3687F72EBFA87800C64E75 /* Pretendard-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */; }; 4C3687FA2EBFC0FB00C64E75 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */; }; 4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */; }; @@ -612,6 +614,8 @@ 4C2526272F3B97D6003CC5AD /* TabBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarManager.swift; sourceTree = ""; }; 4C2526282F3B97D6003CC5AD /* TabBarSwipeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarSwipeObserver.swift; sourceTree = ""; }; 4C2526292F3B97D6003CC5AD /* UIViewController+Find.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Find.swift"; sourceTree = ""; }; + 4C25262E2F3C95DA003CC5AD /* LottieItemManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieItemManager.swift; sourceTree = ""; }; + 4C2526302F3C95EA003CC5AD /* LottieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieItemView.swift; sourceTree = ""; }; 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Medium.ttf"; sourceTree = ""; }; 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlert.swift; sourceTree = ""; }; @@ -1993,6 +1997,7 @@ 4CF2A9652F0B8C5800BA9FDA /* PullToRefreshIndicator.swift */, 4CF2A9672F0B91F300BA9FDA /* AnimatedGIFView.swift */, C645AE9E2EB1055C004BFE69 /* CategoryTabBar.swift */, + 4C2526302F3C95EA003CC5AD /* LottieItemView.swift */, ); path = View; sourceTree = ""; @@ -2384,6 +2389,7 @@ C68931CD2EB7B94B00C5F083 /* FileManagers */ = { isa = PBXGroup; children = ( + 4C25262E2F3C95DA003CC5AD /* LottieItemManager.swift */, C68931CC2EB7B94B00C5F083 /* EffectManager.swift */, AA9123AE2ED4BC490070A9F9 /* LocationManager.swift */, 38C3C27D2EC08794003C5DE1 /* PopupManager.swift */, @@ -2703,6 +2709,7 @@ AA2146B72F15E5B60048D40E /* BundleEditView+SelectSheet.swift in Sources */, 389080172ED3F05D00D7A49F /* FestivalKeyringDetailView.swift in Sources */, 38A22A7F2EC2238800B4C7C5 /* CollectionKeyringPackageView.swift in Sources */, + 4C2526312F3C95EA003CC5AD /* LottieItemView.swift in Sources */, 4CC3D3C82EC8A47E0009D376 /* OutlineText.swift in Sources */, 385425C32EB2C35E00A06C02 /* WidgetSettingView.swift in Sources */, 4CEC622B2EAE08DA0099ECEE /* Font+Styles.swift in Sources */, @@ -2905,6 +2912,7 @@ 4C2525DA2F35B2A7003CC5AD /* BundleSheetFilterBar.swift in Sources */, BC00020F2F35F00200000003 /* BundleSearchBar.swift in Sources */, BC0002102F35F00200000004 /* KeyringEmptyStateView.swift in Sources */, + 4C25262F2F3C95DA003CC5AD /* LottieItemManager.swift in Sources */, BC0002132F35F00200000006 /* KeyringSelectionContent.swift in Sources */, BC0002152F35F00200000008 /* BundleKeyringCellView.swift in Sources */, 3828F5452EC4CC0A00F1B040 /* CollectionViewModel+Filter.swift in Sources */, diff --git a/Keychy/Keychy/CommonModels/KeyringBundle/Background.swift b/Keychy/Keychy/CommonModels/KeyringBundle/Background.swift index 6fa477f7..8da019f7 100644 --- a/Keychy/Keychy/CommonModels/KeyringBundle/Background.swift +++ b/Keychy/Keychy/CommonModels/KeyringBundle/Background.swift @@ -22,7 +22,10 @@ struct Background: Identifiable, Codable, Equatable, Hashable { /// 배경 이미지 URL (썸네일 공통) let backgroundImage: String - + + /// 배경 Lottie JSON URL (nil이면 정적 이미지) + let backgroundLottie: String? + /// 배경 분류 태그 (ex. ["귀여움", "#키워드"]) let tags: [String] @@ -41,6 +44,11 @@ struct Background: Identifiable, Codable, Equatable, Hashable { /// 앱 노출 여부 (false면 앱에서 숨김) let isActive: Bool + /// Lottie 아이템 여부 + var isLottie: Bool { + backgroundLottie != nil && !(backgroundLottie?.isEmpty ?? true) + } + /// 무료 배경 여부 var isFree: Bool { return price == 0 diff --git a/Keychy/Keychy/CommonModels/KeyringBundle/Carabiner.swift b/Keychy/Keychy/CommonModels/KeyringBundle/Carabiner.swift index 254d2118..5801ea75 100644 --- a/Keychy/Keychy/CommonModels/KeyringBundle/Carabiner.swift +++ b/Keychy/Keychy/CommonModels/KeyringBundle/Carabiner.swift @@ -23,7 +23,12 @@ struct Carabiner: Identifiable, Codable, Equatable, Hashable { /// - [1] : 뒷 이미지 /// - [2] : 앞 이미지 let carabinerImage: [String] - + + /// 카라비너 Lottie JSON URL 배열 (nil이면 정적 이미지) + /// - plain: [0] = 단일 Lottie + /// - hamburger: [0] = 썸네일, [1] = 뒷면, [2] = 앞면 + let carabinerLottie: [String]? + /// 카라비너 타입 /// - .hamburger : 벽걸이 형 /// - .plain : 일반 카라비너 형 @@ -66,11 +71,16 @@ struct Carabiner: Identifiable, Codable, Equatable, Hashable { /// 키링 y위치 배열 let keyringYPosition: [CGFloat] + /// Lottie 아이템 여부 + var isLottie: Bool { + carabinerLottie != nil && !(carabinerLottie?.isEmpty ?? true) + } + /// 무료 카라비너 여부 var isFree: Bool { return price == 0 } - + /// 카라비너 타입 enum var type: CarabinerType { return CarabinerType.from(carabinerType) @@ -98,4 +108,24 @@ struct Carabiner: Identifiable, Codable, Equatable, Hashable { var thumbnailImageURL: String { return carabinerImage.first ?? "" } + + /// 뒷면 Lottie URL (plain: [0], hamburger: [1]) + var backLottieURL: String? { + guard isLottie else { return nil } + switch type { + case .hamburger: + return carabinerLottie?.count ?? 0 > 1 ? carabinerLottie?[1] : nil + case .plain: + return carabinerLottie?.count ?? 0 > 0 ? carabinerLottie?[0] : nil + } + } + + /// 앞면 Lottie URL (hamburger 타입만) + var frontLottieURL: String? { + guard isLottie, type == .hamburger, + let lottie = carabinerLottie, lottie.count > 2 else { + return nil + } + return lottie[2] + } } diff --git a/Keychy/Keychy/Core/Components/View/LottieItemView.swift b/Keychy/Keychy/Core/Components/View/LottieItemView.swift new file mode 100644 index 00000000..b5d76a9b --- /dev/null +++ b/Keychy/Keychy/Core/Components/View/LottieItemView.swift @@ -0,0 +1,134 @@ +// +// LottieItemView.swift +// Keychy +// +// Created by 길지훈 on 2/11/26. +// + +import SwiftUI +import Lottie + +/// 배경/카라비너 Lottie 아이템 표시용 뷰 +/// - JSON 파싱을 백그라운드 스레드에서 비동기 수행 (메인 스레드 블로킹 방지) +/// - NSCache 기반 인메모리 캐시로 동일 에셋 재파싱 방지 +/// - Coordinator 패턴으로 LottieAnimationView 생명주기 관리 +struct LottieItemView: UIViewRepresentable { + let assetId: String + let directory: String + let loopMode: LottieLoopMode + let contentMode: UIView.ContentMode + + // MARK: - 인메모리 캐시 + /// LottieAnimation.filepath()는 JSON 파싱 비용이 큼 + /// NSCache로 파싱 결과를 메모리에 유지하여 같은 에셋 재파싱 방지 + /// NSCache는 thread-safe이므로 별도 lock 불필요 + private static let animationCache = NSCache() + + /// NSCache는 class 타입만 저장 가능 → LottieAnimation을 래핑 + private class AnimationWrapper { + let animation: LottieAnimation + init(_ animation: LottieAnimation) { self.animation = animation } + } + + init( + assetId: String, + directory: String, + loopMode: LottieLoopMode = .loop, + contentMode: UIView.ContentMode = .scaleAspectFill + ) { + self.assetId = assetId + self.directory = directory + self.loopMode = loopMode + self.contentMode = contentMode + } + + // MARK: - Coordinator + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var animationView: LottieAnimationView? + var loadTask: Task? + } + + // MARK: - UIViewRepresentable + func makeUIView(context: Context) -> UIView { + let container = UIView(frame: .zero) + container.clipsToBounds = true + + let animationView = LottieAnimationView() + animationView.contentMode = contentMode + context.coordinator.animationView = animationView + + container.addSubview(animationView) + animationView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + animationView.widthAnchor.constraint(equalTo: container.widthAnchor), + animationView.heightAnchor.constraint(equalTo: container.heightAnchor) + ]) + + // JSON 파싱을 백그라운드 스레드에서 비동기 수행 + // Task.detached: 현재 actor 컨텍스트를 상속하지 않으므로 백그라운드에서 실행됨 + let assetId = self.assetId + let directory = self.directory + let loopMode = self.loopMode + + context.coordinator.loadTask = Task.detached(priority: .userInitiated) { + let animation = Self.loadAnimation(assetId: assetId, directory: directory) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard !Task.isCancelled, let animation else { return } + animationView.animation = animation + animationView.loopMode = loopMode + animationView.play() + } + } + + return container + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + /// 뷰 제거 시 비동기 Task 취소 + 애니메이션 정지 + 메모리 해제 + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.loadTask?.cancel() + coordinator.loadTask = nil + coordinator.animationView?.stop() + coordinator.animationView?.animation = nil + coordinator.animationView?.removeFromSuperview() + coordinator.animationView = nil + } + + // MARK: - Animation Loading + /// 인메모리 캐시 → 디스크 순서로 LottieAnimation 로드 + /// 백그라운드 스레드에서 호출되므로 메인 스레드를 블로킹하지 않음 + private static func loadAnimation(assetId: String, directory: String) -> LottieAnimation? { + let key = "\(directory)/\(assetId)" as NSString + + // 1. 인메모리 캐시 히트 → 즉시 반환 (JSON 재파싱 없음) + if let cached = animationCache.object(forKey: key) { + return cached.animation + } + + // 2. 디스크에서 로드 (JSON 파싱 발생) + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let cachedURL = cacheDir.appendingPathComponent("\(directory)/\(assetId).json") + guard FileManager.default.fileExists(atPath: cachedURL.path), + let animation = LottieAnimation.filepath(cachedURL.path) else { return nil } + + // 3. 인메모리 캐시에 저장 + animationCache.setObject(AnimationWrapper(animation), forKey: key) + + return animation + } + + // MARK: - Cache Invalidation + /// 특정 에셋의 인메모리 캐시 무효화 + /// LottieItemManager에서 새 파일 다운로드 완료 시 호출 + static func invalidateCache(assetId: String, directory: String) { + let key = "\(directory)/\(assetId)" as NSString + animationCache.removeObject(forKey: key) + } +} diff --git a/Keychy/Keychy/Core/Components/View/LottieView.swift b/Keychy/Keychy/Core/Components/View/LottieView.swift index b8cff1c0..9a62c0dd 100644 --- a/Keychy/Keychy/Core/Components/View/LottieView.swift +++ b/Keychy/Keychy/Core/Components/View/LottieView.swift @@ -13,37 +13,51 @@ struct LottieView: UIViewRepresentable { let loopMode: LottieLoopMode let speed: CGFloat - private let animationView = LottieAnimationView() + // MARK: - Coordinator + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var animationView: LottieAnimationView? + } + // MARK: - UIViewRepresentable func makeUIView(context: Context) -> UIView { - let view = UIView(frame: .zero) + let container = UIView(frame: .zero) + + let animationView = LottieAnimationView() + context.coordinator.animationView = animationView - // particleId로 캐시 → Bundle 순서로 파일 찾기 if let animation = findParticleAnimation(particleId: name) { animationView.animation = animation animationView.contentMode = .scaleAspectFit animationView.loopMode = loopMode animationView.animationSpeed = speed animationView.play() - } else { - // 파티클을 찾을 수 없을 때 - print("[LottieView] 파티클 찾을 수 없음: \(name)") } - view.addSubview(animationView) + container.addSubview(animationView) animationView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - animationView.widthAnchor.constraint(equalTo: view.widthAnchor), - animationView.heightAnchor.constraint(equalTo: view.heightAnchor) + animationView.widthAnchor.constraint(equalTo: container.widthAnchor), + animationView.heightAnchor.constraint(equalTo: container.heightAnchor) ]) - return view + return container } func updateUIView(_ uiView: UIView, context: Context) {} - /// 파티클 애니메이션 파일 찾기 (캐시 → Bundle 순서) + /// 뷰 제거 시 애니메이션 정지 + 메모리 해제 + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.animationView?.stop() + coordinator.animationView?.animation = nil + coordinator.animationView?.removeFromSuperview() + coordinator.animationView = nil + } + + // MARK: - Private private func findParticleAnimation(particleId: String) -> LottieAnimation? { - // 1. 로컬 캐시에서 찾기 let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let cachedURL = cacheDirectory.appendingPathComponent("particles/\(particleId).json") @@ -51,7 +65,6 @@ struct LottieView: UIViewRepresentable { return LottieAnimation.filepath(cachedURL.path) } - // 2. Bundle에서 찾기 (기본 무료 파티클) if let animation = LottieAnimation.named(particleId) { return animation } diff --git a/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift b/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift new file mode 100644 index 00000000..e4904e05 --- /dev/null +++ b/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift @@ -0,0 +1,212 @@ +// +// LottieItemManager.swift +// Keychy +// +// Created by 길지훈 on 2/11/26. +// + +import Foundation +import FirebaseStorage +import Lottie + +/// 배경/카라비너 Lottie 에셋 다운로드 및 캐싱 매니저 +/// - EffectManager와 동일한 다운로드/캐싱 패턴이지만, 배경/카라비너 도메인 전용 +@MainActor +@Observable +class LottieItemManager { + static let shared = LottieItemManager() + + var downloadProgress: [String: Double] = [:] + var downloadingItemIds: Set = [] + + private init() {} + + // MARK: - 캐시 디렉토리 구분 + + private enum AssetType: String { + case background = "lottie_backgrounds" + case carabinerBack = "lottie_carabiners_back" + case carabinerFront = "lottie_carabiners_front" + } + + // MARK: - 캐시 확인 + + /// 배경 Lottie가 캐시에 있는지 확인 + func isBackgroundCached(id: String) -> Bool { + return fileExists(id: id, type: .background) + } + + /// 카라비너 뒷면 Lottie가 캐시에 있는지 확인 + func isCarabinerBackCached(id: String) -> Bool { + return fileExists(id: id, type: .carabinerBack) + } + + /// 카라비너 앞면 Lottie가 캐시에 있는지 확인 + func isCarabinerFrontCached(id: String) -> Bool { + return fileExists(id: id, type: .carabinerFront) + } + + // MARK: - 다운로드 + + /// 배경 Lottie 다운로드 + func downloadBackgroundLottie(_ background: Background) async { + guard let bgId = background.id, + let lottieURL = background.backgroundLottie, + !lottieURL.isEmpty else { return } + + await downloadLottie( + id: bgId, + remoteURL: lottieURL, + type: .background + ) + } + + /// 카라비너 Lottie 다운로드 (뒷면 + 앞면 병렬) + func downloadCarabinerLottie(_ carabiner: Carabiner) async { + guard let carabinerId = carabiner.id, carabiner.isLottie else { return } + + // 뒷면/앞면 병렬 다운로드 + async let backDownload: Void = { + if let backURL = carabiner.backLottieURL { + await self.downloadLottie( + id: carabinerId, + remoteURL: backURL, + type: .carabinerBack + ) + } + }() + + async let frontDownload: Void = { + if let frontURL = carabiner.frontLottieURL { + await self.downloadLottie( + id: carabinerId, + remoteURL: frontURL, + type: .carabinerFront + ) + } + }() + + await backDownload + await frontDownload + } + + // MARK: - Lottie 로드 + + /// 배경 Lottie 애니메이션 로드 + func loadBackgroundAnimation(id: String) -> LottieAnimation? { + return loadAnimation(id: id, type: .background) + } + + /// 카라비너 뒷면 Lottie 애니메이션 로드 + func loadCarabinerBackAnimation(id: String) -> LottieAnimation? { + return loadAnimation(id: id, type: .carabinerBack) + } + + /// 카라비너 앞면 Lottie 애니메이션 로드 + func loadCarabinerFrontAnimation(id: String) -> LottieAnimation? { + return loadAnimation(id: id, type: .carabinerFront) + } + + // MARK: - Private + + /// 공통 다운로드 로직 + private func downloadLottie(id: String, remoteURL: String, type: AssetType) async { + let downloadKey = "\(type.rawValue)_\(id)" + + // 이미 다운로드 중이면 무시 + guard !downloadingItemIds.contains(downloadKey) else { return } + + // 캐시 유효성 검증: 파일 존재 + URL 일치하면 스킵 + if fileExists(id: id, type: type) && isCacheValid(id: id, currentURL: remoteURL, type: type) { + return + } + + downloadingItemIds.insert(downloadKey) + downloadProgress[downloadKey] = 0.0 + + let storageRef = Storage.storage().reference(forURL: remoteURL) + let localURL = cacheFileURL(id: id, type: type) + + // 디렉토리 생성 + let directory = localURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + // 다운로드 실행 + let downloadTask = storageRef.write(toFile: localURL) + + // 진행률 관찰 + _ = downloadTask.observe(.progress) { [weak self] snapshot in + guard let progress = snapshot.progress, + progress.totalUnitCount > 0 else { return } + let percentComplete = Double(progress.completedUnitCount) / Double(progress.totalUnitCount) + guard percentComplete.isFinite else { return } + + Task { @MainActor in + self?.downloadProgress[downloadKey] = percentComplete + } + } + + // 완료 대기 + await withCheckedContinuation { continuation in + downloadTask.observe(.success) { _ in + continuation.resume() + } + downloadTask.observe(.failure) { _ in + continuation.resume() + } + } + + // 파일 쓰기 완료 확인 + try? await Task.sleep(nanoseconds: 100_000_000) + var attempts = 0 + while !FileManager.default.fileExists(atPath: localURL.path) && attempts < 5 { + try? await Task.sleep(nanoseconds: 100_000_000) + attempts += 1 + } + + // 캐시 URL 저장 (다음 검증용) + saveCacheURL(id: id, url: remoteURL, type: type) + + // 해당 에셋의 인메모리 캐시만 무효화 (전체 캐시 삭제 X) + LottieItemView.invalidateCache(assetId: id, directory: type.rawValue) + + downloadingItemIds.remove(downloadKey) + downloadProgress.removeValue(forKey: downloadKey) + } + + /// 캐시 파일 경로 생성 + private func cacheFileURL(id: String, type: AssetType) -> URL { + let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + return cacheDirectory.appendingPathComponent("\(type.rawValue)/\(id).json") + } + + /// 파일 존재 여부 확인 + private func fileExists(id: String, type: AssetType) -> Bool { + let url = cacheFileURL(id: id, type: type) + return FileManager.default.fileExists(atPath: url.path) + } + + /// 캐시에서 Lottie 애니메이션 로드 + private func loadAnimation(id: String, type: AssetType) -> LottieAnimation? { + let url = cacheFileURL(id: id, type: type) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return LottieAnimation.filepath(url.path) + } + + // MARK: - Cache Validation (UserDefaults 기반) + + private func cacheURLKey(for id: String, type: AssetType) -> String { + return "cachedLottieURL_\(type.rawValue)_\(id)" + } + + private func isCacheValid(id: String, currentURL: String, type: AssetType) -> Bool { + let key = cacheURLKey(for: id, type: type) + guard let savedURL = UserDefaults.standard.string(forKey: key) else { return false } + return savedURL == currentURL + } + + private func saveCacheURL(id: String, url: String, type: AssetType) { + let key = cacheURLKey(for: id, type: type) + UserDefaults.standard.set(url, forKey: key) + } +} diff --git a/Keychy/Keychy/Core/Firebase/DataInitializer.swift b/Keychy/Keychy/Core/Firebase/DataInitializer.swift index f30f8808..393ad059 100644 --- a/Keychy/Keychy/Core/Firebase/DataInitializer.swift +++ b/Keychy/Keychy/Core/Firebase/DataInitializer.swift @@ -32,12 +32,64 @@ func initializeDatas() async { // MARK: - Background Initialization func initializeBackgrounds() async { let backgrounds: [[String: Any]] = [ + // MARK: 정적 배경 1 [ - "id": "ExampleBackground", - "backgroundName": "예시 배경", - "description": "새로운 배경 설명을 입력하세요", - "backgroundImage": "https://firebasestorage.googleapis.com/...", - "tags": ["태그1", "태그2"], + "id": "CheckHeart", + "backgroundName": "체크 하트", + "description": "체크 패턴 위에 하트가 그려진 배경이에요.", + "backgroundImage": "https://placeholder.com/bg.png", + "tags": ["배경"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "isActive": false + ], + // MARK: 정적 배경 2 + [ + "id": "PinkRibbon", + "backgroundName": "핑크 리본", + "description": "핑크색 리본이 그려진 배경이에요.", + "backgroundImage": "https://placeholder.com/bg.png", + "tags": ["배경"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "isActive": false + ], + // MARK: Lottie 배경 1 + [ + "id": "StarlightSky", + "backgroundName": "별빛 하늘", + "description": "별빛이 반짝이며 움직이는 하늘 배경이에요.", + "backgroundImage": "https://placeholder.com/bg.png", + "backgroundLottie": "gs://keychy-f6011.firebasestorage.app/Backgrounds/REPLACE_ME/background.json", + "tags": ["배경"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "isActive": false + ], + // MARK: Lottie 배경 2 + [ + "id": "AuroraSpace", + "backgroundName": "오로라 우주", + "description": "빛이 일렁이는 움직이는 우주 배경이에요.", + "backgroundImage": "https://placeholder.com/bg.png", + "backgroundLottie": "gs://keychy-f6011.firebasestorage.app/Backgrounds/REPLACE_ME/background.json", + "tags": ["배경"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "isActive": false + ], + // MARK: Lottie 배경 3 + [ + "id": "CloudBalloonSky", + "backgroundName": "구름 풍선 하늘", + "description": "구름 위로 풍선이 떠오르는 움직이는 배경이에요.", + "backgroundImage": "https://placeholder.com/bg.png", + "backgroundLottie": "gs://keychy-f6011.firebasestorage.app/Backgrounds/REPLACE_ME/background.json", + "tags": ["배경"], "price": 0, "downloadCount": 0, "useCount": 0, @@ -70,22 +122,102 @@ func initializeBackgrounds() async { // MARK: - Carabiner Initialization func initializeCarabiners() async { let carabiners: [[String: Any]] = [ + // MARK: Lottie 카라비너 1 (plain) [ - "id": "ExampleCarabiner", - "carabinerName": "예시 카라비너", - "carabinerImage": ["https://firebasestorage.googleapis.com/..."], - "carabinerType": "plain", // "plain" 또는 "hamburger" - "description": "새로운 카라비너 설명을 입력하세요", - "maxKeyringCount": 5, - "tags": ["태그1", "태그2"], + "id": "CarouselOrgel", + "carabinerName": "회전목마 오르골", + "carabinerImage": ["https://placeholder.com/cb.png"], + "carabinerLottie": ["gs://keychy-f6011.firebasestorage.app/Carabiners/REPLACE_ME/Carabiner0.json"], + "carabinerType": "plain", + "description": "은은한 빛과 함께 오르골 사운드가 담긴 회전목마 카라비너예요.", + "maxKeyringCount": 3, + "tags": ["카라비너"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "carabinerX": 0.0, + "carabinerY": 0.0, + "carabinerWidth": 100.0, + "keyringXPosition": [0.5, 0.3, 0.7], + "keyringYPosition": [0.3, 0.4, 0.4], + "isActive": false + ], + // MARK: Lottie 카라비너 2 (plain) + [ + "id": "UfoCat", + "carabinerName": "UFO 냥", + "carabinerImage": ["https://placeholder.com/cb.png"], + "carabinerLottie": ["gs://keychy-f6011.firebasestorage.app/Carabiners/REPLACE_ME/Carabiner0.json"], + "carabinerType": "plain", + "description": "고양이 외계인이 UFO에 탑승한 카라비너예요.\n귀여운 움직임도 함께^. .^₎⟆", + "maxKeyringCount": 3, + "tags": ["카라비너"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "carabinerX": 0.0, + "carabinerY": 0.0, + "carabinerWidth": 100.0, + "keyringXPosition": [0.5, 0.3, 0.7], + "keyringYPosition": [0.3, 0.4, 0.4], + "isActive": false + ], + // MARK: Lottie 카라비너 3 (plain) + [ + "id": "HeartPlanet", + "carabinerName": "하트 행성", + "carabinerImage": ["https://placeholder.com/cb.png"], + "carabinerLottie": ["gs://keychy-f6011.firebasestorage.app/Carabiners/REPLACE_ME/Carabiner0.json"], + "carabinerType": "plain", + "description": "행성 주변을 하트 위성이 공전하고 있는 카라비너예요.", + "maxKeyringCount": 3, + "tags": ["카라비너"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "carabinerX": 0.0, + "carabinerY": 0.0, + "carabinerWidth": 100.0, + "keyringXPosition": [0.5, 0.3, 0.7], + "keyringYPosition": [0.3, 0.4, 0.4], + "isActive": false + ], + // MARK: 정적 카라비너 1 (hamburger) + [ + "id": "HeartPepero", + "carabinerName": "하트 빼빼로", + "carabinerImage": ["https://placeholder.com/cb_thumb.png", "https://placeholder.com/cb_back.png", "https://placeholder.com/cb_front.png"], + "carabinerType": "hamburger", + "description": "하트모양 빼빼로 카라비너예요.\n빼빼로 위에 있는 별에 키링을 걸 수 있어요.", + "maxKeyringCount": 3, + "tags": ["카라비너"], + "price": 0, + "downloadCount": 0, + "useCount": 0, + "carabinerX": 0.0, + "carabinerY": 0.0, + "carabinerWidth": 100.0, + "keyringXPosition": [0.5, 0.3, 0.7], + "keyringYPosition": [0.3, 0.4, 0.4], + "isActive": false + ], + // MARK: 정적 카라비너 2 (plain) + [ + "id": "MeltingWhiteChoco", + "carabinerName": "멜팅 화이트 초코", + "carabinerImage": ["https://placeholder.com/cb.png"], + "carabinerType": "plain", + "description": "초콜릿이 녹아 달콤하게 흘러내리며 스프링클이 톡톡 뿌려진 카라비너예요.", + "maxKeyringCount": 3, + "tags": ["카라비너"], "price": 0, "downloadCount": 0, "useCount": 0, "carabinerX": 0.0, "carabinerY": 0.0, "carabinerWidth": 100.0, - "keyringXPosition": [0.5, 0.3, 0.7, 0.4, 0.6], - "keyringYPosition": [0.3, 0.4, 0.4, 0.5, 0.5], + "keyringXPosition": [0.5, 0.3, 0.7], + "keyringYPosition": [0.3, 0.4, 0.4], "isActive": false ] ] diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift index f5293180..de1f1a0a 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift @@ -8,6 +8,7 @@ import SwiftUI import SpriteKit import Combine +import Lottie /// 여러 키링을 하나의 씬에 배치하는 Scene class MultiKeyringScene: SKScene { @@ -59,7 +60,6 @@ class MultiKeyringScene: SKScene { // MARK: - 씬 준비 완료 콜백 var onEngineReady: (() -> Void)? // 엔진 준비 완료 콜백 - var onKeyringVisualReady: (() -> Void)? // 키링이 진짜 시각적으로 보이는지 콜백 var onSetupComplete: (() -> Void)? // 모든 키링 로드 완료 및 물리 활성화 완료 콜백 // MARK: - 키링 로드 완료 추적 private var totalKeyringsToLoad = 0 // 로드해야 할 총 키링 수 @@ -85,6 +85,7 @@ class MultiKeyringScene: SKScene { var backgroundImageURL: String? // 배경 이미지 URL var carabinerBackImageURL: String? // 카라비너 뒷면 이미지 (hamburger 타입) var carabinerFrontImageURL: String? // 카라비너 앞면 이미지 (hamburger 타입) + var carabinerLottieId: String? // 카라비너 Lottie ID (nil이면 정적 이미지) // MARK: - 영상 생성용 최적화 플래그 var disableShadows: Bool = false // 그림자 비활성화 (영상 생성 시 성능 최적화) @@ -99,6 +100,11 @@ class MultiKeyringScene: SKScene { private var carabinerBackNode: SKSpriteNode? private var carabinerFrontNode: SKSpriteNode? + // MARK: - 카라비너 Lottie 프리렌더링 텍스처 (비디오 생성에서도 사용) + var carabinerBackTextures: [SKTexture]? + var carabinerFrontTextures: [SKTexture]? + var carabinerLottieFPS: Double = 30 + // MARK: - 스와이프 제스처 관련 var lastTouchLocation: CGPoint? var lastTouchTime: TimeInterval = 0 @@ -117,7 +123,8 @@ class MultiKeyringScene: SKScene { carabinerId: String = "", carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, - carabinerWidth: CGFloat = 0 + carabinerWidth: CGFloat = 0, + carabinerLottieId: String? = nil ) { self.keyringDataList = keyringDataList self.currentRingType = ringType @@ -130,6 +137,7 @@ class MultiKeyringScene: SKScene { self.carabinerX = carabinerX self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth + self.carabinerLottieId = carabinerLottieId super.init(size: .zero) } @@ -160,6 +168,10 @@ class MultiKeyringScene: SKScene { carabinerFrontReady = false didStartKeyringSetup = false + // Lottie 프리렌더링 텍스처 해제 + carabinerBackTextures = nil + carabinerFrontTextures = nil + // 모든 물리 조인트 제거 physicsWorld.removeAllJoints() @@ -202,12 +214,27 @@ class MultiKeyringScene: SKScene { guard let self else { return } guard !self.isCleaningUp else { return } - // 카라비너 이미지 병렬 로드 - async let backLoaded: Void = self.loadCarabinerBack() - async let frontLoaded: Void = self.loadCarabinerFront() - - await backLoaded - await frontLoaded + if let lottieId = self.carabinerLottieId { + if self.disableShadows { + // 비디오/캡처 모드: 기존대로 프리렌더링 (프레임 인덱스 기반 수동 교체 필요) + async let backLoaded: Void = self.loadCarabinerLottie(side: .back, id: lottieId) + async let frontLoaded: Void = self.loadCarabinerLottie(side: .front, id: lottieId) + await backLoaded + await frontLoaded + } else { + // 실시간 모드: 프리렌더링 스킵, SwiftUI LottieItemView 오버레이가 담당 + await MainActor.run { [weak self] in + self?.setCarabinerReady(side: .back) + self?.setCarabinerReady(side: .front) + } + } + } else { + // 정적 이미지 카라비너 로드 (기존) + async let backLoaded: Void = self.loadCarabinerBack() + async let frontLoaded: Void = self.loadCarabinerFront() + await backLoaded + await frontLoaded + } // 키링 설정 (카라비너 준비 후) await MainActor.run { [weak self] in @@ -353,6 +380,131 @@ class MultiKeyringScene: SKScene { } } + // MARK: - Carabiner Lottie Setup + + /// 카라비너 Lottie의 앞/뒤 구분 + private enum CarabinerSide { + case back, front + + var cacheDirectory: String { + switch self { + case .back: "lottie_carabiners_back" + case .front: "lottie_carabiners_front" + } + } + + var zPosition: CGFloat { + switch self { + case .back: -900 + case .front: -800 + } + } + + var hasShadow: Bool { self == .back } + } + + /// [비디오/캡처 전용] 카라비너 Lottie 첫 프레임만 정적 렌더링 + /// - 실시간 모드에서는 호출되지 않음 (SwiftUI LottieItemView 오버레이가 담당) + /// - 전체 프리렌더링 대신 첫 프레임 1장만 렌더링하여 메모리 절약 + private func loadCarabinerLottie(side: CarabinerSide, id: String) async { + let carabinerWidth = self.carabinerWidth + + // 백그라운드: JSON 파싱 + 첫 프레임 렌더링 + let cacheDirectory = side.cacheDirectory + let result = await Task.detached(priority: .userInitiated) { + () -> (texture: SKTexture, size: CGSize)? in + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let cachedURL = cacheDir.appendingPathComponent("\(cacheDirectory)/\(id).json") + guard FileManager.default.fileExists(atPath: cachedURL.path), + let animation = LottieAnimation.filepath(cachedURL.path) else { return nil } + + let aspectRatio = animation.size.height / animation.size.width + let renderSize = CGSize(width: carabinerWidth, height: carabinerWidth * aspectRatio) + + // 첫 프레임만 렌더링 (정적 이미지로 사용) + let format = UIGraphicsImageRendererFormat() + format.scale = 2.0 + let config = LottieConfiguration(renderingEngine: .mainThread) + let lottieView = LottieAnimationView(animation: animation, configuration: config) + lottieView.frame = CGRect(origin: .zero, size: renderSize) + lottieView.contentMode = .scaleAspectFit + lottieView.currentFrame = AnimationFrameTime(animation.startFrame) + lottieView.setNeedsDisplay() + lottieView.layer.displayIfNeeded() + + let renderer = UIGraphicsImageRenderer(bounds: lottieView.bounds, format: format) + let image = renderer.image { context in + lottieView.layer.render(in: context.cgContext) + } + return (SKTexture(image: image), renderSize) + }.value + + guard let result else { + await MainActor.run { [weak self] in self?.setCarabinerReady(side: side) } + return + } + + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp else { + self?.setCarabinerReady(side: side) + return + } + self.setupCarabinerLottieNode(side: side, texture: result.texture, size: result.size) + self.setCarabinerReady(side: side) + } + } + + /// 카라비너 Lottie 노드 생성 및 씬에 추가 (캐시 히트/미스 공통) + private func setupCarabinerLottieNode(side: CarabinerSide, texture: SKTexture, size: CGSize) { + let node = SKSpriteNode(texture: texture) + node.size = size + let spriteKitY = self.size.height - (self.carabinerY + size.height / 2) + node.position = CGPoint(x: self.carabinerX + size.width / 2, y: spriteKitY) + node.zPosition = side.zPosition + self.addChild(node) + self.setCarabinerNode(side: side, node: node) + } + + // MARK: - CarabinerSide 헬퍼 (프로퍼티 분기) + + private func setCarabinerReady(side: CarabinerSide) { + switch side { + case .back: carabinerBackReady = true + case .front: carabinerFrontReady = true + } + } + + private func setCarabinerNode(side: CarabinerSide, node: SKSpriteNode) { + switch side { + case .back: carabinerBackNode = node + case .front: carabinerFrontNode = node + } + } + + private func carabinerNode(for side: CarabinerSide) -> SKSpriteNode? { + switch side { + case .back: carabinerBackNode + case .front: carabinerFrontNode + } + } + + /// 카라비너 Lottie 텍스처 수동 업데이트 (비디오 생성용) + /// - 비디오 렌더링에서는 SKAction이 제대로 동작하지 않으므로, 프레임 인덱스 기반으로 수동 교체 + /// - videoFPS와 carabinerLottieFPS의 비율로 Lottie 프레임 인덱스를 계산 + func updateCarabinerLottieTexture(at frameIndex: Int, videoFPS: Double) { + guard let backTextures = carabinerBackTextures, !backTextures.isEmpty else { return } + + // 비디오 프레임 → Lottie 프레임 인덱스 매핑 (modulo로 루프 처리) + let lottieFrameIndex = Int(Double(frameIndex) * carabinerLottieFPS / videoFPS) % backTextures.count + carabinerBackNode?.texture = backTextures[lottieFrameIndex] + + // 앞면 텍스처 교체 (hamburger 타입만) + if let frontTextures = carabinerFrontTextures, !frontTextures.isEmpty { + let frontIndex = Int(Double(frameIndex) * carabinerLottieFPS / videoFPS) % frontTextures.count + carabinerFrontNode?.texture = frontTextures[frontIndex] + } + } + // MARK: - Setup private func setupKeyringsIfNeeded() { @@ -642,7 +794,6 @@ class MultiKeyringScene: SKScene { body.physicsBody?.contactTestBitMask = 0 self.addChild(body) - onKeyringVisualReady?() if let spriteBody = body as? SKSpriteNode { self.addShadowToNode(spriteBody, offsetX: 8, offsetY: -8) } diff --git a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift index 0970d043..e078dfe5 100644 --- a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift +++ b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift @@ -24,20 +24,23 @@ struct MultiKeyringSceneView: View { let chainType: ChainType let backgroundColor: UIColor let backgroundImageURL: String? + let backgroundLottieId: String? let carabinerBackImageURL: String? let carabinerFrontImageURL: String? + let carabinerLottieId: String? let carabinerId: String let carabinerX: CGFloat let carabinerY: CGFloat let carabinerWidth: CGFloat let currentCarabinerType: CarabinerType + let cleanupOnDisappear: Bool let onBackgroundLoaded: (() -> Void)? let onAllKeyringsReady: (() -> Void)? @State private var scene: MultiKeyringScene? @State private var particleEffects: [ParticleEffect] = [] @State private var backgroundImage: UIImage? - @State private var visibleKeyringCount = 0 + @State private var carabinerLottieAspectRatio: CGFloat = 1.0 // 기본 화면 크기 (iPhone 16 Pro 기준) private let defaultSceneSize = CGSize(width: 402, height: 874) @@ -48,13 +51,16 @@ struct MultiKeyringSceneView: View { chainType: ChainType = .basic, backgroundColor: UIColor = .clear, backgroundImageURL: String? = nil, + backgroundLottieId: String? = nil, carabinerBackImageURL: String? = nil, carabinerFrontImageURL: String? = nil, + carabinerLottieId: String? = nil, carabinerId: String = "", carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0, currentCarabinerType: CarabinerType, + cleanupOnDisappear: Bool = false, onBackgroundLoaded: (() -> Void)? = nil, onAllKeyringsReady: (() -> Void)? = nil ) { @@ -63,13 +69,16 @@ struct MultiKeyringSceneView: View { self.chainType = chainType self.backgroundColor = backgroundColor self.backgroundImageURL = backgroundImageURL + self.backgroundLottieId = backgroundLottieId self.carabinerBackImageURL = carabinerBackImageURL self.carabinerFrontImageURL = carabinerFrontImageURL + self.carabinerLottieId = carabinerLottieId self.carabinerId = carabinerId self.carabinerX = carabinerX self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth self.currentCarabinerType = currentCarabinerType + self.cleanupOnDisappear = cleanupOnDisappear self.onBackgroundLoaded = onBackgroundLoaded self.onAllKeyringsReady = onAllKeyringsReady } @@ -77,12 +86,15 @@ struct MultiKeyringSceneView: View { var body: some View { ZStack { backgroundView + carabinerBackLottieOverlay // 카라비너 뒷면 Lottie (SpriteKit 씬 뒤) sceneView + carabinerFrontLottieOverlay // 카라비너 앞면 Lottie (hamburger만, 씬 앞) particleEffectsView } .onAppear { if scene == nil { loadBackgroundImage() + loadCarabinerLottieAspectRatio() setupScene() } } @@ -90,11 +102,16 @@ struct MultiKeyringSceneView: View { loadBackgroundImage() } .onChange(of: currentCarabinerType) { _, _ in + loadCarabinerLottieAspectRatio() setupScene() } - .onChange(of: visibleKeyringCount) { _, count in - if count == keyringDataList.count { - onAllKeyringsReady?() + .onDisappear { + // .id() 변경으로 뷰가 교체될 때 이전 씬의 비동기 콜백 무효화 + // (비로티 카라비너 이미지 로드가 뒤늦게 완료되어 콜백이 누출되는 것 방지) + scene?.onSetupComplete = nil + + if cleanupOnDisappear { + cleanupScene() } } } @@ -105,7 +122,16 @@ extension MultiKeyringSceneView { private var backgroundView: some View { GeometryReader { geometry in Group { - if let backgroundImage { + if let bgLottieId = backgroundLottieId { + // Lottie 배경 + LottieItemView( + assetId: bgLottieId, + directory: "lottie_backgrounds" + ) + .id(bgLottieId) + .frame(width: geometry.size.width, height: geometry.size.height) + } else if let backgroundImage { + // 정적 이미지 배경 (기존) Image(uiImage: backgroundImage) .resizable() .scaledToFill() @@ -146,6 +172,78 @@ extension MultiKeyringSceneView { } } + // MARK: - 카라비너 Lottie 오버레이 (실시간 모드 전용) + + /// 카라비너 뒷면 Lottie 오버레이 (plain + hamburger 공통) + private var carabinerBackLottieOverlay: some View { + Group { + if let lottieId = carabinerLottieId { + GeometryReader { geometry in + let metrics = carabinerScreenMetrics( + geometry: geometry, aspectRatio: carabinerLottieAspectRatio + ) + LottieItemView( + assetId: lottieId, + directory: "lottie_carabiners_back" + ) + .frame(width: metrics.width, height: metrics.height) + .position(x: metrics.centerX, y: metrics.centerY) + .shadow(color: .black.opacity(0.25), radius: 1.0, x: 2, y: 3) + } + .allowsHitTesting(false) + } + } + } + + /// 카라비너 앞면 Lottie 오버레이 (hamburger 타입만) + private var carabinerFrontLottieOverlay: some View { + Group { + if let lottieId = carabinerLottieId, + currentCarabinerType == .hamburger { + GeometryReader { geometry in + let metrics = carabinerScreenMetrics( + geometry: geometry, aspectRatio: carabinerLottieAspectRatio + ) + LottieItemView( + assetId: lottieId, + directory: "lottie_carabiners_front" + ) + .frame(width: metrics.width, height: metrics.height) + .position(x: metrics.centerX, y: metrics.centerY) + } + .allowsHitTesting(false) + } + } + } + + /// 씬 좌표(402×874) → 화면 좌표 변환 (aspectFill 스케일링 고려) + private func carabinerScreenMetrics( + geometry: GeometryProxy, aspectRatio: CGFloat + ) -> (width: CGFloat, height: CGFloat, centerX: CGFloat, centerY: CGFloat) { + let sceneW = defaultSceneSize.width + let sceneH = defaultSceneSize.height + // aspectFill: 화면을 꽉 채우도록 스케일 (초과분은 클리핑) + let scale = max(geometry.size.width / sceneW, geometry.size.height / sceneH) + let dx = (geometry.size.width - sceneW * scale) / 2 + let dy = (geometry.size.height - sceneH * scale) / 2 + + let cbWidth = carabinerWidth * scale + let cbHeight = carabinerWidth * aspectRatio * scale + let cbCenterX = dx + (carabinerX + carabinerWidth / 2) * scale + let cbCenterY = dy + (carabinerY + carabinerWidth * aspectRatio / 2) * scale + + return (cbWidth, cbHeight, cbCenterX, cbCenterY) + } + + /// 캐시된 Lottie JSON에서 종횡비만 파싱 (렌더링 없음, 즉시 완료) + private func loadCarabinerLottieAspectRatio() { + guard let lottieId = carabinerLottieId else { return } + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let path = cacheDir.appendingPathComponent("lottie_carabiners_back/\(lottieId).json") + if let animation = LottieAnimation.filepath(path.path) { + carabinerLottieAspectRatio = animation.size.height / animation.size.width + } + } /// 배경 이미지 로드 private func loadBackgroundImage() { @@ -171,8 +269,6 @@ extension MultiKeyringSceneView { cleanupScene() } - visibleKeyringCount = 0 - let newScene = MultiKeyringScene( keyringDataList: keyringDataList, ringType: ringType, @@ -184,15 +280,16 @@ extension MultiKeyringSceneView { carabinerId: carabinerId, carabinerX: carabinerX, carabinerY: carabinerY, - carabinerWidth: carabinerWidth + carabinerWidth: carabinerWidth, + carabinerLottieId: carabinerLottieId ) newScene.size = defaultSceneSize newScene.scaleMode = .aspectFill newScene.currentCarabinerType = currentCarabinerType newScene.onPlayParticleEffect = handleParticleEffect - newScene.onKeyringVisualReady = { - visibleKeyringCount += 1 + newScene.onSetupComplete = { + onAllKeyringsReady?() } scene = newScene } diff --git a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Particle.swift b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Particle.swift index 06d8c139..afc0c6e9 100644 --- a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Particle.swift +++ b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Particle.swift @@ -80,8 +80,8 @@ extension BundleVideoGenerator { ) } - /// Lottie 애니메이션의 모든 프레임을 SKTexture로 프리렌더링 - private func preRenderAllFrames(lottieView: LottieAnimationView, animation: LottieAnimation) -> [SKTexture] { + /// Lottie 애니메이션의 모든 프레임을 SKTexture로 프리렌더링 (배경/카라비너에서도 공용 사용) + func preRenderAllFrames(lottieView: LottieAnimationView, animation: LottieAnimation) -> [SKTexture] { let totalFrames = Int(animation.endFrame - animation.startFrame) var textures: [SKTexture] = [] textures.reserveCapacity(totalFrames) diff --git a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift index 502cde6b..c623b9e8 100644 --- a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift +++ b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift @@ -25,9 +25,13 @@ extension BundleVideoGenerator { } for frameIndex in 0..: View { chainType: .basic, backgroundColor: .clear, backgroundImageURL: bg.background.backgroundImage, + backgroundLottieId: bg.background.isLottie ? bg.background.id : nil, carabinerBackImageURL: cb.carabiner.backImageURL, carabinerFrontImageURL: cb.carabiner.frontImageURL, + carabinerLottieId: cb.carabiner.isLottie ? cb.carabiner.id : nil, carabinerId: cb.carabiner.id ?? "", carabinerX: cb.carabiner.carabinerX, carabinerY: cb.carabiner.carabinerY, carabinerWidth: cb.carabiner.carabinerWidth, currentCarabinerType: cb.carabiner.type, - onBackgroundLoaded: { - // 키링이 없으면 배경 로드 시 바로 준비 완료 - if selectedKeyrings.isEmpty { - withAnimation(.easeOut(duration: 0.3)) { - isSceneReady = true - } - } - }, + cleanupOnDisappear: true, onAllKeyringsReady: { + // onSetupComplete에서 호출됨 + // (카라비너 Lottie 프리렌더링 + 키링 로드 + 물리 활성화 후) withAnimation(.easeOut(duration: 0.3)) { isSceneReady = true } } ) - .id("scene_\(bg.background.id ?? "bg")_\(cb.carabiner.id ?? "cb")_\(selectedKeyrings.count)_\(sceneRefreshId.uuidString)") + .id("scene_\(cb.carabiner.id ?? "cb")_\(selectedKeyrings.count)_\(sceneRefreshId.uuidString)") // 키링 추가 + 버튼들 keyringButtons(carabiner: cb.carabiner) @@ -109,6 +106,13 @@ struct BundleCreateView: View { .blur(radius: showPurchaseSuccessAlert || isCapturing ? 10 : 0) } + // Lottie 씬 로딩 중 (시트 포함 전체 차단) + if !isSceneReady { + Color.black20 + .ignoresSafeArea() + LoadingAlert(type: .longWithKeychy, message: "아이템을 불러오고 있어요") + } + // 캡처 중 로딩 if isCapturing { Color.black20 @@ -174,6 +178,7 @@ extension BundleCreateView { var customNavigationBar: some View { CustomNavigationBar { BackToolbarButton { + TabBarManager.show() router.pop() } } center: { diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift index 531a6814..3c4fca9e 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift @@ -34,8 +34,10 @@ extension BundleDetailView { keyringDataList: keyringDataList, backgroundImage: backgroundImage, backgroundImageURL: background.backgroundImage, + backgroundLottieId: background.isLottie ? background.id : nil, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerLottieId: carabiner.isLottie ? carabiner.id : nil, carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, @@ -116,8 +118,10 @@ extension BundleDetailView { keyringDataList: keyringDataList, backgroundImage: backgroundImage, backgroundImageURL: background.backgroundImage, + backgroundLottieId: background.isLottie ? background.id : nil, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerLottieId: carabiner.isLottie ? carabiner.id : nil, carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift index 9680370b..64082905 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift @@ -73,13 +73,16 @@ struct BundleDetailView: View { chainType: .basic, backgroundColor: .clear, backgroundImageURL: background.backgroundImage, + backgroundLottieId: background.isLottie ? background.id : nil, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerLottieId: carabiner.isLottie ? carabiner.id : nil, carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, currentCarabinerType: carabiner.type, + cleanupOnDisappear: true, onAllKeyringsReady: { // 기존 딜레이 작업 취소 readyDelayTask?.cancel() diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift index c40e4529..ba0769e2 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift @@ -41,7 +41,10 @@ extension BundleEditView { bundleVM.selectedKeyrings.removeAll() bundleVM.keyringOrder.removeAll() - // 3) 새 카라비너 적용 + // 3) 새 카라비너 적용 (Lottie면 로딩 표시) + if selectCarabiner?.carabiner.isLottie == true { + isSceneReady = false + } bundleVM.newSelectedCarabiner = selectCarabiner // 4) 빈 상태를 씬/리스트에 반영 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift index c9e9e96e..427a2816 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift @@ -183,13 +183,16 @@ struct BundleEditView: View { chainType: .basic, backgroundColor: .clear, backgroundImageURL: background.background.backgroundImage, + backgroundLottieId: background.background.isLottie ? background.background.id : nil, carabinerBackImageURL: carabiner.carabiner.backImageURL, carabinerFrontImageURL: carabiner.carabiner.frontImageURL, + carabinerLottieId: carabiner.carabiner.isLottie ? carabiner.carabiner.id : nil, carabinerId: carabiner.carabiner.id ?? "", carabinerX: carabiner.carabiner.carabinerX, carabinerY: carabiner.carabiner.carabinerY, carabinerWidth: carabiner.carabiner.carabinerWidth, currentCarabinerType: carabiner.carabiner.type, + cleanupOnDisappear: true, onAllKeyringsReady: { withAnimation(.easeOut(duration: 0.3)) { isSceneReady = true @@ -198,7 +201,7 @@ struct BundleEditView: View { ) .ignoresSafeArea() .animation(.easeInOut(duration: 0.3), value: isSceneReady) - .id("scene_\(background.background.id ?? "bg")_\(carabiner.carabiner.id ?? "cb")_\(keyringDataList.count)_\(sceneRefreshId.uuidString)") + .id("scene_\(carabiner.carabiner.id ?? "cb")_\(keyringDataList.count)_\(sceneRefreshId.uuidString)") // 키링 추가 버튼들 GeometryReader { geometry in diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift index a1654583..149897c4 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift @@ -11,28 +11,19 @@ import NukeUI struct BackgroundCell: View { let background: BackgroundViewData let isSelected: Bool - + var useThumbnail: Bool = false + var body: some View { VStack(spacing: 6) { ZStack(alignment: .top) { - // 배경 이미지 - LazyImage(url: URL(string: background.background.backgroundImage)) { state in - if let image = state.image { - image - .resizable() - .scaledToFill() - .clipped() - } else if state.isLoading { - LoadingAlert(type: .short30, message: nil) - } - } - .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) - .background(.white100) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelected ? .main500 : .clear, lineWidth: 2) - ) + cellImageContent + .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelected ? .main500 : .clear, lineWidth: 2) + ) VStack { HStack { // 유료 아이콘 @@ -85,4 +76,26 @@ struct BackgroundCell: View { } .contentShape(Rectangle()) } + + /// Lottie / 정적 이미지 분기 (공통 모디파이어는 호출처에서 적용) + @ViewBuilder + private var cellImageContent: some View { + if background.background.isLottie && !useThumbnail, let bgId = background.background.id { + LottieItemView( + assetId: bgId, + directory: "lottie_backgrounds" + ) + } else { + LazyImage(url: URL(string: background.background.backgroundImage)) { state in + if let image = state.image { + image + .resizable() + .scaledToFill() + .clipped() + } else if state.isLoading { + LoadingAlert(type: .short30, message: nil) + } + } + } + } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift index 2531edaa..2e9ba757 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift @@ -11,33 +11,20 @@ import NukeUI struct CarabinerCell: View { var carabiner: CarabinerViewData var isSelected: Bool + var useThumbnail: Bool = false var body: some View { VStack(spacing: 6) { ZStack(alignment: .topLeading) { - // 카라비너 이미지 - LazyImage(url: URL(string: carabiner.carabiner.carabinerImage[0])) { state in - if let image = state.image { - image - .resizable() - .scaledToFit() - .clipped() - } else if state.isLoading { - LoadingAlert(type: .short30, message: nil) - } else { - Color.clear - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - .padding(3.55) - .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) - .background(.white100) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelected ? .mainOpacity80 : .clear, lineWidth: 1.8) - ) + cellImageContent + .padding(3.55) + .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelected ? .mainOpacity80 : .clear, lineWidth: 1.8) + ) // 유료 재화 표시 VStack { @@ -91,4 +78,31 @@ struct CarabinerCell: View { } .contentShape(Rectangle()) } + + /// Lottie / 정적 이미지 분기 (공통 모디파이어는 호출처에서 적용) + @ViewBuilder + private var cellImageContent: some View { + if carabiner.carabiner.isLottie && !useThumbnail, let carabinerId = carabiner.carabiner.id { + LottieItemView( + assetId: carabinerId, + directory: "lottie_carabiners_back", + contentMode: .scaleAspectFit + ) + } else { + LazyImage(url: URL(string: carabiner.carabiner.carabinerImage[0])) { state in + if let image = state.image { + image + .resizable() + .scaledToFit() + .clipped() + } else if state.isLoading { + LoadingAlert(type: .short30, message: nil) + } else { + Color.clear + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift index 4e393523..93de0792 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift @@ -60,17 +60,19 @@ struct SelectBackgroundSheet: View { // 그리드만 (필터바는 DraggableSheet header로 이동) LazyVGrid(columns: gridColumns, spacing: 20) { ForEach(filteredAndSortedBackgrounds) { bg in - BackgroundCell(background: bg, isSelected: (bg == selectedBG)) - .onTapGesture { - onBackgroundTap(bg) + Button { + onBackgroundTap(bg) - // 무료이고, 유저가 보유x인 경우에만 바로 추가 - if !bg.isOwned && bg.background.isFree { - Task { - await viewModel.addBackgroundToUser(backgroundName: bg.background.backgroundName, userManager: UserManager.shared) - } + // 무료이고, 유저가 보유x인 경우에만 바로 추가 + if !bg.isOwned && bg.background.isFree { + Task { + await viewModel.addBackgroundToUser(backgroundName: bg.background.backgroundName, userManager: UserManager.shared) } } + } label: { + BackgroundCell(background: bg, isSelected: (bg == selectedBG)) + } + .buttonStyle(.plain) } } .padding(.horizontal, 20) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift index 4c4fc3ea..f0dfe6f2 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift @@ -60,16 +60,18 @@ struct SelectCarabinerSheet: View { // 그리드만 (필터바는 DraggableSheet header로 이동) LazyVGrid(columns: gridColumns, spacing: 20) { ForEach(filteredAndSortedCarabiners) { cb in - CarabinerCell(carabiner: cb, isSelected: (selectedCarabiner == cb)) - .onTapGesture { - onCarabinerTap(cb) + Button { + onCarabinerTap(cb) - if !cb.isOwned && cb.carabiner.isFree { - Task { - await viewModel.addCarabinerToUser(carabinerName: cb.carabiner.carabinerName, userManager: UserManager.shared) - } + if !cb.isOwned && cb.carabiner.isFree { + Task { + await viewModel.addCarabinerToUser(carabinerName: cb.carabiner.carabinerName, userManager: UserManager.shared) } } + } label: { + CarabinerCell(carabiner: cb, isSelected: (selectedCarabiner == cb)) + } + .buttonStyle(.plain) } } .padding(.horizontal, 20) diff --git a/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift b/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift index 19968321..7dcadb74 100644 --- a/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift +++ b/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift @@ -56,6 +56,20 @@ class CollectionViewModel { func loadBackgroundsAndCarabiners() async { await dataManager.fetchBackgroundsIfNeeded() await dataManager.fetchCarabinersIfNeeded() + + // Lottie 아이템 JSON 프리다운로드 + let lottieBackgrounds = dataManager.backgrounds.filter { $0.isLottie } + let lottieCarabiners = dataManager.carabiners.filter { $0.isLottie } + if !lottieBackgrounds.isEmpty || !lottieCarabiners.isEmpty { + Task { + for bg in lottieBackgrounds { + await LottieItemManager.shared.downloadBackgroundLottie(bg) + } + for cb in lottieCarabiners { + await LottieItemManager.shared.downloadCarabinerLottie(cb) + } + } + } } /// 네트워크 에러 후 재시도 diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift index 96f9cd70..2a76b3c2 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift @@ -24,12 +24,24 @@ class HomeViewModel { /// 마지막으로 로드한 뭉치 ID (뭉치 변경 감지용) private var lastLoadedBundleId: String? + /// 씬 준비 완료 대기 Task (새 로딩 시작 시 취소용) + private var sceneReadyTask: Task? + + /// 뭉치 전환 Task (빠른 연속 전환 시 이전 전환 취소용) + private var switchBundleTask: Task? + + /// 씬 세대 카운터 (이전 씬의 콜백이 현재 씬에 영향주지 않도록 구분) + private(set) var sceneGeneration = 0 + /// 다른 화면에서 키링/뭉치 수정 후 홈 리프레시 필요 여부 static var needsRefresh: Bool = false /// 네트워크 에러 발생 여부 var hasNetworkError: Bool = false + /// 마지막 씬 생성 시점의 키링 URL 시그니처 (.id() 변경 감지용) + private var lastSceneKeyringSignature: String = "" + // MARK: - Private Properties private let db = Firestore.firestore() @@ -43,6 +55,7 @@ class HomeViewModel { @MainActor func loadMainBundle(collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel, onBackgroundLoaded: (() -> Void)?) async { + // 리프레시 필요 시 캐시 무효화 if Self.needsRefresh { Self.needsRefresh = false @@ -57,7 +70,10 @@ class HomeViewModel { } let uid = UserManager.shared.userUID - guard !uid.isEmpty else { return } + guard !uid.isEmpty else { + isSceneReady = true + return + } // 1. 배경 및 카라비너 데이터 로드 await collectionViewModel.loadBackgroundsAndCarabiners() @@ -69,46 +85,54 @@ class HomeViewModel { } } - // 3. 메인 뭉치 설정 (isMain == true인 뭉치, 없으면 첫 번째 뭉치) + // 3. 메인 뭉치 찾기 (아직 selectedBundle에 설정하지 않음 — 배치 업데이트를 위해) + var bundle: KeyringBundle if let mainBundle = bundleViewModel.sortedBundles.first(where: { $0.isMain }) { - bundleViewModel.selectedBundle = mainBundle + bundle = mainBundle } else if let firstBundle = bundleViewModel.sortedBundles.first { - bundleViewModel.selectedBundle = firstBundle + bundle = firstBundle } else { - // 번들이 하나도 없는 경우 - 스플래시 즉시 종료 + isSceneReady = true onBackgroundLoaded?() return } - - // 4. 선택된 뭉치의 배경과 카라비너 설정 - guard var bundle = bundleViewModel.selectedBundle else { return } - - // 배경 resolve 시도 + // 4. 배경 resolve (로컬 변수에만 저장) var resolvedBackground = bundleViewModel.resolveBackground(from: bundle.selectedBackground) - // 배경이 없으면 첫 번째 배경으로 업데이트 + // 배경이 없으면 첫 번째 배경으로 fallback + Firebase 업데이트 if resolvedBackground == nil, let firstBackground = bundleViewModel.backgrounds.first { resolvedBackground = firstBackground - // Firebase에 번들의 배경 업데이트 if let documentId = bundle.documentId, let backgroundId = firstBackground.id { await updateBundleBackground(documentId: documentId, backgroundId: backgroundId) - // 로컬 상태도 업데이트 + // bundles 배열만 업데이트 (selectedBundle은 아직 변경 X) bundle.selectedBackground = backgroundId - bundleViewModel.selectedBundle?.selectedBackground = backgroundId if let index = bundleViewModel.bundles.firstIndex(where: { $0.documentId == documentId }) { bundleViewModel.bundles[index].selectedBackground = backgroundId } } } + // 5. 카라비너 resolve (로컬 변수에만 저장) + var resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + if resolvedCarabiner == nil, let fallback = bundleViewModel.carabiners.first { + resolvedCarabiner = fallback + } - bundleViewModel.selectedBackground = resolvedBackground - bundleViewModel.selectedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + guard let carabiner = resolvedCarabiner else { + isSceneReady = true + return + } - // 5. 키링 데이터 생성 - guard let carabiner = bundleViewModel.selectedCarabiner else { return } - keyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) + // 6. 키링 데이터 생성 (아직 UI 상태 변경 없음) + let newKeyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) + + // 7. 모든 상태를 한 번에 업데이트 (SwiftUI body 재평가 최소화) + // switchBundle과 동일한 배치 업데이트 패턴 + bundleViewModel.selectedBundle = bundle + bundleViewModel.selectedBackground = resolvedBackground + bundleViewModel.selectedCarabiner = carabiner + keyringDataList = newKeyringDataList // 데이터 로드 완료 표시 lastLoadedBundleId = bundle.documentId @@ -249,7 +273,21 @@ class HomeViewModel { func handleKeyringDataChange() { // 빈 뭉치면 이미 createKeyringDataList에서 isSceneReady = true 설정됨 // 다시 false로 리셋하면 무한로딩 발생 - guard !keyringDataList.isEmpty else { return } + guard !keyringDataList.isEmpty else { + lastSceneKeyringSignature = "" + return + } + + // .id() 변경 여부 체크: bodyImageURL이 같으면 씬이 재생성되지 않음 + // 씬이 재생성되지 않으면 onSetupComplete 콜백이 안 오므로 + // isSceneReady = false로 바꾸면 무한로딩 발생 + let newSignature = keyringDataList.map(\.bodyImageURL).joined(separator: ",") + guard newSignature != lastSceneKeyringSignature else { return } + lastSceneKeyringSignature = newSignature + + // 이전 씬의 준비 완료 Task 취소 + sceneReadyTask?.cancel() + sceneReadyTask = nil withAnimation(.easeIn(duration: 0.2)) { isSceneReady = false @@ -257,12 +295,19 @@ class HomeViewModel { } /// 모든 키링 준비 완료되면 0.5초 대기 후 로딩을 삭제함 - func handleAllKeyringsReady() { - Task { [weak self] in + /// - Parameter generation: 이 콜백을 생성한 씬의 세대 번호 + func handleAllKeyringsReady(generation: Int) { + // 이전 씬의 콜백이면 무시 (세대가 다르면 이미 새 씬이 생성된 것) + guard generation == sceneGeneration else { return } + + sceneReadyTask?.cancel() + + sceneReadyTask = Task { [weak self] in try? await Task.sleep(for: .seconds(0.5)) + guard !Task.isCancelled else { return } await MainActor.run { [weak self] in - guard let self else { return } + guard let self, generation == self.sceneGeneration else { return } withAnimation(.easeOut(duration: 0.3)) { self.isSceneReady = true } @@ -280,31 +325,50 @@ class HomeViewModel { // MARK: - Bundle Switching - /// 다른 뭉치로 전환 - /// - Parameters: - /// - bundle: 전환할 뭉치 - /// - collectionViewModel: CollectionViewModel - /// - bundleViewModel: BundleViewModel + /// 뭉치 전환 요청 (이전 전환 진행 중이면 취소 후 새 전환 시작) @MainActor - func switchBundle(to bundle: KeyringBundle, collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel) async { - // 1. 씬 준비 상태 초기화 (로딩 효과 표시) + func requestBundleSwitch(to bundle: KeyringBundle, collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel) { + // 이전 뭉치 전환 Task 취소 + switchBundleTask?.cancel() + withAnimation(.easeIn(duration: 0.2)) { isSceneReady = false } - // 2. 대표뭉치 설정 (isMain 업데이트) + switchBundleTask = Task { + await switchBundle(to: bundle, collectionViewModel: collectionViewModel, bundleViewModel: bundleViewModel) + } + } + + /// 다른 뭉치로 전환 (내부 구현, requestBundleSwitch를 통해 호출) + @MainActor + private func switchBundle(to bundle: KeyringBundle, collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel) async { + // 1. 대표뭉치 설정 (isMain 업데이트) await updateMainBundle(newMainBundle: bundle, bundleViewModel: bundleViewModel) - // 3. 모든 데이터 먼저 준비 (UI 업데이트 전) + // 취소 체크: 다른 뭉치 전환 요청이 들어왔으면 중단 + guard !Task.isCancelled else { return } + + // 2. 모든 데이터 먼저 준비 (UI 업데이트 전) let resolvedBackground = bundleViewModel.resolveBackground(from: bundle.selectedBackground) - let resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + // 카라비너 resolve 실패 시 첫 번째 카라비너로 fallback + var resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + if resolvedCarabiner == nil, let fallback = bundleViewModel.carabiners.first { + resolvedCarabiner = fallback + } - guard let carabiner = resolvedCarabiner else { return } + guard let carabiner = resolvedCarabiner else { + isSceneReady = true + return + } - // 4. 키링 데이터 생성 (새 카라비너 기준) + // 3. 키링 데이터 생성 (새 카라비너 기준) let newKeyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) - // 5. 모든 상태를 한 번에 업데이트 (SwiftUI re-render 최소화) + // 취소 체크: 키링 데이터 생성 중 다른 전환 요청이 들어왔으면 중단 + guard !Task.isCancelled else { return } + + // 4. 모든 상태를 한 번에 업데이트 (SwiftUI re-render 최소화) bundleViewModel.selectedBundle = bundle bundleViewModel.selectedBackground = resolvedBackground ?? bundleViewModel.backgrounds.first bundleViewModel.selectedCarabiner = carabiner diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index da6446b6..b6aab7cf 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -44,16 +44,20 @@ struct HomeView: View { // 블러 영역 ZStack(alignment: .top) { if let bundle = bundleViewModel.selectedBundle, - let carabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner), + let carabiner = bundleViewModel.selectedCarabiner, let background = bundleViewModel.selectedBackground { + // 현재 세대를 캡처하여 이전 씬의 콜백과 구분 + let currentGeneration = viewModel.sceneGeneration MultiKeyringSceneView( keyringDataList: viewModel.keyringDataList, ringType: .basic, chainType: .basic, backgroundColor: .clear, backgroundImageURL: background.backgroundImage, + backgroundLottieId: background.isLottie ? background.id : nil, carabinerBackImageURL: carabiner.backImageURL, carabinerFrontImageURL: carabiner.frontImageURL, + carabinerLottieId: carabiner.isLottie ? carabiner.id : nil, carabinerId: carabiner.id ?? "", carabinerX: carabiner.carabinerX, carabinerY: carabiner.carabinerY, @@ -61,7 +65,7 @@ struct HomeView: View { currentCarabinerType: carabiner.type, onBackgroundLoaded: onBackgroundLoaded, onAllKeyringsReady: { - viewModel.handleAllKeyringsReady() + viewModel.handleAllKeyringsReady(generation: currentGeneration) } ) .ignoresSafeArea() @@ -247,17 +251,12 @@ extension HomeView { showBundleSwitchPopup = false } - // 로딩 시작 - viewModel.isSceneReady = false - - // 선택된 뭉치로 변경 후 로드 - Task { - await viewModel.switchBundle( - to: bundle, - collectionViewModel: collectionViewModel, - bundleViewModel: bundleViewModel - ) - } + // 뭉치 전환 요청 (이전 전환 진행 중이면 자동 취소) + viewModel.requestBundleSwitch( + to: bundle, + collectionViewModel: collectionViewModel, + bundleViewModel: bundleViewModel + ) } } diff --git a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift index 39a9222e..ee1b9ceb 100644 --- a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift +++ b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift @@ -263,10 +263,28 @@ class WorkshopViewModel { await dataManager.fetchBackgroundsIfNeeded() extractAvailableTags() loadedCategories.insert("배경") + // Lottie 배경 JSON 프리다운로드 + let lottieBackgrounds = backgrounds.filter { $0.isLottie } + if !lottieBackgrounds.isEmpty { + Task { + for bg in lottieBackgrounds { + await LottieItemManager.shared.downloadBackgroundLottie(bg) + } + } + } case "카라비너": await dataManager.fetchCarabinersIfNeeded() extractAvailableTags() loadedCategories.insert("카라비너") + // Lottie 카라비너 JSON 프리다운로드 + let lottieCarabiners = carabiners.filter { $0.isLottie } + if !lottieCarabiners.isEmpty { + Task { + for cb in lottieCarabiners { + await LottieItemManager.shared.downloadCarabinerLottie(cb) + } + } + } default: break } @@ -314,6 +332,20 @@ class WorkshopViewModel { // 정렬 적용 applySorting() + + // Lottie 아이템 JSON 프리다운로드 + let lottieBackgrounds = backgrounds.filter { $0.isLottie } + let lottieCarabiners = carabiners.filter { $0.isLottie } + if !lottieBackgrounds.isEmpty || !lottieCarabiners.isEmpty { + Task { + for bg in lottieBackgrounds { + await LottieItemManager.shared.downloadBackgroundLottie(bg) + } + for cb in lottieCarabiners { + await LottieItemManager.shared.downloadCarabinerLottie(cb) + } + } + } } /// 네트워크 에러 후 재시도 diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift index c470f0ea..5120659f 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift @@ -55,6 +55,17 @@ struct WorkshopItemCard: View { } } } + } else if let background = item as? Background, background.isLottie, let bgId = background.id { + // Lottie 배경 + LottieItemView(assetId: bgId, directory: "lottie_backgrounds") + .frame(width: twoGridCellWidth, height: itemHeight) + .clipped() + } else if let carabiner = item as? Carabiner, carabiner.isLottie, let cbId = carabiner.id { + // Lottie 카라비너 + LottieItemView(assetId: cbId, directory: "lottie_carabiners_back", contentMode: .scaleAspectFit) + .padding(.horizontal, 5) + .frame(width: twoGridCellWidth, height: itemHeight) + .clipped() } else { // Sound, Background, Carabiner, 키링 등은 기존처럼 이미지로 처리 (GIF 지원) SimpleAnimatedImage(url: item.thumbnailURL) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift index e1999db6..40c7a783 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift @@ -182,7 +182,24 @@ extension WorkshopItemDetailView { // 파티클이 아닌 경우 이미지 표시 if !(item is Particle) { - if item is Background { + if let background = item as? Background, background.isLottie, let bgId = background.id { + // Lottie 배경 + LottieItemView(assetId: bgId, directory: "lottie_backgrounds") + .frame(height: getBottomPadding(5) == 0 ? 501 : 380) + .frame(maxWidth: .infinity) + .clipped() + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.gray50, lineWidth: 2) + ) + } else if let carabiner = item as? Carabiner, carabiner.isLottie, let cbId = carabiner.id { + // Lottie 카라비너 + LottieItemView(assetId: cbId, directory: "lottie_carabiners_back", contentMode: .scaleAspectFit) + .aspectRatio(1, contentMode: .fit) + .clipped() + .cornerRadius(20) + } else if item is Background { ItemDetailImage(itemURL: getPreviewURL()) .scaledToFill() .frame(maxWidth: .infinity, maxHeight: getBottomPadding(5) == 0 ? 501 : 380)