From f4f93cbbcb76f340624e587797b32ea2d0b7eea6 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 21:16:51 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20Background/Carabiner=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EC=97=90=20Lottie=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Background: backgroundLottie(String?), isLottie computed property 추가 - Carabiner: carabinerLottie([String]?), isLottie/backLottieURL/frontLottieURL 추가 카라비너는 햄버거 타입도 있어서 배열. - Optional 필드로 기존 Firestore 데이터 하위 호환 유지 --- .../KeyringBundle/Background.swift | 10 +++++- .../KeyringBundle/Carabiner.swift | 34 +++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Keychy/Keychy/CommonModels/KeyringBundle/Background.swift b/Keychy/Keychy/CommonModels/KeyringBundle/Background.swift index 6fa477f7f..8da019f7e 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 254d21185..5801ea758 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] + } } From 0a225455af509b42c64f1d96f4bd6b2fee3138cf Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 21:17:36 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20LottieItemManager=20=EB=B0=8F=20L?= =?UTF-8?q?ottieItemView=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EffectManager와 동일한 다운로드/캐싱 패턴이지만, 배경/카라비너 도메인 전용 - LottieItemManager: Firebase Storage에서 Lottie JSON 다운로드/캐싱, URL 기반 캐시 무효화 - LottieItemView: UIViewRepresentable 기반 범용 Lottie 뷰 컴포넌트 --- .../Core/Components/View/LottieItemView.swift | 65 ++++++ .../Core/FileManagers/LottieItemManager.swift | 212 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 Keychy/Keychy/Core/Components/View/LottieItemView.swift create mode 100644 Keychy/Keychy/Core/FileManagers/LottieItemManager.swift diff --git a/Keychy/Keychy/Core/Components/View/LottieItemView.swift b/Keychy/Keychy/Core/Components/View/LottieItemView.swift new file mode 100644 index 000000000..d84c5c420 --- /dev/null +++ b/Keychy/Keychy/Core/Components/View/LottieItemView.swift @@ -0,0 +1,65 @@ +// +// LottieItemView.swift +// Keychy +// +// Created by 길지훈 on 2/11/26. +// + +import SwiftUI +import Lottie +import NukeUI + +/// 배경/카라비너 Lottie 아이템 표시용 뷰 +/// - LottieView와 달리 캐시 경로를 파라미터로 받아 범용 사용 +/// - Lottie 로드 실패 시 fallbackImageURL로 정적 이미지 표시 +struct LottieItemView: UIViewRepresentable { + let assetId: String + let directory: String + let loopMode: LottieLoopMode + let contentMode: UIView.ContentMode + + private let animationView = LottieAnimationView() + + init( + assetId: String, + directory: String, + loopMode: LottieLoopMode = .loop, + contentMode: UIView.ContentMode = .scaleAspectFill + ) { + self.assetId = assetId + self.directory = directory + self.loopMode = loopMode + self.contentMode = contentMode + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.clipsToBounds = true + + if let animation = findAnimation() { + animationView.animation = animation + animationView.contentMode = contentMode + animationView.loopMode = loopMode + animationView.play() + } + + view.addSubview(animationView) + animationView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + animationView.widthAnchor.constraint(equalTo: view.widthAnchor), + animationView.heightAnchor.constraint(equalTo: view.heightAnchor) + ]) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + /// 캐시 디렉토리에서 Lottie 애니메이션 찾기 + private func findAnimation() -> LottieAnimation? { + let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let cachedURL = cacheDirectory.appendingPathComponent("\(directory)/\(assetId).json") + + guard FileManager.default.fileExists(atPath: cachedURL.path) else { return nil } + return LottieAnimation.filepath(cachedURL.path) + } +} diff --git a/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift b/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift new file mode 100644 index 000000000..d09edd072 --- /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) + + // Lottie 애니메이션 캐시 클리어 (새 파일 로드를 위해) + LottieAnimationCache.shared?.clearCache() + + 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) + } +} From 5d24cc0d0d200e1ed6770f0168c91f64bdeff4c8 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 21:18:19 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20BackgroundCell/CarabinerCell?= =?UTF-8?q?=EC=97=90=20Lottie=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isLottie 체크 후 LottieItemView 또는 기존 LazyImage 분기 --- .../Bundle/Views/Shared/BackgroundCell.swift | 46 ++++++++++----- .../Bundle/Views/Shared/CarabinerCell.swift | 58 ++++++++++++------- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift index a1654583c..82616db35 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift @@ -15,24 +15,38 @@ struct BackgroundCell: View { 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) + // 배경 이미지 또는 Lottie + if background.background.isLottie, let bgId = background.background.id { + LottieItemView( + assetId: bgId, + directory: "lottie_backgrounds" + ) + .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelected ? .main500 : .clear, lineWidth: 2) + ) + } 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) + } } + .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelected ? .main500 : .clear, lineWidth: 2) + ) } - .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) - .background(.white100) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelected ? .main500 : .clear, lineWidth: 2) - ) VStack { HStack { // 유료 아이콘 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift index 2531edaa2..ff6e7c035 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift @@ -15,29 +15,45 @@ struct CarabinerCell: View { 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)) + // 카라비너 이미지 또는 Lottie + if carabiner.carabiner.isLottie, let carabinerId = carabiner.carabiner.id { + LottieItemView( + assetId: carabinerId, + directory: "lottie_carabiners_back", + contentMode: .scaleAspectFit + ) + .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) + ) + } 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)) + } } + .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) + ) } - .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 { From e2b7fc0b2e6fafc71e4ef9aa09581fc5128f812e Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 21:20:50 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20SpriteKit=20=EC=94=AC=EC=97=90=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD/=EC=B9=B4=EB=9D=BC=EB=B9=84=EB=84=88=20Lotti?= =?UTF-8?q?e=20=ED=94=84=EB=A6=AC=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultiKeyringScene: Lottie→SKTexture 프리렌더링, SKAction.animate 반복 재생 - 비디오 모드에서는 프레임 인덱스 기반 수동 텍스처 교체 - MultiKeyringSceneView: carabinerLottieId 파라미터 전달 --- .../Scene/MultiKeyringScene.swift | 205 +++++++++++++++++- .../View/MultiKeyringSceneView.swift | 19 +- 2 files changed, 215 insertions(+), 9 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift index f52931806..71b498a71 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 { @@ -85,6 +86,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 +101,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 +124,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 +138,7 @@ class MultiKeyringScene: SKScene { self.carabinerX = carabinerX self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth + self.carabinerLottieId = carabinerLottieId super.init(size: .zero) } @@ -160,6 +169,10 @@ class MultiKeyringScene: SKScene { carabinerFrontReady = false didStartKeyringSetup = false + // Lottie 프리렌더링 텍스처 해제 + carabinerBackTextures = nil + carabinerFrontTextures = nil + // 모든 물리 조인트 제거 physicsWorld.removeAllJoints() @@ -202,12 +215,19 @@ 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 { + // Lottie 카라비너 로드 (프리렌더링 포함) + async let backLoaded: Void = self.loadCarabinerBackLottie(id: lottieId) + async let frontLoaded: Void = self.loadCarabinerFrontLottie(id: lottieId) + await backLoaded + await frontLoaded + } 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 +373,177 @@ class MultiKeyringScene: SKScene { } } + // MARK: - Carabiner Lottie Setup + + /// 카라비너 뒷면 Lottie 로드 및 프리렌더링 + /// - LottieItemManager 캐시에서 애니메이션 로드 → 전체 프레임을 SKTexture 배열로 프리렌더링 + /// - SKAction.animate()로 반복 재생하여 SpriteKit 내에서 Lottie 효과 구현 + private func loadCarabinerBackLottie(id: String) async { + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp else { + self?.carabinerBackReady = true + return + } + + // 캐시에서 Lottie 애니메이션 로드 + guard let animation = LottieItemManager.shared.loadCarabinerBackAnimation(id: id) else { + self.carabinerBackReady = true + return + } + + // 카라비너 크기 계산 (Lottie 원본 비율 유지) + let aspectRatio = animation.size.height / animation.size.width + let nodeWidth = self.carabinerWidth + let nodeHeight = nodeWidth * aspectRatio + let renderSize = CGSize(width: nodeWidth, height: nodeHeight) + + // 모든 프레임 프리렌더링 → SKTexture 배열 + let textures = self.preRenderLottieFrames(animation: animation, size: renderSize) + guard !textures.isEmpty else { + self.carabinerBackReady = true + return + } + + // 비디오 생성용 텍스처 저장 + self.carabinerBackTextures = textures + self.carabinerLottieFPS = animation.framerate + + // 첫 프레임으로 노드 생성 + let carabinerNode = SKSpriteNode(texture: textures[0]) + carabinerNode.size = CGSize(width: nodeWidth, height: nodeHeight) + + // 위치 계산 (기존 정적 이미지와 동일 로직) + let centerX = self.carabinerX + nodeWidth / 2 + let centerY = self.carabinerY + nodeHeight / 2 + let spriteKitY = self.size.height - centerY + carabinerNode.position = CGPoint(x: centerX, y: spriteKitY) + carabinerNode.zPosition = -900 + + // 실시간 모드에서만 SKAction 재생 (비디오 모드에서는 수동 텍스처 교체) + if !self.disableShadows { + let timePerFrame = 1.0 / animation.framerate + let animate = SKAction.animate(with: textures, timePerFrame: timePerFrame) + carabinerNode.run(SKAction.repeatForever(animate)) + } + + self.addChild(carabinerNode) + self.addShadowToNode(carabinerNode, offsetX: 2, offsetY: -3, blurRadius: 1.0) + self.carabinerBackNode = carabinerNode + self.carabinerBackReady = true + } + } + + /// 카라비너 앞면 Lottie 로드 및 프리렌더링 (hamburger 타입 전용) + /// - 앞면이 캐시에 없으면 (plain 타입) 바로 ready 처리 + private func loadCarabinerFrontLottie(id: String) async { + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp else { + self?.carabinerFrontReady = true + return + } + + // 캐시에서 앞면 Lottie 로드 (plain 타입은 앞면이 없으므로 nil) + guard let animation = LottieItemManager.shared.loadCarabinerFrontAnimation(id: id) else { + self.carabinerFrontReady = true + return + } + + // 카라비너 크기 계산 (뒷면과 동일한 비율) + let aspectRatio = animation.size.height / animation.size.width + let nodeWidth = self.carabinerWidth + let nodeHeight = nodeWidth * aspectRatio + let renderSize = CGSize(width: nodeWidth, height: nodeHeight) + + // 모든 프레임 프리렌더링 + let textures = self.preRenderLottieFrames(animation: animation, size: renderSize) + guard !textures.isEmpty else { + self.carabinerFrontReady = true + return + } + + // 비디오 생성용 텍스처 저장 + self.carabinerFrontTextures = textures + + // 첫 프레임으로 노드 생성 + let carabinerNode = SKSpriteNode(texture: textures[0]) + carabinerNode.size = CGSize(width: nodeWidth, height: nodeHeight) + + // 위치 계산 + let centerX = self.carabinerX + nodeWidth / 2 + let centerY = self.carabinerY + nodeHeight / 2 + let spriteKitY = self.size.height - centerY + carabinerNode.position = CGPoint(x: centerX, y: spriteKitY) + carabinerNode.zPosition = -800 // 뒷면(-900)과 키링들(0~) 사이 + + // 실시간 모드에서만 SKAction 재생 + if !self.disableShadows { + let timePerFrame = 1.0 / animation.framerate + let animate = SKAction.animate(with: textures, timePerFrame: timePerFrame) + carabinerNode.run(SKAction.repeatForever(animate)) + } + + self.addChild(carabinerNode) + self.carabinerFrontNode = carabinerNode + self.carabinerFrontReady = true + } + } + + /// 카라비너 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] + } + } + + /// Lottie 애니메이션의 모든 프레임을 SKTexture 배열로 프리렌더링 + /// - BundleVideoGenerator+Particle.swift의 preRenderAllFrames() 패턴 재활용 + /// - SpriteKit은 Lottie를 네이티브 지원하지 않으므로, 각 프레임을 이미지로 렌더링 후 텍스처 변환 + private func preRenderLottieFrames( + animation: LottieAnimation, + size: CGSize, + scale: CGFloat = 2.0 + ) -> [SKTexture] { + // 렌더링 스케일 지정 (기본 2x, 메모리와 품질의 균형) + let format = UIGraphicsImageRendererFormat() + format.scale = scale + + // mainThread 렌더링 엔진 사용 (프레임 단위 수동 제어에 필수) + let config = LottieConfiguration(renderingEngine: .mainThread) + let lottieView = LottieAnimationView(animation: animation, configuration: config) + lottieView.frame = CGRect(origin: .zero, size: size) + lottieView.contentMode = .scaleAspectFit + + let totalFrames = Int(animation.endFrame - animation.startFrame) + var textures: [SKTexture] = [] + textures.reserveCapacity(totalFrames) + + let imageRenderer = UIGraphicsImageRenderer(bounds: lottieView.bounds, format: format) + + for frameOffset in 0.. Date: Wed, 11 Feb 2026 21:21:36 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20BundleVideoGenerator=EC=97=90=20L?= =?UTF-8?q?ottie=20=EB=B0=B0=EA=B2=BD/=EC=B9=B4=EB=9D=BC=EB=B9=84=EB=84=88?= =?UTF-8?q?=20=EC=98=81=EC=83=81=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generateVideo()에 backgroundLottieId, carabinerLottieId 파라미터 추가 - 배경 Lottie 프리렌더링 및 프레임별 텍스처 교체 - preRenderAllFrames() 접근제어를 internal로 변경하여 공용화 --- .../BundleVideoGenerator+Particle.swift | 4 +-- .../BundleVideoGenerator+Rendering.swift | 12 ++++++++ .../Bundle/BundleVideoGenerator+Setup.swift | 29 +++++++++++++++++-- .../Video/Bundle/BundleVideoGenerator.swift | 11 +++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Particle.swift b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Particle.swift index 06d8c139e..afc0c6e92 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 502cde6be..e917aff4f 100644 --- a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift +++ b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift @@ -27,6 +27,8 @@ extension BundleVideoGenerator { for frameIndex in 0.. Date: Wed, 11 Feb 2026 21:22:33 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EB=B7=B0?= =?UTF-8?q?=EC=97=90=20Lottie=20ID=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC=20=EB=B0=8F=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BundleEditView/CreateView/DetailView: backgroundLottieId, carabinerLottieId 전달 - VideoGen 확장: generateVideo() 호출에 Lottie ID 전달 - BundleViewModel+Fetch: 배경/카라비너 fetch 시 Lottie JSON 프리다운로드 --- .../ViewModels/BundleViewModel+Fetch.swift | 20 +++++++++++++++++++ .../BundleCompleteView+VideoGen.swift | 4 ++++ .../Views/Create/BundleCreateView.swift | 2 ++ .../Detail/BundleDetailView+VideoGen.swift | 4 ++++ .../Views/Detail/BundleDetailView.swift | 2 ++ .../Bundle/Views/Edit/BundleEditView.swift | 2 ++ 6 files changed, 34 insertions(+) diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Fetch.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Fetch.swift index 717292457..a33ff0ee5 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Fetch.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Fetch.swift @@ -67,6 +67,16 @@ extension BundleViewModel { BackgroundViewData(background: bg, isOwned: ownedIds.contains(bg.id ?? "")) } + // Lottie 배경 JSON 다운로드 (백그라운드에서 비동기 실행) + let lottieBackgrounds = items.filter { $0.isLottie } + if !lottieBackgrounds.isEmpty { + Task { + for bg in lottieBackgrounds { + await LottieItemManager.shared.downloadBackgroundLottie(bg) + } + } + } + await MainActor.run { self.backgroundViewData = decorated self.isLoading = false @@ -88,6 +98,16 @@ extension BundleViewModel { CarabinerViewData(carabiner: cb, isOwned: ownedIds.contains(cb.id ?? "")) } + // Lottie 카라비너 JSON 다운로드 (백그라운드에서 비동기 실행) + let lottieCarabiners = items.filter { $0.isLottie } + if !lottieCarabiners.isEmpty { + Task { + for cb in lottieCarabiners { + await LottieItemManager.shared.downloadCarabinerLottie(cb) + } + } + } + await MainActor.run { self.carabinerViewData = decorated self.isLoading = false diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift index d4ec3794c..1e5e3ec70 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift @@ -32,8 +32,10 @@ extension BundleCompleteView { 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, @@ -74,8 +76,10 @@ extension BundleCompleteView { 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/Create/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift index 5f773922d..4ada1f743 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift @@ -73,8 +73,10 @@ struct BundleCreateView: 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, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView+VideoGen.swift index 531a68141..3c4fca9ef 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 9680370bc..134060fa2 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift @@ -73,8 +73,10 @@ 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, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift index c9e9e96e5..a069fbd33 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift @@ -183,8 +183,10 @@ 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, From a312cc3daca439f57c6673d01784c95668a40afc Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 21:23:03 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20=EA=B3=B5=EB=B0=A9=20=ED=83=AD=20?= =?UTF-8?q?Lottie=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 8 +++++ .../ViewModels/WorkshopViewModel.swift | 32 +++++++++++++++++++ .../Views/Components/WorkshopItemCard.swift | 11 +++++++ .../Views/Main/WorkshopItemDetailView.swift | 18 ++++++++++- 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 258a72e66..e601a6be0 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/Presentation/Workshop/ViewModels/WorkshopViewModel.swift b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift index 39a9222e1..ee1b9cebc 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 c470f0ea3..5120659fd 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 e1999db63..d6e392cce 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift @@ -182,7 +182,23 @@ 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") + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: getBottomPadding(5) == 0 ? 501 : 380) + .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) + .scaledToFit() + .aspectRatio(1, contentMode: .fit) + .cornerRadius(20) + } else if item is Background { ItemDetailImage(itemURL: getPreviewURL()) .scaledToFill() .frame(maxWidth: .infinity, maxHeight: getBottomPadding(5) == 0 ? 501 : 380) From 2e99be9948eb6fcfb4855f068f5d01b441741a9d Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 22:22:17 +0900 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20=EB=B2=88=EB=93=A4=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20->=20=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0?= =?UTF-8?q?=20=ED=83=AD=EB=B0=94=20=EC=82=AC=EB=9D=BC=EC=A7=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bundle/Views/Create/BundleCreateView.swift | 1 + .../Workshop/Views/Main/WorkshopItemDetailView.swift | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift index 4ada1f743..44ae1edf4 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift @@ -176,6 +176,7 @@ extension BundleCreateView { var customNavigationBar: some View { CustomNavigationBar { BackToolbarButton { + TabBarManager.show() router.pop() } } center: { diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift index d6e392cce..40c7a7838 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift @@ -185,8 +185,9 @@ extension WorkshopItemDetailView { if let background = item as? Background, background.isLottie, let bgId = background.id { // Lottie 배경 LottieItemView(assetId: bgId, directory: "lottie_backgrounds") - .scaledToFill() - .frame(maxWidth: .infinity, maxHeight: getBottomPadding(5) == 0 ? 501 : 380) + .frame(height: getBottomPadding(5) == 0 ? 501 : 380) + .frame(maxWidth: .infinity) + .clipped() .cornerRadius(20) .overlay( RoundedRectangle(cornerRadius: 20) @@ -195,8 +196,8 @@ extension WorkshopItemDetailView { } else if let carabiner = item as? Carabiner, carabiner.isLottie, let cbId = carabiner.id { // Lottie 카라비너 LottieItemView(assetId: cbId, directory: "lottie_carabiners_back", contentMode: .scaleAspectFit) - .scaledToFit() .aspectRatio(1, contentMode: .fit) + .clipped() .cornerRadius(20) } else if item is Background { ItemDetailImage(itemURL: getPreviewURL()) From 3bbb1b8b66ab440b7cdd8b0769d08573fa7a8640 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 11 Feb 2026 22:33:57 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=ED=99=88=20=EB=8C=80=ED=91=9C?= =?UTF-8?q?=EB=AD=89=EC=B9=98=20=EB=B0=8F=20=EB=AD=89=EC=B9=98=EC=99=84?= =?UTF-8?q?=EC=84=B1=EB=B7=B0=20Lottie=20=EC=A7=80=EC=9B=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bundle/Views/Complete/BundleCompleteView.swift | 2 ++ .../ViewModels/CollectionViewModel.swift | 14 ++++++++++++++ .../Presentation/Home/Views/Main/HomeView.swift | 2 ++ 3 files changed, 18 insertions(+) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift index 2d653270a..1543541c9 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift @@ -93,8 +93,10 @@ extension BundleCompleteView { 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, diff --git a/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift b/Keychy/Keychy/Presentation/Collection/ViewModels/CollectionViewModel.swift index 199683213..7dcadb747 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/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index da6446b69..091a4cbc2 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -52,8 +52,10 @@ struct HomeView: 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, From 57975e1a99eafd001bddcd26562507993c4a6c3e Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 19:38:16 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20LottieItemView=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=A1=9C=EB=94=A9=20+=20NSCache=20?= =?UTF-8?q?=EC=9D=B8=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Components/View/LottieItemView.swift | 115 ++++++++++++++---- .../Core/Components/View/LottieView.swift | 39 ++++-- .../Core/FileManagers/LottieItemManager.swift | 4 +- 3 files changed, 120 insertions(+), 38 deletions(-) diff --git a/Keychy/Keychy/Core/Components/View/LottieItemView.swift b/Keychy/Keychy/Core/Components/View/LottieItemView.swift index d84c5c420..b5d76a9b0 100644 --- a/Keychy/Keychy/Core/Components/View/LottieItemView.swift +++ b/Keychy/Keychy/Core/Components/View/LottieItemView.swift @@ -7,18 +7,28 @@ import SwiftUI import Lottie -import NukeUI /// 배경/카라비너 Lottie 아이템 표시용 뷰 -/// - LottieView와 달리 캐시 경로를 파라미터로 받아 범용 사용 -/// - Lottie 로드 실패 시 fallbackImageURL로 정적 이미지 표시 +/// - JSON 파싱을 백그라운드 스레드에서 비동기 수행 (메인 스레드 블로킹 방지) +/// - NSCache 기반 인메모리 캐시로 동일 에셋 재파싱 방지 +/// - Coordinator 패턴으로 LottieAnimationView 생명주기 관리 struct LottieItemView: UIViewRepresentable { let assetId: String let directory: String let loopMode: LottieLoopMode let contentMode: UIView.ContentMode - private let animationView = LottieAnimationView() + // 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, @@ -32,34 +42,93 @@ struct LottieItemView: UIViewRepresentable { 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 view = UIView(frame: .zero) - view.clipsToBounds = true - - if let animation = findAnimation() { - animationView.animation = animation - animationView.contentMode = contentMode - animationView.loopMode = loopMode - animationView.play() - } + let container = UIView(frame: .zero) + container.clipsToBounds = true + + let animationView = LottieAnimationView() + animationView.contentMode = contentMode + context.coordinator.animationView = animationView - 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 + + // 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) {} - /// 캐시 디렉토리에서 Lottie 애니메이션 찾기 - private func findAnimation() -> LottieAnimation? { - let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let cachedURL = cacheDirectory.appendingPathComponent("\(directory)/\(assetId).json") + /// 뷰 제거 시 비동기 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 + } - guard FileManager.default.fileExists(atPath: cachedURL.path) else { return nil } - return LottieAnimation.filepath(cachedURL.path) + // 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 b8cff1c04..9a62c0dd5 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 index d09edd072..e4904e054 100644 --- a/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift +++ b/Keychy/Keychy/Core/FileManagers/LottieItemManager.swift @@ -167,8 +167,8 @@ class LottieItemManager { // 캐시 URL 저장 (다음 검증용) saveCacheURL(id: id, url: remoteURL, type: type) - // Lottie 애니메이션 캐시 클리어 (새 파일 로드를 위해) - LottieAnimationCache.shared?.clearCache() + // 해당 에셋의 인메모리 캐시만 무효화 (전체 캐시 삭제 X) + LottieItemView.invalidateCache(assetId: id, directory: type.rawValue) downloadingItemIds.remove(downloadKey) downloadProgress.removeValue(forKey: downloadKey) From b5613763123d6b231ac6353e9bbf1c2b767ca08c Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 19:38:34 +0900 Subject: [PATCH 11/21] =?UTF-8?q?chore:=20DataInitializer=20-=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD/=EC=B9=B4=EB=9D=BC=EB=B9=84=EB=84=88=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=BD=EC=9E=85=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Firebase/DataInitializer.swift | 160 ++++++++++++++++-- 1 file changed, 146 insertions(+), 14 deletions(-) diff --git a/Keychy/Keychy/Core/Firebase/DataInitializer.swift b/Keychy/Keychy/Core/Firebase/DataInitializer.swift index f30f88089..393ad0595 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 ] ] From 734500a53721c13b3f5751591b0750fe28e30300 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 19:38:45 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20Lottie=20=EC=B9=B4=EB=9D=BC?= =?UTF-8?q?=EB=B9=84=EB=84=88=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bundle/Views/Edit/BundleEditView+Alert.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift index c40e45293..ba0769e23 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) 빈 상태를 씬/리스트에 반영 From 2f9b2314333fa28b9cc2e8434024d2bf08ec67dd Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 19:39:02 +0900 Subject: [PATCH 13/21] =?UTF-8?q?refactor:=20=EC=B9=B4=EB=9D=BC=EB=B9=84?= =?UTF-8?q?=EB=84=88=20Lottie=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20=EB=8D=B0=EB=93=9C=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scene/MultiKeyringScene.swift | 304 ++++++++++++------ .../View/MultiKeyringSceneView.swift | 12 +- .../Bundle/Views/Shared/BackgroundCell.swift | 51 ++- .../Bundle/Views/Shared/CarabinerCell.swift | 60 ++-- 4 files changed, 260 insertions(+), 167 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift index 71b498a71..f10ee9e72 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift @@ -60,7 +60,6 @@ class MultiKeyringScene: SKScene { // MARK: - 씬 준비 완료 콜백 var onEngineReady: (() -> Void)? // 엔진 준비 완료 콜백 - var onKeyringVisualReady: (() -> Void)? // 키링이 진짜 시각적으로 보이는지 콜백 var onSetupComplete: (() -> Void)? // 모든 키링 로드 완료 및 물리 활성화 완료 콜백 // MARK: - 키링 로드 완료 추적 private var totalKeyringsToLoad = 0 // 로드해야 할 총 키링 수 @@ -217,8 +216,8 @@ class MultiKeyringScene: SKScene { if let lottieId = self.carabinerLottieId { // Lottie 카라비너 로드 (프리렌더링 포함) - async let backLoaded: Void = self.loadCarabinerBackLottie(id: lottieId) - async let frontLoaded: Void = self.loadCarabinerFrontLottie(id: lottieId) + 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 { @@ -375,116 +374,158 @@ class MultiKeyringScene: SKScene { // MARK: - Carabiner Lottie Setup - /// 카라비너 뒷면 Lottie 로드 및 프리렌더링 - /// - LottieItemManager 캐시에서 애니메이션 로드 → 전체 프레임을 SKTexture 배열로 프리렌더링 - /// - SKAction.animate()로 반복 재생하여 SpriteKit 내에서 Lottie 효과 구현 - private func loadCarabinerBackLottie(id: String) async { - await MainActor.run { [weak self] in - guard let self, !self.isCleaningUp else { - self?.carabinerBackReady = true - return - } + /// 카라비너 Lottie의 앞/뒤 구분 + private enum CarabinerSide { + case back, front - // 캐시에서 Lottie 애니메이션 로드 - guard let animation = LottieItemManager.shared.loadCarabinerBackAnimation(id: id) else { - self.carabinerBackReady = true - return + var cacheDirectory: String { + switch self { + case .back: "lottie_carabiners_back" + case .front: "lottie_carabiners_front" } + } - // 카라비너 크기 계산 (Lottie 원본 비율 유지) - let aspectRatio = animation.size.height / animation.size.width - let nodeWidth = self.carabinerWidth - let nodeHeight = nodeWidth * aspectRatio - let renderSize = CGSize(width: nodeWidth, height: nodeHeight) - - // 모든 프레임 프리렌더링 → SKTexture 배열 - let textures = self.preRenderLottieFrames(animation: animation, size: renderSize) - guard !textures.isEmpty else { - self.carabinerBackReady = true - return + var zPosition: CGFloat { + switch self { + case .back: -900 + case .front: -800 } + } - // 비디오 생성용 텍스처 저장 - self.carabinerBackTextures = textures - self.carabinerLottieFPS = animation.framerate + var hasShadow: Bool { self == .back } + } - // 첫 프레임으로 노드 생성 - let carabinerNode = SKSpriteNode(texture: textures[0]) - carabinerNode.size = CGSize(width: nodeWidth, height: nodeHeight) + /// 카라비너 Lottie 로드 및 프리렌더링 (back/front 공통) + /// - 실시간 모드: 첫 프레임만 즉시 렌더링 → ready → 나머지 프레임 백그라운드 프리렌더링 → 애니메이션 시작 + /// - 비디오 모드(disableShadows): 전체 프레임 프리렌더링 완료 후 ready (프레임 인덱스 기반 수동 교체 필요) + private func loadCarabinerLottie(side: CarabinerSide, id: String) async { + let carabinerWidth = self.carabinerWidth + let isVideoMode = self.disableShadows + + // 1. 백그라운드: JSON 파싱 + let cacheDirectory = side.cacheDirectory + let parsed = await Task.detached(priority: .userInitiated) { + () -> (animation: LottieAnimation, aspectRatio: CGFloat)? 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 } + return (animation, animation.size.height / animation.size.width) + }.value + + guard let parsed else { + await MainActor.run { [weak self] in self?.setCarabinerReady(side: side) } + return + } - // 위치 계산 (기존 정적 이미지와 동일 로직) - let centerX = self.carabinerX + nodeWidth / 2 - let centerY = self.carabinerY + nodeHeight / 2 - let spriteKitY = self.size.height - centerY - carabinerNode.position = CGPoint(x: centerX, y: spriteKitY) - carabinerNode.zPosition = -900 + let nodeHeight = carabinerWidth * parsed.aspectRatio + let renderSize = CGSize(width: carabinerWidth, height: nodeHeight) - // 실시간 모드에서만 SKAction 재생 (비디오 모드에서는 수동 텍스처 교체) - if !self.disableShadows { - let timePerFrame = 1.0 / animation.framerate - let animate = SKAction.animate(with: textures, timePerFrame: timePerFrame) - carabinerNode.run(SKAction.repeatForever(animate)) + if isVideoMode { + // 비디오 모드: 전체 프리렌더링 후 ready (updateCarabinerLottieTexture에 전체 텍스처 필요) + let textures = await Task.detached(priority: .userInitiated) { + MultiKeyringScene.preRenderLottieFrames(animation: parsed.animation, size: renderSize) + }.value + + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp, !textures.isEmpty else { + self?.setCarabinerReady(side: side) + return + } + self.setCarabinerTextures(side: side, textures: textures) + self.carabinerLottieFPS = parsed.animation.framerate + let node = SKSpriteNode(texture: textures[0]) + node.size = renderSize + let spriteKitY = self.size.height - (self.carabinerY + nodeHeight / 2) + node.position = CGPoint(x: self.carabinerX + carabinerWidth / 2, y: spriteKitY) + node.zPosition = side.zPosition + self.addChild(node) + if side.hasShadow { + self.addShadowToNode(node, offsetX: 2, offsetY: -3, blurRadius: 1.0) + } + self.setCarabinerNode(side: side, node: node) + self.setCarabinerReady(side: side) } + } else { + // 실시간 모드: 첫 프레임 즉시 표시 → ready → 백그라운드에서 전체 프리렌더링 → 애니메이션 시작 + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp else { + self?.setCarabinerReady(side: side) + return + } - self.addChild(carabinerNode) - self.addShadowToNode(carabinerNode, offsetX: 2, offsetY: -3, blurRadius: 1.0) - self.carabinerBackNode = carabinerNode - self.carabinerBackReady = true - } - } + let firstTexture = Self.renderSingleFrame( + animation: parsed.animation, + frame: parsed.animation.startFrame, + size: renderSize + ) + guard let firstTexture else { + self.setCarabinerReady(side: side) + return + } - /// 카라비너 앞면 Lottie 로드 및 프리렌더링 (hamburger 타입 전용) - /// - 앞면이 캐시에 없으면 (plain 타입) 바로 ready 처리 - private func loadCarabinerFrontLottie(id: String) async { - await MainActor.run { [weak self] in - guard let self, !self.isCleaningUp else { - self?.carabinerFrontReady = true - return - } + self.carabinerLottieFPS = parsed.animation.framerate - // 캐시에서 앞면 Lottie 로드 (plain 타입은 앞면이 없으므로 nil) - guard let animation = LottieItemManager.shared.loadCarabinerFrontAnimation(id: id) else { - self.carabinerFrontReady = true - return + let node = SKSpriteNode(texture: firstTexture) + node.size = renderSize + let spriteKitY = self.size.height - (self.carabinerY + nodeHeight / 2) + node.position = CGPoint(x: self.carabinerX + carabinerWidth / 2, y: spriteKitY) + node.zPosition = side.zPosition + + self.addChild(node) + if side.hasShadow { + self.addShadowToNode(node, offsetX: 2, offsetY: -3, blurRadius: 1.0) + } + self.setCarabinerNode(side: side, node: node) + self.setCarabinerReady(side: side) } - // 카라비너 크기 계산 (뒷면과 동일한 비율) - let aspectRatio = animation.size.height / animation.size.width - let nodeWidth = self.carabinerWidth - let nodeHeight = nodeWidth * aspectRatio - let renderSize = CGSize(width: nodeWidth, height: nodeHeight) + // 메인에서 청크 단위 프리렌더링 (Task.yield()로 UI 양보하며 진행) + let allTextures = await Self.preRenderLottieFramesAsync( + animation: parsed.animation, + size: renderSize + ) - // 모든 프레임 프리렌더링 - let textures = self.preRenderLottieFrames(animation: animation, size: renderSize) - guard !textures.isEmpty else { - self.carabinerFrontReady = true - return + // 전체 텍스처 저장 + SKAction 애니메이션 시작 + // MainActor에서 실행해야 SpriteKit 노드 조작 가능 + // run(repeatForever)는 동기 버전 사용 (async 버전은 영원히 리턴하지 않음) + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp, !allTextures.isEmpty else { return } + self.setCarabinerTextures(side: side, textures: allTextures) + let timePerFrame = 1.0 / parsed.animation.framerate + let animate = SKAction.animate(with: allTextures, timePerFrame: timePerFrame) + self.carabinerNode(for: side)?.run(SKAction.repeatForever(animate)) } + } + } - // 비디오 생성용 텍스처 저장 - self.carabinerFrontTextures = textures + // MARK: - CarabinerSide 헬퍼 (프로퍼티 분기) - // 첫 프레임으로 노드 생성 - let carabinerNode = SKSpriteNode(texture: textures[0]) - carabinerNode.size = CGSize(width: nodeWidth, height: nodeHeight) + private func setCarabinerReady(side: CarabinerSide) { + switch side { + case .back: carabinerBackReady = true + case .front: carabinerFrontReady = true + } + } - // 위치 계산 - let centerX = self.carabinerX + nodeWidth / 2 - let centerY = self.carabinerY + nodeHeight / 2 - let spriteKitY = self.size.height - centerY - carabinerNode.position = CGPoint(x: centerX, y: spriteKitY) - carabinerNode.zPosition = -800 // 뒷면(-900)과 키링들(0~) 사이 + private func setCarabinerTextures(side: CarabinerSide, textures: [SKTexture]) { + switch side { + case .back: carabinerBackTextures = textures + case .front: carabinerFrontTextures = textures + } + } - // 실시간 모드에서만 SKAction 재생 - if !self.disableShadows { - let timePerFrame = 1.0 / animation.framerate - let animate = SKAction.animate(with: textures, timePerFrame: timePerFrame) - carabinerNode.run(SKAction.repeatForever(animate)) - } + private func setCarabinerNode(side: CarabinerSide, node: SKSpriteNode) { + switch side { + case .back: carabinerBackNode = node + case .front: carabinerFrontNode = node + } + } - self.addChild(carabinerNode) - self.carabinerFrontNode = carabinerNode - self.carabinerFrontReady = true + private func carabinerNode(for side: CarabinerSide) -> SKSpriteNode? { + switch side { + case .back: carabinerBackNode + case .front: carabinerFrontNode } } @@ -505,19 +546,39 @@ class MultiKeyringScene: SKScene { } } - /// Lottie 애니메이션의 모든 프레임을 SKTexture 배열로 프리렌더링 - /// - BundleVideoGenerator+Particle.swift의 preRenderAllFrames() 패턴 재활용 - /// - SpriteKit은 Lottie를 네이티브 지원하지 않으므로, 각 프레임을 이미지로 렌더링 후 텍스처 변환 - private func preRenderLottieFrames( + /// 단일 프레임만 렌더링하여 SKTexture 반환 (첫 프레임 즉시 표시용) + /// - 전체 프리렌더링 대비 1/N 비용으로 즉시 화면에 카라비너를 표시할 수 있음 + static func renderSingleFrame( animation: LottieAnimation, + frame: CGFloat, size: CGSize, scale: CGFloat = 2.0 - ) -> [SKTexture] { - // 렌더링 스케일 지정 (기본 2x, 메모리와 품질의 균형) + ) -> SKTexture? { let format = UIGraphicsImageRendererFormat() format.scale = scale + let config = LottieConfiguration(renderingEngine: .mainThread) + let lottieView = LottieAnimationView(animation: animation, configuration: config) + lottieView.frame = CGRect(origin: .zero, size: size) + lottieView.contentMode = .scaleAspectFit + lottieView.currentFrame = AnimationFrameTime(frame) + 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) + } - // mainThread 렌더링 엔진 사용 (프레임 단위 수동 제어에 필수) + /// [비디오 전용] 모든 프레임을 동기적으로 프리렌더링 (비디오 생성 시 사용) + static func preRenderLottieFrames( + animation: LottieAnimation, + size: CGSize, + scale: CGFloat = 2.0 + ) -> [SKTexture] { + let format = UIGraphicsImageRendererFormat() + format.scale = scale let config = LottieConfiguration(renderingEngine: .mainThread) let lottieView = LottieAnimationView(animation: animation, configuration: config) lottieView.frame = CGRect(origin: .zero, size: size) @@ -544,6 +605,50 @@ class MultiKeyringScene: SKScene { return textures } + /// [실시간 전용] 청크 단위로 프레임 프리렌더링 (UI 블로킹 방지) + /// - Lottie의 .mainThread 엔진은 UIKit API를 사용하므로 반드시 메인 스레드에서 실행해야 함 + /// - Task.yield()로 청크 사이에 메인 런루프에 양보하여 터치/애니메이션 이벤트 처리 허용 + /// - chunkSize: 한 번에 렌더링할 프레임 수 (작을수록 UI 반응성↑, 전체 소요시간↑) + @MainActor + static func preRenderLottieFramesAsync( + animation: LottieAnimation, + size: CGSize, + scale: CGFloat = 2.0, + chunkSize: Int = 3 + ) async -> [SKTexture] { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + let config = LottieConfiguration(renderingEngine: .mainThread) + let lottieView = LottieAnimationView(animation: animation, configuration: config) + lottieView.frame = CGRect(origin: .zero, size: size) + lottieView.contentMode = .scaleAspectFit + + let totalFrames = Int(animation.endFrame - animation.startFrame) + var textures: [SKTexture] = [] + textures.reserveCapacity(totalFrames) + + let imageRenderer = UIGraphicsImageRenderer(bounds: lottieView.bounds, format: format) + + for chunkStart in stride(from: 0, to: totalFrames, by: chunkSize) { + let chunkEnd = min(chunkStart + chunkSize, totalFrames) + for frameOffset in chunkStart.. Date: Thu, 12 Feb 2026 19:39:12 +0900 Subject: [PATCH 14/21] =?UTF-8?q?fix:=20=EB=B0=B0=EA=B2=BD/=EC=B9=B4?= =?UTF-8?q?=EB=9D=BC=EB=B9=84=EB=84=88=20=EC=84=A0=ED=83=9D=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=EC=85=80=20=EC=9C=84=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=20=EC=95=88=20=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Create/BundleCreateView+SelectSheet.swift | 1 + .../Bundle/Views/Create/BundleCreateView.swift | 17 +++++++++-------- .../Views/Shared/SelectBackgroundSheet.swift | 18 ++++++++++-------- .../Views/Shared/SelectCarabinerSheet.swift | 16 +++++++++------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift index 340060f8b..7715e2fbe 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift @@ -93,6 +93,7 @@ extension BundleCreateView { viewModel: bundleVM, selectedCarabiner: bundleVM.newSelectedCarabiner, onCarabinerTap: { carabiner in + if carabiner.carabiner.isLottie { isSceneReady = false } bundleVM.newSelectedCarabiner = carabiner } ) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift index 44ae1edf4..c9672eca5 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift @@ -82,15 +82,9 @@ struct BundleCreateView: View { carabinerY: cb.carabiner.carabinerY, carabinerWidth: cb.carabiner.carabinerWidth, currentCarabinerType: cb.carabiner.type, - onBackgroundLoaded: { - // 키링이 없으면 배경 로드 시 바로 준비 완료 - if selectedKeyrings.isEmpty { - withAnimation(.easeOut(duration: 0.3)) { - isSceneReady = true - } - } - }, onAllKeyringsReady: { + // onSetupComplete에서 호출됨 + // (카라비너 Lottie 프리렌더링 + 키링 로드 + 물리 활성화 후) withAnimation(.easeOut(duration: 0.3)) { isSceneReady = true } @@ -111,6 +105,13 @@ struct BundleCreateView: View { .blur(radius: showPurchaseSuccessAlert || isCapturing ? 10 : 0) } + // Lottie 씬 로딩 중 (시트 포함 전체 차단) + if !isSceneReady { + Color.black20 + .ignoresSafeArea() + LoadingAlert(type: .longWithKeychy, message: "아이템을 불러오고 있어요") + } + // 캡처 중 로딩 if isCapturing { Color.black20 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift index 4e3935234..72b7fbce6 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), useThumbnail: true) + } + .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 4c4fc3ea2..b986f3ffe 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), useThumbnail: true) + } + .buttonStyle(.plain) } } .padding(.horizontal, 20) From 8699e238ce698cd8be65a16774b91de9815dab41 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 22:33:24 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20Lottie=20=EC=B9=B4=EB=9D=BC?= =?UTF-8?q?=EB=B9=84=EB=84=88=EB=A5=BC=20SpriteKit=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=EC=97=90=EC=84=9C=20SwiftUI=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실시간 뷰에서 Lottie 카라비너 SKTexture 프리렌더링을 제거하고 SwiftUI LottieItemView 오버레이로 대체하여 ~250MB 메모리 절감 및 즉시 로딩. 비디오/캡처 모드는 첫 프레임만 정적 렌더링하여 메모리 크래시 해결. 불필요한 데드코드(preRenderLottieFrames, TextureCacheEntry 등) 제거. --- .../Scene/MultiKeyringScene.swift | 258 ++++-------------- .../View/MultiKeyringSceneView.swift | 90 ++++++ 2 files changed, 147 insertions(+), 201 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift index f10ee9e72..de1f1a0ae 100644 --- a/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift +++ b/Keychy/Keychy/Core/KeyringBundle/Scene/MultiKeyringScene.swift @@ -215,11 +215,19 @@ class MultiKeyringScene: SKScene { guard !self.isCleaningUp else { return } if let lottieId = self.carabinerLottieId { - // Lottie 카라비너 로드 (프리렌더링 포함) - async let backLoaded: Void = self.loadCarabinerLottie(side: .back, id: lottieId) - async let frontLoaded: Void = self.loadCarabinerLottie(side: .front, id: lottieId) - await backLoaded - await frontLoaded + 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() @@ -395,110 +403,68 @@ class MultiKeyringScene: SKScene { var hasShadow: Bool { self == .back } } - /// 카라비너 Lottie 로드 및 프리렌더링 (back/front 공통) - /// - 실시간 모드: 첫 프레임만 즉시 렌더링 → ready → 나머지 프레임 백그라운드 프리렌더링 → 애니메이션 시작 - /// - 비디오 모드(disableShadows): 전체 프레임 프리렌더링 완료 후 ready (프레임 인덱스 기반 수동 교체 필요) + /// [비디오/캡처 전용] 카라비너 Lottie 첫 프레임만 정적 렌더링 + /// - 실시간 모드에서는 호출되지 않음 (SwiftUI LottieItemView 오버레이가 담당) + /// - 전체 프리렌더링 대신 첫 프레임 1장만 렌더링하여 메모리 절약 private func loadCarabinerLottie(side: CarabinerSide, id: String) async { let carabinerWidth = self.carabinerWidth - let isVideoMode = self.disableShadows - // 1. 백그라운드: JSON 파싱 + // 백그라운드: JSON 파싱 + 첫 프레임 렌더링 let cacheDirectory = side.cacheDirectory - let parsed = await Task.detached(priority: .userInitiated) { - () -> (animation: LottieAnimation, aspectRatio: CGFloat)? in + 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 } - return (animation, animation.size.height / animation.size.width) + + 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 parsed else { + guard let result else { await MainActor.run { [weak self] in self?.setCarabinerReady(side: side) } return } - let nodeHeight = carabinerWidth * parsed.aspectRatio - let renderSize = CGSize(width: carabinerWidth, height: nodeHeight) - - if isVideoMode { - // 비디오 모드: 전체 프리렌더링 후 ready (updateCarabinerLottieTexture에 전체 텍스처 필요) - let textures = await Task.detached(priority: .userInitiated) { - MultiKeyringScene.preRenderLottieFrames(animation: parsed.animation, size: renderSize) - }.value - - await MainActor.run { [weak self] in - guard let self, !self.isCleaningUp, !textures.isEmpty else { - self?.setCarabinerReady(side: side) - return - } - self.setCarabinerTextures(side: side, textures: textures) - self.carabinerLottieFPS = parsed.animation.framerate - let node = SKSpriteNode(texture: textures[0]) - node.size = renderSize - let spriteKitY = self.size.height - (self.carabinerY + nodeHeight / 2) - node.position = CGPoint(x: self.carabinerX + carabinerWidth / 2, y: spriteKitY) - node.zPosition = side.zPosition - self.addChild(node) - if side.hasShadow { - self.addShadowToNode(node, offsetX: 2, offsetY: -3, blurRadius: 1.0) - } - self.setCarabinerNode(side: side, node: node) - self.setCarabinerReady(side: side) - } - } else { - // 실시간 모드: 첫 프레임 즉시 표시 → ready → 백그라운드에서 전체 프리렌더링 → 애니메이션 시작 - await MainActor.run { [weak self] in - guard let self, !self.isCleaningUp else { - self?.setCarabinerReady(side: side) - return - } - - let firstTexture = Self.renderSingleFrame( - animation: parsed.animation, - frame: parsed.animation.startFrame, - size: renderSize - ) - guard let firstTexture else { - self.setCarabinerReady(side: side) - return - } - - self.carabinerLottieFPS = parsed.animation.framerate - - let node = SKSpriteNode(texture: firstTexture) - node.size = renderSize - let spriteKitY = self.size.height - (self.carabinerY + nodeHeight / 2) - node.position = CGPoint(x: self.carabinerX + carabinerWidth / 2, y: spriteKitY) - node.zPosition = side.zPosition - - self.addChild(node) - if side.hasShadow { - self.addShadowToNode(node, offsetX: 2, offsetY: -3, blurRadius: 1.0) - } - self.setCarabinerNode(side: side, node: node) - self.setCarabinerReady(side: side) - } - - // 메인에서 청크 단위 프리렌더링 (Task.yield()로 UI 양보하며 진행) - let allTextures = await Self.preRenderLottieFramesAsync( - animation: parsed.animation, - size: renderSize - ) - - // 전체 텍스처 저장 + SKAction 애니메이션 시작 - // MainActor에서 실행해야 SpriteKit 노드 조작 가능 - // run(repeatForever)는 동기 버전 사용 (async 버전은 영원히 리턴하지 않음) - await MainActor.run { [weak self] in - guard let self, !self.isCleaningUp, !allTextures.isEmpty else { return } - self.setCarabinerTextures(side: side, textures: allTextures) - let timePerFrame = 1.0 / parsed.animation.framerate - let animate = SKAction.animate(with: allTextures, timePerFrame: timePerFrame) - self.carabinerNode(for: side)?.run(SKAction.repeatForever(animate)) + 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) { @@ -508,13 +474,6 @@ class MultiKeyringScene: SKScene { } } - private func setCarabinerTextures(side: CarabinerSide, textures: [SKTexture]) { - switch side { - case .back: carabinerBackTextures = textures - case .front: carabinerFrontTextures = textures - } - } - private func setCarabinerNode(side: CarabinerSide, node: SKSpriteNode) { switch side { case .back: carabinerBackNode = node @@ -546,109 +505,6 @@ class MultiKeyringScene: SKScene { } } - /// 단일 프레임만 렌더링하여 SKTexture 반환 (첫 프레임 즉시 표시용) - /// - 전체 프리렌더링 대비 1/N 비용으로 즉시 화면에 카라비너를 표시할 수 있음 - static func renderSingleFrame( - animation: LottieAnimation, - frame: CGFloat, - size: CGSize, - scale: CGFloat = 2.0 - ) -> SKTexture? { - let format = UIGraphicsImageRendererFormat() - format.scale = scale - let config = LottieConfiguration(renderingEngine: .mainThread) - let lottieView = LottieAnimationView(animation: animation, configuration: config) - lottieView.frame = CGRect(origin: .zero, size: size) - lottieView.contentMode = .scaleAspectFit - lottieView.currentFrame = AnimationFrameTime(frame) - 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) - } - - /// [비디오 전용] 모든 프레임을 동기적으로 프리렌더링 (비디오 생성 시 사용) - static func preRenderLottieFrames( - animation: LottieAnimation, - size: CGSize, - scale: CGFloat = 2.0 - ) -> [SKTexture] { - let format = UIGraphicsImageRendererFormat() - format.scale = scale - let config = LottieConfiguration(renderingEngine: .mainThread) - let lottieView = LottieAnimationView(animation: animation, configuration: config) - lottieView.frame = CGRect(origin: .zero, size: size) - lottieView.contentMode = .scaleAspectFit - - let totalFrames = Int(animation.endFrame - animation.startFrame) - var textures: [SKTexture] = [] - textures.reserveCapacity(totalFrames) - - let imageRenderer = UIGraphicsImageRenderer(bounds: lottieView.bounds, format: format) - - for frameOffset in 0.. [SKTexture] { - let format = UIGraphicsImageRendererFormat() - format.scale = scale - let config = LottieConfiguration(renderingEngine: .mainThread) - let lottieView = LottieAnimationView(animation: animation, configuration: config) - lottieView.frame = CGRect(origin: .zero, size: size) - lottieView.contentMode = .scaleAspectFit - - let totalFrames = Int(animation.endFrame - animation.startFrame) - var textures: [SKTexture] = [] - textures.reserveCapacity(totalFrames) - - let imageRenderer = UIGraphicsImageRenderer(bounds: lottieView.bounds, format: format) - - for chunkStart in stride(from: 0, to: totalFrames, by: chunkSize) { - let chunkEnd = min(chunkStart + chunkSize, totalFrames) - for frameOffset in chunkStart.. Void)? let onAllKeyringsReady: (() -> Void)? @State private var scene: MultiKeyringScene? @State private var particleEffects: [ParticleEffect] = [] @State private var backgroundImage: UIImage? + @State private var carabinerLottieAspectRatio: CGFloat = 1.0 // 기본 화면 크기 (iPhone 16 Pro 기준) private let defaultSceneSize = CGSize(width: 402, height: 874) @@ -58,6 +60,7 @@ struct MultiKeyringSceneView: View { carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0, currentCarabinerType: CarabinerType, + cleanupOnDisappear: Bool = false, onBackgroundLoaded: (() -> Void)? = nil, onAllKeyringsReady: (() -> Void)? = nil ) { @@ -75,6 +78,7 @@ struct MultiKeyringSceneView: View { self.carabinerY = carabinerY self.carabinerWidth = carabinerWidth self.currentCarabinerType = currentCarabinerType + self.cleanupOnDisappear = cleanupOnDisappear self.onBackgroundLoaded = onBackgroundLoaded self.onAllKeyringsReady = onAllKeyringsReady } @@ -82,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() } } @@ -95,8 +102,18 @@ struct MultiKeyringSceneView: View { loadBackgroundImage() } .onChange(of: currentCarabinerType) { _, _ in + loadCarabinerLottieAspectRatio() setupScene() } + .onDisappear { + // 이전 씬의 완료 콜백 무효화 (.id() 변경으로 뷰가 재생성될 때 + // 이전 씬의 비동기 로딩이 뒤늦게 완료되어 콜백이 발동하는 것을 방지) + scene?.onSetupComplete = nil + + if cleanupOnDisappear { + cleanupScene() + } + } } } @@ -111,6 +128,7 @@ extension MultiKeyringSceneView { assetId: bgLottieId, directory: "lottie_backgrounds" ) + .id(bgLottieId) .frame(width: geometry.size.width, height: geometry.size.height) } else if let backgroundImage { // 정적 이미지 배경 (기존) @@ -154,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() { From 6b0f105167683211f33d1b9fb2b181731dfbf116 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 22:33:48 +0900 Subject: [PATCH 16/21] =?UTF-8?q?fix:=20=EB=AD=89=EC=B9=98=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=ED=82=A4=EB=A7=81?= =?UTF-8?q?=20=EB=AC=BC=EB=A6=AC=20=ED=94=84=EB=A0=88=EC=9E=84=20=EB=93=9C?= =?UTF-8?q?=EB=9E=8D=20=EC=88=98=EC=A0=95(=EC=99=9C=EC=9D=B4=EA=B1=B8?= =?UTF-8?q?=EB=98=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scene.update(0)으로 물리 타이밍 리셋 후 시뮬레이션 시간(frameIndex/fps)으로 일정한 delta 보장. 프레임 간 Task.sleep(0.0167)으로 물리 엔진 계산 시간 확보. 배경 Lottie도 첫 프레임만 정적 렌더링하여 메모리 사용량 대폭 감소. --- .../BundleVideoGenerator+Rendering.swift | 7 +++++- .../Bundle/BundleVideoGenerator+Setup.swift | 25 ++++++++++--------- .../Video/Bundle/BundleVideoGenerator.swift | 3 +++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift index e917aff4f..c623b9e84 100644 --- a/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift +++ b/Keychy/Keychy/Core/Video/Bundle/BundleVideoGenerator+Rendering.swift @@ -25,11 +25,13 @@ extension BundleVideoGenerator { } for frameIndex in 0.. Date: Thu, 12 Feb 2026 22:34:11 +0900 Subject: [PATCH 17/21] =?UTF-8?q?refactor:=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EC=85=80=20Lottie=20=EC=9E=AC=EC=83=9D=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EB=B0=8F=20=EB=B7=B0=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EC=8B=9C=20=EC=94=AC=20=ED=81=B4=EB=A6=B0=EC=97=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Bundle/Views/Complete/BundleCompleteView.swift | 1 + .../Presentation/Bundle/Views/Create/BundleCreateView.swift | 3 ++- .../Presentation/Bundle/Views/Detail/BundleDetailView.swift | 1 + .../Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift | 3 ++- .../Bundle/Views/Shared/SelectBackgroundSheet.swift | 2 +- .../Bundle/Views/Shared/SelectCarabinerSheet.swift | 2 +- 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift index 1543541c9..d90fe4f9b 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift @@ -102,6 +102,7 @@ extension BundleCompleteView { carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, currentCarabinerType: carabiner.type, + cleanupOnDisappear: true, onAllKeyringsReady: { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation(.easeOut(duration: 0.3)) { diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift index c9672eca5..223fbccbe 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift @@ -82,6 +82,7 @@ struct BundleCreateView: View { carabinerY: cb.carabiner.carabinerY, carabinerWidth: cb.carabiner.carabinerWidth, currentCarabinerType: cb.carabiner.type, + cleanupOnDisappear: true, onAllKeyringsReady: { // onSetupComplete에서 호출됨 // (카라비너 Lottie 프리렌더링 + 키링 로드 + 물리 활성화 후) @@ -90,7 +91,7 @@ struct BundleCreateView: View { } } ) - .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) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift index 134060fa2..640829051 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Detail/BundleDetailView.swift @@ -82,6 +82,7 @@ struct BundleDetailView: View { carabinerY: carabiner.carabinerY, carabinerWidth: carabiner.carabinerWidth, currentCarabinerType: carabiner.type, + cleanupOnDisappear: true, onAllKeyringsReady: { // 기존 딜레이 작업 취소 readyDelayTask?.cancel() diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift index a069fbd33..427a28169 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift @@ -192,6 +192,7 @@ struct BundleEditView: View { carabinerY: carabiner.carabiner.carabinerY, carabinerWidth: carabiner.carabiner.carabinerWidth, currentCarabinerType: carabiner.carabiner.type, + cleanupOnDisappear: true, onAllKeyringsReady: { withAnimation(.easeOut(duration: 0.3)) { isSceneReady = true @@ -200,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/SelectBackgroundSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift index 72b7fbce6..93de0792b 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift @@ -70,7 +70,7 @@ struct SelectBackgroundSheet: View { } } } label: { - BackgroundCell(background: bg, isSelected: (bg == selectedBG), useThumbnail: true) + BackgroundCell(background: bg, isSelected: (bg == selectedBG)) } .buttonStyle(.plain) } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift index b986f3ffe..f0dfe6f20 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift @@ -69,7 +69,7 @@ struct SelectCarabinerSheet: View { } } } label: { - CarabinerCell(carabiner: cb, isSelected: (selectedCarabiner == cb), useThumbnail: true) + CarabinerCell(carabiner: cb, isSelected: (selectedCarabiner == cb)) } .buttonStyle(.plain) } From 7660ae819976579552d3524f28bbfbe324f994e0 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 22:34:38 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=9D=B8=EB=94=94=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EA=B0=80=20=ED=82=A4=EB=A7=81=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=EC=97=90=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 keyringDataList로 생성된 씬의 handleAllKeyringsReady Task가 실제 키링 씬보다 먼저 isSceneReady=true를 설정하는 레이스 컨디션 해결. sceneReadyTask를 저장하고 새 로딩 시작 시 이전 Task를 취소하도록 변경. --- .../Home/ViewModels/HomeViewModel.swift | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift index 96f9cd706..6cd3440f5 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift @@ -24,6 +24,12 @@ class HomeViewModel { /// 마지막으로 로드한 뭉치 ID (뭉치 변경 감지용) private var lastLoadedBundleId: String? + /// 씬 준비 완료 대기 Task (새 로딩 시작 시 취소용) + private var sceneReadyTask: Task? + + /// 씬 세대 카운터 (이전 씬의 콜백이 현재 씬에 영향주지 않도록 구분) + private(set) var sceneGeneration = 0 + /// 다른 화면에서 키링/뭉치 수정 후 홈 리프레시 필요 여부 static var needsRefresh: Bool = false @@ -251,18 +257,30 @@ class HomeViewModel { // 다시 false로 리셋하면 무한로딩 발생 guard !keyringDataList.isEmpty else { return } + // 이전 씬의 준비 완료 Task 취소 + 세대 증가 (이전 씬 콜백 무효화) + sceneReadyTask?.cancel() + sceneReadyTask = nil + sceneGeneration += 1 + withAnimation(.easeIn(duration: 0.2)) { isSceneReady = false } } /// 모든 키링 준비 완료되면 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 } From 4b054aec6c70b6ae13e7cc95c08fbcaaad8c6dbc Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 23:00:51 +0900 Subject: [PATCH 19/21] =?UTF-8?q?fix:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=EB=A1=9C=EB=94=A9=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(guard=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?isSceneReady=20=EB=AF=B8=EB=B3=B5=EA=B5=AC=20+=20=EC=84=B8?= =?UTF-8?q?=EB=8C=80=20=EB=B6=88=EC=9D=BC=EC=B9=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/MultiKeyringSceneView.swift | 4 - .../Home/ViewModels/HomeViewModel.swift | 86 ++++++++++++++++--- .../Home/Views/Main/HomeView.swift | 8 +- 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift index 31b5d0d13..d170d7ab8 100644 --- a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift +++ b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift @@ -106,10 +106,6 @@ struct MultiKeyringSceneView: View { setupScene() } .onDisappear { - // 이전 씬의 완료 콜백 무효화 (.id() 변경으로 뷰가 재생성될 때 - // 이전 씬의 비동기 로딩이 뒤늦게 완료되어 콜백이 발동하는 것을 방지) - scene?.onSetupComplete = nil - if cleanupOnDisappear { cleanupScene() } diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift index 6cd3440f5..545320ac1 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift @@ -49,6 +49,8 @@ class HomeViewModel { @MainActor func loadMainBundle(collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel, onBackgroundLoaded: (() -> Void)?) async { + print("🔵 [loadMainBundle] 시작 - isSceneReady=\(isSceneReady), isDataLoaded=\(isDataLoaded)") + // 리프레시 필요 시 캐시 무효화 if Self.needsRefresh { Self.needsRefresh = false @@ -59,21 +61,30 @@ class HomeViewModel { if isDataLoaded, let currentBundle = bundleViewModel.selectedBundle, lastLoadedBundleId == currentBundle.documentId { + print("🔵 [loadMainBundle] 캐시 히트 - 스킵") return } let uid = UserManager.shared.userUID - guard !uid.isEmpty else { return } + guard !uid.isEmpty else { + print("🔴 [loadMainBundle] uid 비어있음 → isSceneReady = true") + isSceneReady = true + return + } // 1. 배경 및 카라비너 데이터 로드 + print("🔵 [loadMainBundle] 1. 배경/카라비너 로드 시작") await collectionViewModel.loadBackgroundsAndCarabiners() + print("🔵 [loadMainBundle] 1. 완료 - backgrounds=\(bundleViewModel.backgrounds.count), carabiners=\(bundleViewModel.carabiners.count)") // 2. 번들 목록 로드 + print("🔵 [loadMainBundle] 2. 번들 로드 시작") await withCheckedContinuation { continuation in bundleViewModel.fetchAllBundles(uid: uid) { _ in continuation.resume() } } + print("🔵 [loadMainBundle] 2. 완료 - bundles=\(bundleViewModel.sortedBundles.count)") // 3. 메인 뭉치 설정 (isMain == true인 뭉치, 없으면 첫 번째 뭉치) if let mainBundle = bundleViewModel.sortedBundles.first(where: { $0.isMain }) { @@ -81,13 +92,19 @@ class HomeViewModel { } else if let firstBundle = bundleViewModel.sortedBundles.first { bundleViewModel.selectedBundle = firstBundle } else { - // 번들이 하나도 없는 경우 - 스플래시 즉시 종료 + print("🔴 [loadMainBundle] 번들 0개 → isSceneReady = true") + isSceneReady = true onBackgroundLoaded?() return } // 4. 선택된 뭉치의 배경과 카라비너 설정 - guard var bundle = bundleViewModel.selectedBundle else { return } + guard var bundle = bundleViewModel.selectedBundle else { + print("🔴 [loadMainBundle] selectedBundle nil → isSceneReady = true") + isSceneReady = true + return + } + print("🔵 [loadMainBundle] 3. 선택된 번들: \(bundle.name), keyrings=\(bundle.keyrings.count), selectedCarabiner=\(bundle.selectedCarabiner)") // 배경 resolve 시도 var resolvedBackground = bundleViewModel.resolveBackground(from: bundle.selectedBackground) @@ -110,15 +127,31 @@ class HomeViewModel { } bundleViewModel.selectedBackground = resolvedBackground - bundleViewModel.selectedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + print("🔵 [loadMainBundle] 4. 배경 resolve: \(resolvedBackground?.id ?? "nil")") + + // 카라비너 resolve 시도, 실패 시 첫 번째 카라비너로 fallback + var resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + print("🔵 [loadMainBundle] 4. 카라비너 resolve: \(resolvedCarabiner?.id ?? "nil") (원본 ID=\(bundle.selectedCarabiner))") + if resolvedCarabiner == nil, let fallback = bundleViewModel.carabiners.first { + resolvedCarabiner = fallback + print("🟡 [loadMainBundle] 카라비너 fallback 적용: \(fallback.id ?? "nil")") + } + bundleViewModel.selectedCarabiner = resolvedCarabiner // 5. 키링 데이터 생성 - guard let carabiner = bundleViewModel.selectedCarabiner else { return } + guard let carabiner = bundleViewModel.selectedCarabiner else { + print("🔴 [loadMainBundle] 카라비너 없음 (fallback도 실패) → isSceneReady = true") + isSceneReady = true + return + } + print("🔵 [loadMainBundle] 5. keyringDataList 생성 시작") keyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) + print("🔵 [loadMainBundle] 5. 완료 - keyringDataList=\(keyringDataList.count)") // 데이터 로드 완료 표시 lastLoadedBundleId = bundle.documentId isDataLoaded = true + print("🟢 [loadMainBundle] 완료 - isSceneReady=\(isSceneReady), isDataLoaded=\(isDataLoaded), sceneGeneration=\(sceneGeneration)") } /// 뭉치의 키링들을 MultiKeyringScene.KeyringData 배열로 변환 @@ -253,14 +286,22 @@ class HomeViewModel { /// 키링 데이터 변경 감지 시 씬 준비 상태 초기화 func handleKeyringDataChange() { + print("🟠 [handleKeyringDataChange] 호출 - keyringDataList=\(keyringDataList.count), isSceneReady=\(isSceneReady)") // 빈 뭉치면 이미 createKeyringDataList에서 isSceneReady = true 설정됨 // 다시 false로 리셋하면 무한로딩 발생 - guard !keyringDataList.isEmpty else { return } + guard !keyringDataList.isEmpty else { + print("🟠 [handleKeyringDataChange] 빈 리스트 → 스킵 (isSceneReady 유지)") + return + } - // 이전 씬의 준비 완료 Task 취소 + 세대 증가 (이전 씬 콜백 무효화) + // 이전 씬의 준비 완료 Task 취소 + // ⚠️ sceneGeneration은 여기서 증가시키지 않음 + // .onChange는 body 재평가 이후에 발동되므로, + // 이미 body에서 캡처한 generation과 불일치가 발생함 + // .id() 수정자가 씬 재생성을 담당하므로 세대 관리 불필요 sceneReadyTask?.cancel() sceneReadyTask = nil - sceneGeneration += 1 + print("🟠 [handleKeyringDataChange] sceneGeneration 유지=\(sceneGeneration), isSceneReady → false") withAnimation(.easeIn(duration: 0.2)) { isSceneReady = false @@ -270,17 +311,28 @@ class HomeViewModel { /// 모든 키링 준비 완료되면 0.5초 대기 후 로딩을 삭제함 /// - Parameter generation: 이 콜백을 생성한 씬의 세대 번호 func handleAllKeyringsReady(generation: Int) { + print("🟣 [handleAllKeyringsReady] 호출 - generation=\(generation), sceneGeneration=\(sceneGeneration)") // 이전 씬의 콜백이면 무시 (세대가 다르면 이미 새 씬이 생성된 것) - guard generation == sceneGeneration else { return } + guard generation == sceneGeneration else { + print("🔴 [handleAllKeyringsReady] 세대 불일치! generation=\(generation) != sceneGeneration=\(sceneGeneration) → 무시") + return + } sceneReadyTask?.cancel() sceneReadyTask = Task { [weak self] in try? await Task.sleep(for: .seconds(0.5)) - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + print("🔴 [handleAllKeyringsReady] Task 취소됨") + return + } await MainActor.run { [weak self] in - guard let self, generation == self.sceneGeneration else { return } + guard let self, generation == self.sceneGeneration else { + print("🔴 [handleAllKeyringsReady] sleep 후 세대 불일치 → 무시") + return + } + print("🟢 [handleAllKeyringsReady] isSceneReady → true (generation=\(generation))") withAnimation(.easeOut(duration: 0.3)) { self.isSceneReady = true } @@ -315,9 +367,17 @@ class HomeViewModel { // 3. 모든 데이터 먼저 준비 (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. 키링 데이터 생성 (새 카라비너 기준) let newKeyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index 091a4cbc2..1b266c946 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -44,8 +44,11 @@ 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 + let _ = print("🔷 [HomeView body] 씬 생성 - bundle=\(bundle.name), carabiner=\(carabiner.id ?? "nil"), bg=\(background.id ?? "nil"), gen=\(currentGeneration), keyrings=\(viewModel.keyringDataList.count)") MultiKeyringSceneView( keyringDataList: viewModel.keyringDataList, ringType: .basic, @@ -63,7 +66,7 @@ struct HomeView: View { currentCarabinerType: carabiner.type, onBackgroundLoaded: onBackgroundLoaded, onAllKeyringsReady: { - viewModel.handleAllKeyringsReady() + viewModel.handleAllKeyringsReady(generation: currentGeneration) } ) .ignoresSafeArea() @@ -72,6 +75,7 @@ struct HomeView: View { .id("\(bundle.documentId ?? "")_\(background.id ?? "")_\(carabiner.id ?? "")_\(viewModel.keyringDataList.map(\.bodyImageURL).joined(separator: ","))") } else { // 데이터 로딩 중 + let _ = print("🔴 [HomeView body] 씬 미생성 - bundle=\(bundleViewModel.selectedBundle?.name ?? "nil"), carabiner=\(bundleViewModel.selectedCarabiner?.id ?? "nil"), bg=\(bundleViewModel.selectedBackground?.id ?? "nil")") Color.clear.ignoresSafeArea() } From 856df9b7c60e51e0dbac79902ba6349bd7d4a854 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 23:40:23 +0900 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=EB=A1=9C=EB=94=A9=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(guard=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?isSceneReady=20=EB=AF=B8=EB=B3=B5=EA=B5=AC=20+=20=EC=84=B8?= =?UTF-8?q?=EB=8C=80=20=EB=B6=88=EC=9D=BC=EC=B9=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadMainBundle의 모든 guard 실패 경로에 isSceneReady = true 복구 - 카라비너 resolve 실패 시 carabiners.first fallback 적용 - HomeView body에서 resolveCarabiner 재호출 대신 selectedCarabiner 직접 사용 - handleKeyringDataChange에서 sceneGeneration 증가 제거 (onChange 타이밍 불일치) - handleKeyringDataChange에 키링 URL 시그니처 비교 추가 (같은 데이터 리로드 시 무한로딩 방지) - onSetupComplete = nil 복원 (비로티 카라비너의 이전 씬 콜백 누출 차단) - loadMainBundle 배치 업데이트 적용 (뭉치 전환 시 이전 키링 위치 혼입 방지) - switchBundle에 Task 취소 로직 추가 (빠른 연속 전환 시 데이터 꼬임 방지) 아오 힘들어 --- .../View/MultiKeyringSceneView.swift | 4 + .../Home/ViewModels/HomeViewModel.swift | 105 +++++++++++------- .../Home/Views/Main/HomeView.swift | 17 +-- 3 files changed, 73 insertions(+), 53 deletions(-) diff --git a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift index d170d7ab8..e078dfe56 100644 --- a/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift +++ b/Keychy/Keychy/Core/KeyringBundle/View/MultiKeyringSceneView.swift @@ -106,6 +106,10 @@ struct MultiKeyringSceneView: View { setupScene() } .onDisappear { + // .id() 변경으로 뷰가 교체될 때 이전 씬의 비동기 콜백 무효화 + // (비로티 카라비너 이미지 로드가 뒤늦게 완료되어 콜백이 누출되는 것 방지) + scene?.onSetupComplete = nil + if cleanupOnDisappear { cleanupScene() } diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift index 545320ac1..a5bc03776 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift @@ -27,6 +27,9 @@ class HomeViewModel { /// 씬 준비 완료 대기 Task (새 로딩 시작 시 취소용) private var sceneReadyTask: Task? + /// 뭉치 전환 Task (빠른 연속 전환 시 이전 전환 취소용) + private var switchBundleTask: Task? + /// 씬 세대 카운터 (이전 씬의 콜백이 현재 씬에 영향주지 않도록 구분) private(set) var sceneGeneration = 0 @@ -36,6 +39,9 @@ class HomeViewModel { /// 네트워크 에러 발생 여부 var hasNetworkError: Bool = false + /// 마지막 씬 생성 시점의 키링 URL 시그니처 (.id() 변경 감지용) + private var lastSceneKeyringSignature: String = "" + // MARK: - Private Properties private let db = Firestore.firestore() @@ -86,67 +92,64 @@ class HomeViewModel { } print("🔵 [loadMainBundle] 2. 완료 - bundles=\(bundleViewModel.sortedBundles.count)") - // 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 { print("🔴 [loadMainBundle] 번들 0개 → isSceneReady = true") isSceneReady = true onBackgroundLoaded?() return } - - // 4. 선택된 뭉치의 배경과 카라비너 설정 - guard var bundle = bundleViewModel.selectedBundle else { - print("🔴 [loadMainBundle] selectedBundle nil → isSceneReady = true") - isSceneReady = true - return - } print("🔵 [loadMainBundle] 3. 선택된 번들: \(bundle.name), keyrings=\(bundle.keyrings.count), selectedCarabiner=\(bundle.selectedCarabiner)") - // 배경 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 } } } - - bundleViewModel.selectedBackground = resolvedBackground print("🔵 [loadMainBundle] 4. 배경 resolve: \(resolvedBackground?.id ?? "nil")") - // 카라비너 resolve 시도, 실패 시 첫 번째 카라비너로 fallback + // 5. 카라비너 resolve (로컬 변수에만 저장) var resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) - print("🔵 [loadMainBundle] 4. 카라비너 resolve: \(resolvedCarabiner?.id ?? "nil") (원본 ID=\(bundle.selectedCarabiner))") + print("🔵 [loadMainBundle] 5. 카라비너 resolve: \(resolvedCarabiner?.id ?? "nil") (원본 ID=\(bundle.selectedCarabiner))") if resolvedCarabiner == nil, let fallback = bundleViewModel.carabiners.first { resolvedCarabiner = fallback print("🟡 [loadMainBundle] 카라비너 fallback 적용: \(fallback.id ?? "nil")") } - bundleViewModel.selectedCarabiner = resolvedCarabiner - // 5. 키링 데이터 생성 - guard let carabiner = bundleViewModel.selectedCarabiner else { + guard let carabiner = resolvedCarabiner else { print("🔴 [loadMainBundle] 카라비너 없음 (fallback도 실패) → isSceneReady = true") isSceneReady = true return } - print("🔵 [loadMainBundle] 5. keyringDataList 생성 시작") - keyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) - print("🔵 [loadMainBundle] 5. 완료 - keyringDataList=\(keyringDataList.count)") + + // 6. 키링 데이터 생성 (아직 UI 상태 변경 없음) + print("🔵 [loadMainBundle] 6. keyringDataList 생성 시작") + let newKeyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) + print("🔵 [loadMainBundle] 6. 완료 - keyringDataList=\(newKeyringDataList.count)") + + // 7. 모든 상태를 한 번에 업데이트 (SwiftUI body 재평가 최소화) + // switchBundle과 동일한 배치 업데이트 패턴 + bundleViewModel.selectedBundle = bundle + bundleViewModel.selectedBackground = resolvedBackground + bundleViewModel.selectedCarabiner = carabiner + keyringDataList = newKeyringDataList // 데이터 로드 완료 표시 lastLoadedBundleId = bundle.documentId @@ -291,17 +294,24 @@ class HomeViewModel { // 다시 false로 리셋하면 무한로딩 발생 guard !keyringDataList.isEmpty else { print("🟠 [handleKeyringDataChange] 빈 리스트 → 스킵 (isSceneReady 유지)") + lastSceneKeyringSignature = "" return } + // .id() 변경 여부 체크: bodyImageURL이 같으면 씬이 재생성되지 않음 + // 씬이 재생성되지 않으면 onSetupComplete 콜백이 안 오므로 + // isSceneReady = false로 바꾸면 무한로딩 발생 + let newSignature = keyringDataList.map(\.bodyImageURL).joined(separator: ",") + guard newSignature != lastSceneKeyringSignature else { + print("🟠 [handleKeyringDataChange] 같은 키링 URL → .id() 변경 없음 → 스킵") + return + } + lastSceneKeyringSignature = newSignature + // 이전 씬의 준비 완료 Task 취소 - // ⚠️ sceneGeneration은 여기서 증가시키지 않음 - // .onChange는 body 재평가 이후에 발동되므로, - // 이미 body에서 캡처한 generation과 불일치가 발생함 - // .id() 수정자가 씬 재생성을 담당하므로 세대 관리 불필요 sceneReadyTask?.cancel() sceneReadyTask = nil - print("🟠 [handleKeyringDataChange] sceneGeneration 유지=\(sceneGeneration), isSceneReady → false") + print("🟠 [handleKeyringDataChange] 씬 재생성 필요 → isSceneReady → false") withAnimation(.easeIn(duration: 0.2)) { isSceneReady = false @@ -350,22 +360,31 @@ 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) // 카라비너 resolve 실패 시 첫 번째 카라비너로 fallback var resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) @@ -374,15 +393,17 @@ class HomeViewModel { } 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 1b266c946..e3f3afe3f 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -253,17 +253,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 + ) } } From 8b3b3ed863dd1b43c06ea60d6c723b2ca664c692 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 12 Feb 2026 23:42:46 +0900 Subject: [PATCH 21/21] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EC=97=90=EC=97=90?= =?UTF-8?q?=EC=97=90=EC=97=90=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/ViewModels/HomeViewModel.swift | 43 ++----------------- .../Home/Views/Main/HomeView.swift | 2 - 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift index a5bc03776..2a76b3c20 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift @@ -55,7 +55,6 @@ class HomeViewModel { @MainActor func loadMainBundle(collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel, onBackgroundLoaded: (() -> Void)?) async { - print("🔵 [loadMainBundle] 시작 - isSceneReady=\(isSceneReady), isDataLoaded=\(isDataLoaded)") // 리프레시 필요 시 캐시 무효화 if Self.needsRefresh { @@ -67,30 +66,24 @@ class HomeViewModel { if isDataLoaded, let currentBundle = bundleViewModel.selectedBundle, lastLoadedBundleId == currentBundle.documentId { - print("🔵 [loadMainBundle] 캐시 히트 - 스킵") return } let uid = UserManager.shared.userUID guard !uid.isEmpty else { - print("🔴 [loadMainBundle] uid 비어있음 → isSceneReady = true") isSceneReady = true return } // 1. 배경 및 카라비너 데이터 로드 - print("🔵 [loadMainBundle] 1. 배경/카라비너 로드 시작") await collectionViewModel.loadBackgroundsAndCarabiners() - print("🔵 [loadMainBundle] 1. 완료 - backgrounds=\(bundleViewModel.backgrounds.count), carabiners=\(bundleViewModel.carabiners.count)") // 2. 번들 목록 로드 - print("🔵 [loadMainBundle] 2. 번들 로드 시작") await withCheckedContinuation { continuation in bundleViewModel.fetchAllBundles(uid: uid) { _ in continuation.resume() } } - print("🔵 [loadMainBundle] 2. 완료 - bundles=\(bundleViewModel.sortedBundles.count)") // 3. 메인 뭉치 찾기 (아직 selectedBundle에 설정하지 않음 — 배치 업데이트를 위해) var bundle: KeyringBundle @@ -99,13 +92,10 @@ class HomeViewModel { } else if let firstBundle = bundleViewModel.sortedBundles.first { bundle = firstBundle } else { - print("🔴 [loadMainBundle] 번들 0개 → isSceneReady = true") isSceneReady = true onBackgroundLoaded?() return } - print("🔵 [loadMainBundle] 3. 선택된 번들: \(bundle.name), keyrings=\(bundle.keyrings.count), selectedCarabiner=\(bundle.selectedCarabiner)") - // 4. 배경 resolve (로컬 변수에만 저장) var resolvedBackground = bundleViewModel.resolveBackground(from: bundle.selectedBackground) @@ -123,26 +113,19 @@ class HomeViewModel { } } } - print("🔵 [loadMainBundle] 4. 배경 resolve: \(resolvedBackground?.id ?? "nil")") - // 5. 카라비너 resolve (로컬 변수에만 저장) var resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) - print("🔵 [loadMainBundle] 5. 카라비너 resolve: \(resolvedCarabiner?.id ?? "nil") (원본 ID=\(bundle.selectedCarabiner))") if resolvedCarabiner == nil, let fallback = bundleViewModel.carabiners.first { resolvedCarabiner = fallback - print("🟡 [loadMainBundle] 카라비너 fallback 적용: \(fallback.id ?? "nil")") } guard let carabiner = resolvedCarabiner else { - print("🔴 [loadMainBundle] 카라비너 없음 (fallback도 실패) → isSceneReady = true") isSceneReady = true return } // 6. 키링 데이터 생성 (아직 UI 상태 변경 없음) - print("🔵 [loadMainBundle] 6. keyringDataList 생성 시작") let newKeyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) - print("🔵 [loadMainBundle] 6. 완료 - keyringDataList=\(newKeyringDataList.count)") // 7. 모든 상태를 한 번에 업데이트 (SwiftUI body 재평가 최소화) // switchBundle과 동일한 배치 업데이트 패턴 @@ -154,7 +137,6 @@ class HomeViewModel { // 데이터 로드 완료 표시 lastLoadedBundleId = bundle.documentId isDataLoaded = true - print("🟢 [loadMainBundle] 완료 - isSceneReady=\(isSceneReady), isDataLoaded=\(isDataLoaded), sceneGeneration=\(sceneGeneration)") } /// 뭉치의 키링들을 MultiKeyringScene.KeyringData 배열로 변환 @@ -289,11 +271,9 @@ class HomeViewModel { /// 키링 데이터 변경 감지 시 씬 준비 상태 초기화 func handleKeyringDataChange() { - print("🟠 [handleKeyringDataChange] 호출 - keyringDataList=\(keyringDataList.count), isSceneReady=\(isSceneReady)") // 빈 뭉치면 이미 createKeyringDataList에서 isSceneReady = true 설정됨 // 다시 false로 리셋하면 무한로딩 발생 guard !keyringDataList.isEmpty else { - print("🟠 [handleKeyringDataChange] 빈 리스트 → 스킵 (isSceneReady 유지)") lastSceneKeyringSignature = "" return } @@ -302,16 +282,12 @@ class HomeViewModel { // 씬이 재생성되지 않으면 onSetupComplete 콜백이 안 오므로 // isSceneReady = false로 바꾸면 무한로딩 발생 let newSignature = keyringDataList.map(\.bodyImageURL).joined(separator: ",") - guard newSignature != lastSceneKeyringSignature else { - print("🟠 [handleKeyringDataChange] 같은 키링 URL → .id() 변경 없음 → 스킵") - return - } + guard newSignature != lastSceneKeyringSignature else { return } lastSceneKeyringSignature = newSignature // 이전 씬의 준비 완료 Task 취소 sceneReadyTask?.cancel() sceneReadyTask = nil - print("🟠 [handleKeyringDataChange] 씬 재생성 필요 → isSceneReady → false") withAnimation(.easeIn(duration: 0.2)) { isSceneReady = false @@ -321,28 +297,17 @@ class HomeViewModel { /// 모든 키링 준비 완료되면 0.5초 대기 후 로딩을 삭제함 /// - Parameter generation: 이 콜백을 생성한 씬의 세대 번호 func handleAllKeyringsReady(generation: Int) { - print("🟣 [handleAllKeyringsReady] 호출 - generation=\(generation), sceneGeneration=\(sceneGeneration)") // 이전 씬의 콜백이면 무시 (세대가 다르면 이미 새 씬이 생성된 것) - guard generation == sceneGeneration else { - print("🔴 [handleAllKeyringsReady] 세대 불일치! generation=\(generation) != sceneGeneration=\(sceneGeneration) → 무시") - return - } + guard generation == sceneGeneration else { return } sceneReadyTask?.cancel() sceneReadyTask = Task { [weak self] in try? await Task.sleep(for: .seconds(0.5)) - guard !Task.isCancelled else { - print("🔴 [handleAllKeyringsReady] Task 취소됨") - return - } + guard !Task.isCancelled else { return } await MainActor.run { [weak self] in - guard let self, generation == self.sceneGeneration else { - print("🔴 [handleAllKeyringsReady] sleep 후 세대 불일치 → 무시") - return - } - print("🟢 [handleAllKeyringsReady] isSceneReady → true (generation=\(generation))") + guard let self, generation == self.sceneGeneration else { return } withAnimation(.easeOut(duration: 0.3)) { self.isSceneReady = true } diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index e3f3afe3f..b6aab7cf6 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -48,7 +48,6 @@ struct HomeView: View { let background = bundleViewModel.selectedBackground { // 현재 세대를 캡처하여 이전 씬의 콜백과 구분 let currentGeneration = viewModel.sceneGeneration - let _ = print("🔷 [HomeView body] 씬 생성 - bundle=\(bundle.name), carabiner=\(carabiner.id ?? "nil"), bg=\(background.id ?? "nil"), gen=\(currentGeneration), keyrings=\(viewModel.keyringDataList.count)") MultiKeyringSceneView( keyringDataList: viewModel.keyringDataList, ringType: .basic, @@ -75,7 +74,6 @@ struct HomeView: View { .id("\(bundle.documentId ?? "")_\(background.id ?? "")_\(carabiner.id ?? "")_\(viewModel.keyringDataList.map(\.bodyImageURL).joined(separator: ","))") } else { // 데이터 로딩 중 - let _ = print("🔴 [HomeView body] 씬 미생성 - bundle=\(bundleViewModel.selectedBundle?.name ?? "nil"), carabiner=\(bundleViewModel.selectedCarabiner?.id ?? "nil"), bg=\(bundleViewModel.selectedBackground?.id ?? "nil")") Color.clear.ignoresSafeArea() }