Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions Projects/Feature/GoalDetail/Sources/Detail/FlyingReactionSupport.swift
Original file line number Diff line number Diff line change
@@ -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..<config.emojiCount).map { order in
FlyingReactionParticle(
emoji: emoji,
startX: .random(in: config.startXRange),
startY: .random(in: config.startYRange),
startDate: Date(),
duration: .random(in: config.durationRange),
delay: (Double(order) * config.delayStep) + .random(in: config.delayJitterRange),
height: .random(in: config.heightRange),
amplitude: .random(in: config.amplitudeRange),
frequency: .random(in: config.frequencyRange),
drift: .random(in: config.driftRange),
phase: .random(in: 0...(CGFloat.pi * 2)),
scale: .random(in: config.scaleRange),
wobble: .random(in: config.wobbleRange)
)
}

reactions.append(contentsOf: newReactions)

let removeAt = (newReactions.map { $0.duration + $0.delay }.max() ?? 1.4) + 0.2

Task { @MainActor [weak self] in
try await Task.sleep(for: .seconds(removeAt))
guard let self else { return }
let ids = Set(newReactions.map(\.id))
self.reactions.removeAll { ids.contains($0.id) }
}
}

func clear() {
reactions.removeAll()
}
}

struct FlyingReactionOverlay: View {
let reactions: [FlyingReactionParticle]
let alignment: Alignment

var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { context in
ZStack(alignment: alignment) {
ForEach(reactions) { reaction in
let progress = reaction.progress(at: context.date)
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)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment)
}
.allowsHitTesting(false)
}
}

struct FlyingReactionConfig {
let emojiCount: Int
let startXRange: ClosedRange<CGFloat>
let startYRange: ClosedRange<CGFloat>
let durationRange: ClosedRange<Double>
let delayStep: Double
let delayJitterRange: ClosedRange<Double>
let heightRange: ClosedRange<CGFloat>
let amplitudeRange: ClosedRange<CGFloat>
let frequencyRange: ClosedRange<CGFloat>
let driftRange: ClosedRange<CGFloat>
let scaleRange: ClosedRange<CGFloat>
let wobbleRange: ClosedRange<CGFloat>
}

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)))
}
}
91 changes: 90 additions & 1 deletion Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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를 생성합니다.
///
Expand Down Expand Up @@ -85,6 +87,8 @@ public struct GoalDetailView: View {
store.send(.onAppear)
}
.onDisappear {
didPlayMyEmojiAppearAnimation = false
myEmojiFlyingReactionEmitter.clear()
store.send(.onDisappear)
}
.fullScreenCover(
Expand All @@ -100,6 +104,9 @@ public struct GoalDetailView: View {
isPresented: $store.isCameraPermissionAlertPresented,
onDismiss: { store.send(.cameraPermissionAlertDismissed) }
)
.overlay(alignment: .bottom) {
myEmojiFlyingReactionOverlay
}
.overlay {
if store.isSavingPhotoLog {
ProgressView()
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand Down
Loading