diff --git a/SodaLive/Sources/Live/Room/V2/Component/WaterHeartView.swift b/SodaLive/Sources/Live/Room/V2/Component/WaterHeartView.swift new file mode 100644 index 0000000..788c6a3 --- /dev/null +++ b/SodaLive/Sources/Live/Room/V2/Component/WaterHeartView.swift @@ -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 { + get { AnimatablePair(progress, phase) } + set { + progress = newValue.first + phase = newValue.second + } + } +} + +#Preview { + WaterHeartView() +} diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index 9343389..1c1c4ed 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -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(