From 26fd51ce7b7a1fb1fb4e68d0c395721948d7d3dd Mon Sep 17 00:00:00 2001 From: jihun Date: Wed, 25 Feb 2026 12:27:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20emojiBurble=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80=20-=20#185?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageAssets.xcassets/Shape/Contents.json | 9 +++++++++ .../Shape/your_emoji.imageset/Contents.json | 15 +++++++++++++++ .../Shape/your_emoji.imageset/your_emoji.svg | 4 ++++ .../Sources/Resources/Image/Images.swift | 8 ++++++++ 4 files changed, 36 insertions(+) create mode 100644 Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/Contents.json create mode 100644 Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/Contents.json create mode 100644 Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/your_emoji.svg diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/Contents.json b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/Contents.json new file mode 100644 index 00000000..802b43ac --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "your_emoji.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/your_emoji.svg b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/your_emoji.svg new file mode 100644 index 00000000..00addd9d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Shape/your_emoji.imageset/your_emoji.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift b/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift index 45389b96..617ac95d 100644 --- a/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift +++ b/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift @@ -14,6 +14,8 @@ public extension Image { enum Illustration { } /// 모듈 공통 벡터 리소스 네임스페이스입니다. enum Vector { } + /// 모듈 공통 Shape 리소스 네임스페이스입니다. + enum Shape { } } /// 모듈 전반에서 공통으로 사용하는 Icon형식의 Image 입니다. @@ -118,3 +120,9 @@ public extension Image.Illustration { static let scare = IllustrationAsset.illustScare.swiftUIImage static let trash = IllustrationAsset.illustTrash.swiftUIImage } + +public extension Image.Shape { + typealias ShapeAsset = SharedDesignSystemAsset.ImageAssets.Shape + + static let emojiBubble = ShapeAsset.yourEmoji.swiftUIImage +} From ebde71da1dfffa3985b3830c736792bfad80633b Mon Sep 17 00:00:00 2001 From: jihun Date: Wed, 25 Feb 2026 12:28:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=9D=B4=EB=AA=A8?= =?UTF-8?q?=EC=A7=80=20=EC=9E=88=EC=9D=84=20=EC=8B=9C=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20#185?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interface/Sources/GoalDetailReducer.swift | 1 + .../Detail/FlyingReactionSupport.swift | 134 ++++++++++++++++++ .../Sources/Detail/GoalDetailView.swift | 91 +++++++++++- .../Sources/Detail/ReactionBarView.swift | 127 ++++------------- 4 files changed, 255 insertions(+), 98 deletions(-) create mode 100644 Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift diff --git a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift index 602ac1cf..87a2eb1a 100644 --- a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift +++ b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift @@ -105,6 +105,7 @@ public struct GoalDetailReducer { public var isCameraPermissionAlertPresented: Bool = false public var selectedReactionEmoji: ReactionEmoji? + public var myHasEmoji: Bool { currentUser == .mySelf && selectedReactionEmoji != nil } public var isShowReactionBar: Bool { currentUser == .you && isCompleted } public var isLoading: Bool { item == nil } public var isEditing: Bool = false diff --git a/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift b/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift new file mode 100644 index 00000000..ed401d7d --- /dev/null +++ b/Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift @@ -0,0 +1,134 @@ +// +// FlyingReactionSupport.swift +// FeatureGoalDetail +// +// Created by Codex on 2/25/26. +// + +import SwiftUI + +import SharedDesignSystem + +@MainActor +final class FlyingReactionEmitter: ObservableObject { + @Published private(set) var reactions: [FlyingReactionParticle] = [] + + func emit( + emoji: ReactionEmoji, + config: FlyingReactionConfig + ) { + let newReactions = (0.. 0, progress <= 1 { + reaction.emoji.image + .offset( + x: reaction.xOffset(at: progress), + y: reaction.yOffset(at: progress) + ) + .opacity(reaction.opacity(at: progress)) + .scaleEffect(reaction.scale) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + } + .allowsHitTesting(false) + } +} + +struct FlyingReactionConfig { + let emojiCount: Int + let startXRange: ClosedRange + let startYRange: ClosedRange + let durationRange: ClosedRange + let delayStep: Double + let delayJitterRange: ClosedRange + let heightRange: ClosedRange + let amplitudeRange: ClosedRange + let frequencyRange: ClosedRange + let driftRange: ClosedRange + let scaleRange: ClosedRange + let wobbleRange: ClosedRange +} + +struct FlyingReactionParticle: Identifiable { + let id = UUID() + let emoji: ReactionEmoji + let startX: CGFloat + let startY: CGFloat + let startDate: Date + let duration: TimeInterval + let delay: TimeInterval + let height: CGFloat + let amplitude: CGFloat + let frequency: CGFloat + let drift: CGFloat + let phase: CGFloat + let scale: CGFloat + let wobble: CGFloat + + func progress(at now: Date) -> CGFloat { + CGFloat((now.timeIntervalSince(startDate) - delay) / duration) + } + + func xOffset(at progress: CGFloat) -> CGFloat { + let angle = Double((progress * frequency * 2 * .pi) + phase) + let wobbleAngle = Double((progress * (frequency + 0.8) * 2 * .pi) + phase * 0.6) + return startX + + (CGFloat(sin(angle)) * amplitude) + + (CGFloat(sin(wobbleAngle)) * wobble) + + (drift * progress) + } + + func yOffset(at progress: CGFloat) -> CGFloat { + startY - (height * progress) + } + + func opacity(at progress: CGFloat) -> Double { + if progress < 0.25 { return Double(progress / 0.25) } + if progress < 0.52 { return 1 } + return Double(max(0, 1 - ((progress - 0.52) / 0.48))) + } +} diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 60bee841..025c6e7b 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -35,6 +35,8 @@ public struct GoalDetailView: View { @Dependency(\.proofPhotoFactory) private var proofPhotoFactory @State private var rectFrame: CGRect = .zero @State private var keyboardFrame: CGRect = .zero + @StateObject private var myEmojiFlyingReactionEmitter = FlyingReactionEmitter() + @State private var didPlayMyEmojiAppearAnimation = false /// GoalDetailView를 생성합니다. /// @@ -85,6 +87,8 @@ public struct GoalDetailView: View { store.send(.onAppear) } .onDisappear { + didPlayMyEmojiAppearAnimation = false + myEmojiFlyingReactionEmitter.clear() store.send(.onDisappear) } .fullScreenCover( @@ -100,6 +104,9 @@ public struct GoalDetailView: View { isPresented: $store.isCameraPermissionAlertPresented, onDismiss: { store.send(.cameraPermissionAlertDismissed) } ) + .overlay(alignment: .bottom) { + myEmojiFlyingReactionOverlay + } .overlay { if store.isSavingPhotoLog { ProgressView() @@ -295,7 +302,7 @@ private extension GoalDetailView { @ViewBuilder content: @escaping () -> Content ) -> some View { let shape = RoundedRectangle(cornerRadius: 20) - + return Color.clear .frame(maxWidth: .infinity) .aspectRatio(1, contentMode: .fit) @@ -318,8 +325,69 @@ private extension GoalDetailView { shape: shape, lineWidth: 1.6 ) + .overlay(alignment: .topTrailing) { + myEmoji + } .rotationEffect(.degrees(degree(isBackground: false))) } + + @ViewBuilder + var myEmoji: some View { + if store.myHasEmoji, + let emoji = store.selectedReactionEmoji?.image { + emoji + .resizable() + .frame(width: 52, height: 52) + .padding( + .init( + top: 5, + leading: 11, + bottom: 19, + trailing: 13 + ) + ) + .background( + Image.Shape.emojiBubble + .frame(width: 76, height: 76) + ) + .offset(x: 19, y: -14) + } else { + EmptyView() + } + } + + var myEmojiFlyingReactionOverlay: some View { + GeometryReader { proxy in + FlyingReactionOverlay( + reactions: myEmojiFlyingReactionEmitter.reactions, + alignment: .bottom + ) + .onChange(of: store.selectedReactionEmoji) { + playMyEmojiAppearAnimationIfNeeded( + containerWidth: proxy.size.width, + containerHeight: proxy.size.height + ) + } + } + .allowsHitTesting(false) + } + + func playMyEmojiAppearAnimationIfNeeded( + containerWidth: CGFloat, + containerHeight: CGFloat + ) { + guard store.myHasEmoji, + !didPlayMyEmojiAppearAnimation, + let selectedEmoji = store.selectedReactionEmoji else { return } + didPlayMyEmojiAppearAnimation = true + myEmojiFlyingReactionEmitter.emit( + emoji: selectedEmoji, + config: .goalDetailBottom( + width: containerWidth, + height: containerHeight + ) + ) + } } // MARK: - Constants @@ -340,6 +408,27 @@ private extension GoalDetailView { } } +private extension FlyingReactionConfig { + static func goalDetailBottom(width: CGFloat, height: CGFloat) -> Self { + let xSpread = max(60, (width / 2) - 24) + let maxTravel = max(220, height - 40) + return FlyingReactionConfig( + emojiCount: 30, + startXRange: (-xSpread)...xSpread, + startYRange: -12...6, + durationRange: 1.05...1.55, + delayStep: 0.03, + delayJitterRange: 0...0.02, + heightRange: (300)...maxTravel, + amplitudeRange: 8...18, + frequencyRange: 0.7...1.2, + driftRange: -20...20, + scaleRange: 0.78...1.08, + wobbleRange: 1...3 + ) + } +} + #Preview { GoalDetailView( store: Store( diff --git a/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift b/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift index bda2a1dc..c10e23de 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift @@ -13,7 +13,7 @@ struct ReactionBarView: View { let selectedEmoji: ReactionEmoji? let onSelect: (ReactionEmoji) -> Void - @State private var flyingReactions: [FloatingReaction] = [] + @StateObject private var flyingReactionEmitter = FlyingReactionEmitter() init( selectedEmoji: ReactionEmoji?, @@ -29,7 +29,10 @@ struct ReactionBarView: View { ForEach(ReactionEmoji.allCases, id: \.self) { emoji in Button { onSelect(emoji) - emitFlyingReactions(for: emoji, width: proxy.size.width) + flyingReactionEmitter.emit( + emoji: emoji, + config: .reactionBar(width: proxy.size.width) + ) } label: { emoji.image .padding(.horizontal, 8) @@ -54,108 +57,38 @@ struct ReactionBarView: View { .stroke(Color.black, lineWidth: 1) ) .overlay(alignment: .bottomLeading) { - flyingReactionLayer + FlyingReactionOverlay( + reactions: flyingReactionEmitter.reactions, + alignment: .bottomLeading + ) } } } private extension ReactionBarView { - struct FloatingReaction: Identifiable { - let id = UUID() - let emoji: ReactionEmoji - let startX: CGFloat - let startY: CGFloat - let startDate: Date - let duration: TimeInterval - let delay: TimeInterval - let height: CGFloat - let amplitude: CGFloat - let frequency: CGFloat - let drift: CGFloat - let phase: CGFloat - let scale: CGFloat - let wobble: CGFloat - - func progress(at now: Date) -> CGFloat { - CGFloat((now.timeIntervalSince(startDate) - delay) / duration) - } - - func xOffset(at progress: CGFloat) -> CGFloat { - let angle = Double((progress * frequency * 2 * .pi) + phase) - let wobbleAngle = Double((progress * (frequency + 0.8) * 2 * .pi) + phase * 0.6) - return startX - + (CGFloat(sin(angle)) * amplitude) - + (CGFloat(sin(wobbleAngle)) * wobble) - + (drift * progress) - } - - func yOffset(at progress: CGFloat) -> CGFloat { - startY - (height * progress) - } - - func opacity(at progress: CGFloat) -> Double { - if progress < 0.25 { return Double(progress / 0.25) } - if progress < 0.52 { return 1 } - return Double(max(0, 1 - ((progress - 0.52) / 0.48))) - } - } - - var flyingReactionLayer: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { context in - ZStack(alignment: .bottomLeading) { - ForEach(flyingReactions) { reaction in - flyingReactionView(reaction, now: context.date) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) - } - .allowsHitTesting(false) - } - - @ViewBuilder - func flyingReactionView(_ reaction: FloatingReaction, now: Date) -> some View { - let progress = reaction.progress(at: now) - if progress > 0, progress <= 1 { - reaction.emoji.image - .offset(x: reaction.xOffset(at: progress), y: reaction.yOffset(at: progress)) - .opacity(reaction.opacity(at: progress)) - .scaleEffect(reaction.scale) - } - } - - func emitFlyingReactions(for emoji: ReactionEmoji, width: CGFloat) { + static func reactionBarConfig(width: CGFloat) -> FlyingReactionConfig { let minX: CGFloat = 8 let maxXInset: CGFloat = 32 - let startY: CGFloat = -12 let maxX = width - maxXInset - let emojiCount = 20 - - let newReactions = (0.. Self { + ReactionBarView.reactionBarConfig(width: width) } }