feat(live-room): 하트 롱프레스 시 가운데 빈 하트가 표시되고 물 채워지는 애니메이션 추가

This commit is contained in:
Yu Sung
2025-11-05 16:07:41 +09:00
parent 0a59c6f575
commit 76757215cf
2 changed files with 192 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
//
// WaterHeartView.swift
// SodaLive
//
// Created by klaus on 11/5/25.
//
import SwiftUI
struct WaterHeartView: View {
var progress: CGFloat = 0 // 0...1
var show: Bool = false //
var phase: CGFloat = 0 // ()
var body: some View {
GeometryReader { geo in
let size = min(geo.size.width, geo.size.height)
ZStack {
//
HeartShape()
.stroke(lineWidth: size * 0.03)
.foregroundStyle(Color(hex: "ff959a"))
.opacity(show ? 1 : 0)
// ( )
ZStack {
// ( )
Rectangle()
.fill(Color(hex: "ff959a"))
.frame(height: geo.size.height * progress)
.position(x: geo.size.width/2,
y: geo.size.height - (geo.size.height * progress)/2)
// ( )
WaveShape(progress: progress,
phase: phase,
amplitude: max(2, size * 0.015))
.fill(Color(hex: "ff959a"))
}
.mask(HeartShape())
.opacity(show ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: show)
}
}
.aspectRatio(4/3, contentMode: .fit)
}
}
struct HeartShape: Shape {
func path(in rect: CGRect) -> Path {
let w = rect.width
let h = rect.height
let scale: CGFloat = 0.9 //
let cx = rect.midX
let cy = rect.midY
let r = min(w, h) * 0.5 * scale
func pt(_ nx: CGFloat, _ ny: CGFloat) -> CGPoint {
CGPoint(x: cx + nx * r, y: cy + ny * r)
}
var p = Path()
// - notch: y -0.20
// - shoulder : (±1.20, 0.12) (±1.25, 0.12)
// - lobe top : (±0.80, -0.95) (±0.80, -0.85)
p.move(to: pt(0, -0.20)) // top notch (shallow)
// right lobe bottom tip
p.addCurve(
to: pt(0, 1.0),
control1: pt(0.80, -0.85), // right lobe top (lowered)
control2: pt(1.25, 0.12) // right shoulder (wider)
)
// left lobe back to notch
p.addCurve(
to: pt(0, -0.20),
control1: pt(-1.25, 0.12), // left shoulder (wider)
control2: pt(-0.80, -0.85) // left lobe top (lowered)
)
p.closeSubpath()
return p
}
}
/// ( )
struct WaveShape: Shape {
/// 0...1 (1 )
var progress: CGFloat
/// ()
var phase: CGFloat
///
var amplitude: CGFloat
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
// progress 0 , 1
let waterLevel = height * (1 - progress)
var path = Path()
path.move(to: CGPoint(x: 0, y: waterLevel))
//
let wavelength = width / 1.2
let step: CGFloat = 4
for x in stride(from: 0, through: width, by: step) {
let relative = x / wavelength
let y = waterLevel + sin(relative * 2 * .pi + phase) * amplitude
path.addLine(to: CGPoint(x: x, y: y))
}
// ( )
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.closeSubpath()
return path
}
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(progress, phase) }
set {
progress = newValue.first
phase = newValue.second
}
}
}
#Preview {
WaterHeartView()
}

View File

@@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Kingfisher import Kingfisher
import SDWebImageSwiftUI import SDWebImageSwiftUI
import Combine
struct LiveRoomViewV2: View { struct LiveRoomViewV2: View {
@@ -17,6 +18,14 @@ struct LiveRoomViewV2: View {
@State private var textHeight: CGFloat = .zero @State private var textHeight: CGFloat = .zero
@State private var menuTextHeight: CGFloat = .zero @State private var menuTextHeight: CGFloat = .zero
//
@State private var isLongPressingHeart: Bool = false
@State private var longPressStartAt: Date? = nil
@State private var showWaterHeart: Bool = false
@State private var waterProgress: CGFloat = 0
@State private var wavePhase: CGFloat = 0
let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
ZStack { ZStack {
Color.black.edgesIgnoringSafeArea(.all) Color.black.edgesIgnoringSafeArea(.all)
@@ -256,6 +265,29 @@ struct LiveRoomViewV2: View {
onClick: { viewModel.likeHeart() }, onClick: { viewModel.likeHeart() },
onLongPress: { viewModel.likeHeart(messageType: .BIG_HEART_DONATION, heartCount: 100) } onLongPress: { viewModel.likeHeart(messageType: .BIG_HEART_DONATION, heartCount: 100) }
) )
.onLongPressGesture(
minimumDuration: 2.0,
maximumDistance: 50,
pressing: { pressing in
if pressing {
if !isLongPressingHeart {
isLongPressingHeart = true
longPressStartAt = Date()
//
waterProgress = 0
wavePhase = 0
}
} else {
isLongPressingHeart = false
showWaterHeart = false
waterProgress = 0
longPressStartAt = nil
}
},
perform: {
// perform (onLongPress)
}
)
} }
} }
@@ -711,6 +743,33 @@ struct LiveRoomViewV2: View {
LoadingView() LoadingView()
} }
} }
.overlay(alignment: .center) {
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 280, height: 210) // 4:3
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
}
.onReceive(heartWaveTimer) { _ in
guard isLongPressingHeart else { return }
let now = Date()
if longPressStartAt == nil { longPressStartAt = now }
let elapsed = now.timeIntervalSince(longPressStartAt!)
if elapsed >= 0.5 {
if !showWaterHeart {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
showWaterHeart = true
}
}
let p = min(max((elapsed - 0.5) / 1.5, 0), 1)
waterProgress = p
//
wavePhase += 0.25
} else {
showWaterHeart = false
waterProgress = 0
}
}
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init()) .edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init())
.sheet( .sheet(