Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f4f93cb
feat: Background/Carabiner 모델에 Lottie 필드 추가
giljihun Feb 11, 2026
0a22545
feat: LottieItemManager 및 LottieItemView 추가
giljihun Feb 11, 2026
5d24cc0
feat: BackgroundCell/CarabinerCell에 Lottie 렌더링 분기 추가
giljihun Feb 11, 2026
e2b7fc0
feat: SpriteKit 씬에 배경/카라비너 Lottie 프리렌더링 지원
giljihun Feb 11, 2026
e82b657
feat: BundleVideoGenerator에 Lottie 배경/카라비너 영상 렌더링 지원
giljihun Feb 11, 2026
aec6fbf
feat: 뭉치 뷰에 Lottie ID 파라미터 전달 및 다운로드 트리거 연결
giljihun Feb 11, 2026
a312cc3
feat: 공방 탭 Lottie 지원
giljihun Feb 11, 2026
2e99be9
fix: 번들만들기 -> 뒤로가기 탭바 사라짐 수정
giljihun Feb 11, 2026
3bbb1b8
feat: 홈 대표뭉치 및 뭉치완성뷰 Lottie 지원 추가
giljihun Feb 11, 2026
57975e1
feat: LottieItemView 비동기 로딩 + NSCache 인메모리 캐시 적용
giljihun Feb 12, 2026
b561376
chore: DataInitializer - 배경/카라비너 데이터 삽입 임시 코드 작성
giljihun Feb 12, 2026
734500a
feat: Lottie 카라비너 선택 시 로딩 오버레이 표시
giljihun Feb 12, 2026
2f9b231
refactor: 카라비너 Lottie 메서드 통합 및 데드코드 제거
giljihun Feb 12, 2026
bbfac11
fix: 배경/카라비너 선택 시트 셀 위 스크롤 안 되는 문제 수정
giljihun Feb 12, 2026
8699e23
feat: Lottie 카라비너를 SpriteKit 프리렌더링에서 SwiftUI 네이티브 재생으로 전환
giljihun Feb 12, 2026
6b0f105
fix: 뭉치 영상 생성 시 키링 물리 프레임 드랍 수정(왜이걸또)
giljihun Feb 12, 2026
4270f49
refactor: 선택 시트 셀 Lottie 재생 복원 및 뷰 전환 시 씬 클린업 적용
giljihun Feb 12, 2026
7660ae8
fix: 홈 화면 로딩 인디케이터가 키링 로드 전에 사라지는 버그 수정
giljihun Feb 12, 2026
4b054ae
fix: 홈 화면 무한로딩 버그 수정 (guard 실패 시 isSceneReady 미복구 + 세대 불일치)
giljihun Feb 12, 2026
856df9b
fix: 홈 화면 무한로딩 버그 수정 (guard 실패 시 isSceneReady 미복구 + 세대 불일치)
giljihun Feb 12, 2026
8b3b3ed
chore: 테스트용 로그 제에에에에거
giljihun Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -612,6 +614,8 @@
4C2526272F3B97D6003CC5AD /* TabBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarManager.swift; sourceTree = "<group>"; };
4C2526282F3B97D6003CC5AD /* TabBarSwipeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarSwipeObserver.swift; sourceTree = "<group>"; };
4C2526292F3B97D6003CC5AD /* UIViewController+Find.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Find.swift"; sourceTree = "<group>"; };
4C25262E2F3C95DA003CC5AD /* LottieItemManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieItemManager.swift; sourceTree = "<group>"; };
4C2526302F3C95EA003CC5AD /* LottieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieItemView.swift; sourceTree = "<group>"; };
4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Medium.ttf"; sourceTree = "<group>"; };
4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlert.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1993,6 +1997,7 @@
4CF2A9652F0B8C5800BA9FDA /* PullToRefreshIndicator.swift */,
4CF2A9672F0B91F300BA9FDA /* AnimatedGIFView.swift */,
C645AE9E2EB1055C004BFE69 /* CategoryTabBar.swift */,
4C2526302F3C95EA003CC5AD /* LottieItemView.swift */,
);
path = View;
sourceTree = "<group>";
Expand Down Expand Up @@ -2384,6 +2389,7 @@
C68931CD2EB7B94B00C5F083 /* FileManagers */ = {
isa = PBXGroup;
children = (
4C25262E2F3C95DA003CC5AD /* LottieItemManager.swift */,
C68931CC2EB7B94B00C5F083 /* EffectManager.swift */,
AA9123AE2ED4BC490070A9F9 /* LocationManager.swift */,
38C3C27D2EC08794003C5DE1 /* PopupManager.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
10 changes: 9 additions & 1 deletion Keychy/Keychy/CommonModels/KeyringBundle/Background.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions Keychy/Keychy/CommonModels/KeyringBundle/Carabiner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 : 일반 카라비너 형
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
}
}
134 changes: 134 additions & 0 deletions Keychy/Keychy/Core/Components/View/LottieItemView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// LottieItemView.swift
// Keychy
//
// Created by 길지훈 on 2/11/26.
//

import SwiftUI
import Lottie

/// 배경/카라비너 Lottie 아이템 표시용 뷰
/// - JSON 파싱을 백그라운드 스레드에서 비동기 수행 (메인 스레드 블로킹 방지)
/// - NSCache 기반 인메모리 캐시로 동일 에셋 재파싱 방지
/// - Coordinator 패턴으로 LottieAnimationView 생명주기 관리
struct LottieItemView: UIViewRepresentable {
let assetId: String
let directory: String
let loopMode: LottieLoopMode
let contentMode: UIView.ContentMode

// MARK: - 인메모리 캐시
/// LottieAnimation.filepath()는 JSON 파싱 비용이 큼
/// NSCache로 파싱 결과를 메모리에 유지하여 같은 에셋 재파싱 방지
/// NSCache는 thread-safe이므로 별도 lock 불필요
private static let animationCache = NSCache<NSString, AnimationWrapper>()

/// NSCache는 class 타입만 저장 가능 → LottieAnimation을 래핑
private class AnimationWrapper {
let animation: LottieAnimation
init(_ animation: LottieAnimation) { self.animation = animation }
}

init(
assetId: String,
directory: String,
loopMode: LottieLoopMode = .loop,
contentMode: UIView.ContentMode = .scaleAspectFill
) {
self.assetId = assetId
self.directory = directory
self.loopMode = loopMode
self.contentMode = contentMode
}

// MARK: - Coordinator
func makeCoordinator() -> Coordinator {
Coordinator()
}

class Coordinator {
var animationView: LottieAnimationView?
var loadTask: Task<Void, Never>?
}

// MARK: - UIViewRepresentable
func makeUIView(context: Context) -> UIView {
let container = UIView(frame: .zero)
container.clipsToBounds = true

let animationView = LottieAnimationView()
animationView.contentMode = contentMode
context.coordinator.animationView = animationView

container.addSubview(animationView)
animationView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: container.widthAnchor),
animationView.heightAnchor.constraint(equalTo: container.heightAnchor)
])

// JSON 파싱을 백그라운드 스레드에서 비동기 수행
// Task.detached: 현재 actor 컨텍스트를 상속하지 않으므로 백그라운드에서 실행됨
let assetId = self.assetId
let directory = self.directory
let loopMode = self.loopMode

context.coordinator.loadTask = Task.detached(priority: .userInitiated) {
let animation = Self.loadAnimation(assetId: assetId, directory: directory)
guard !Task.isCancelled else { return }

await MainActor.run {
guard !Task.isCancelled, let animation else { return }
animationView.animation = animation
animationView.loopMode = loopMode
animationView.play()
}
}

return container
}

func updateUIView(_ uiView: UIView, context: Context) {}

/// 뷰 제거 시 비동기 Task 취소 + 애니메이션 정지 + 메모리 해제
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
coordinator.loadTask?.cancel()
coordinator.loadTask = nil
coordinator.animationView?.stop()
coordinator.animationView?.animation = nil
coordinator.animationView?.removeFromSuperview()
coordinator.animationView = nil
}

// MARK: - Animation Loading
/// 인메모리 캐시 → 디스크 순서로 LottieAnimation 로드
/// 백그라운드 스레드에서 호출되므로 메인 스레드를 블로킹하지 않음
private static func loadAnimation(assetId: String, directory: String) -> LottieAnimation? {
let key = "\(directory)/\(assetId)" as NSString

// 1. 인메모리 캐시 히트 → 즉시 반환 (JSON 재파싱 없음)
if let cached = animationCache.object(forKey: key) {
return cached.animation
}

// 2. 디스크에서 로드 (JSON 파싱 발생)
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let cachedURL = cacheDir.appendingPathComponent("\(directory)/\(assetId).json")
guard FileManager.default.fileExists(atPath: cachedURL.path),
let animation = LottieAnimation.filepath(cachedURL.path) else { return nil }

// 3. 인메모리 캐시에 저장
animationCache.setObject(AnimationWrapper(animation), forKey: key)

return animation
}

// MARK: - Cache Invalidation
/// 특정 에셋의 인메모리 캐시 무효화
/// LottieItemManager에서 새 파일 다운로드 완료 시 호출
static func invalidateCache(assetId: String, directory: String) {
let key = "\(directory)/\(assetId)" as NSString
animationCache.removeObject(forKey: key)
}
}
39 changes: 26 additions & 13 deletions Keychy/Keychy/Core/Components/View/LottieView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,45 +13,58 @@ 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")

if FileManager.default.fileExists(atPath: cachedURL.path) {
return LottieAnimation.filepath(cachedURL.path)
}

// 2. Bundle에서 찾기 (기본 무료 파티클)
if let animation = LottieAnimation.named(particleId) {
return animation
}
Expand Down
Loading