136 lines
4.2 KiB
Swift
136 lines
4.2 KiB
Swift
//
|
|
// 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.01)
|
|
.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(1, 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()
|
|
|
|
// 위쪽 파임
|
|
p.move(to: pt(0.0, -0.45))
|
|
|
|
// 오른쪽 반쪽: 둥근 윗볼 -> 어깨 -> 바닥 포인트
|
|
p.addCurve(
|
|
to: pt(0.0, 0.65), // 바닥 포인트(최저점)
|
|
control1: pt(0.62, -1.02), // 오른쪽 윗볼의 정점 쪽(더 둥글게/높게)
|
|
control2: pt(1.22, -0.04) // 오른쪽 어깨(조금 위로, 더 바깥쪽)
|
|
)
|
|
|
|
// 왼쪽 반쪽: 대칭
|
|
p.addCurve(
|
|
to: pt(0.0, -0.45), // 시작점(위쪽 파임)으로 회귀
|
|
control1: pt(-1.22, -0.04), // 왼쪽 어깨
|
|
control2: pt(-0.62, -1.02) // 왼쪽 윗볼
|
|
)
|
|
|
|
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()
|
|
}
|