feat(live-room): 하트 롱프레스 시 가운데 빈 하트가 표시되고 물 채워지는 애니메이션 추가
This commit is contained in:
133
SodaLive/Sources/Live/Room/V2/Component/WaterHeartView.swift
Normal file
133
SodaLive/Sources/Live/Room/V2/Component/WaterHeartView.swift
Normal 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()
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
import SDWebImageSwiftUI
|
||||
import Combine
|
||||
|
||||
struct LiveRoomViewV2: View {
|
||||
|
||||
@@ -17,6 +18,14 @@ struct LiveRoomViewV2: View {
|
||||
@State private var textHeight: 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 {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
@@ -256,6 +265,29 @@ struct LiveRoomViewV2: View {
|
||||
onClick: { viewModel.likeHeart() },
|
||||
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()
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init())
|
||||
.sheet(
|
||||
|
||||
Reference in New Issue
Block a user