Skip to content

Instantly share code, notes, and snippets.

@alexjlockwood
Last active February 16, 2025 22:56
Show Gist options
  • Save alexjlockwood/50bd479d4879679055ba7e07ca9f8305 to your computer and use it in GitHub Desktop.
Save alexjlockwood/50bd479d4879679055ba7e07ca9f8305 to your computer and use it in GitHub Desktop.
Favorite/unfavorite heart break animation in SwiftUI
import SwiftUI
/// A heart break icon animation in SwiftUI.
struct HeartBreakView: View {
@Binding private var isSelected: Bool
@State private var rotation: CGFloat = 0
@State private var strokeHeartOpacity: CGFloat = 1
@State private var heartBreakOpacity: CGFloat = 0
@State private var trimProgress: CGFloat = 1
@State private var clipProgress: CGFloat = 0
init(isSelected: Binding<Bool>) {
self._isSelected = isSelected
}
var body: some View {
ZStack {
// Animates the clipped fill path (when the heart animates from empty to filled).
PathView(commands: kHeartFilled, fill: kColor, clipProgress: self.clipProgress)
// Animates the rotation of the filled heart break path.
ZStack {
PathView(commands: kHeartBreakLeft, fill: kColor, rotation: -self.rotation)
PathView(commands: kHeartBreakRight, fill: kColor, rotation: self.rotation)
}
.compositingGroup()
.opacity(self.heartBreakOpacity)
// Animates the rotation of the stroked heart break path.
ZStack {
PathView(commands: kHeartStrokeLeft, stroke: kColor, rotation: -self.rotation)
PathView(commands: kHeartStrokeRight, stroke: kColor, rotation: self.rotation)
}
.compositingGroup()
.opacity(self.strokeHeartOpacity)
// Animates the trim of the stroked heart after the heart break animation.
ZStack {
PathView(commands: kHeartStrokeLeft, stroke: kColor, trimProgress: self.trimProgress)
PathView(commands: kHeartStrokeRight, stroke: kColor, trimProgress: self.trimProgress)
}
}
.frame(width: kSize, height: kSize)
.accessibilityAddTraits(self.isSelected ? .isSelected : [])
.contentShape(Rectangle())
.onTapGesture {
self.isSelected.toggle()
if self.isSelected {
self.rotation = 0
self.strokeHeartOpacity = 1
self.heartBreakOpacity = 0
self.trimProgress = 0
withAnimation(.linear(duration: 0.3)) {
self.clipProgress = 1
}
} else {
self.heartBreakOpacity = 1
self.strokeHeartOpacity = 0
self.clipProgress = 0
withAnimation(.easeOut(duration: 0.3)) {
self.heartBreakOpacity = 0
self.rotation = 20
}
withAnimation(.easeInOut(duration: 0.3).delay(0.4)) {
self.trimProgress = 1
}
}
}
}
private struct PathView: View {
let commands: [PathCommand]
var fill: SwiftUI.Color = .clear
var stroke: SwiftUI.Color = .clear
var rotation: CGFloat = 0
var trimProgress: CGFloat = 1
var clipProgress: CGFloat = 1
var body: some View {
PathShape(commands: self.commands)
.trim(from: 0, to: self.trimProgress)
.fill(self.fill)
.stroke(self.stroke, style: .init(lineWidth: kStrokeWidth))
.rotationEffect(.degrees(self.rotation), anchor: kRotationAnchor)
.clipShape(ClipShape(progress: self.clipProgress))
}
private struct PathShape: Shape {
let commands: [PathCommand]
func path(in rect: CGRect) -> Path {
var path = Path()
/// Converts a point from viewport coordinates to device pixel coordinates.
func fromViewport(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
return .init(
x: x * rect.width / kViewportSize,
y: y * rect.height / kViewportSize
)
}
for command in self.commands {
switch command {
case .move(let x, let y):
path.move(to: fromViewport(x, y))
case .line(let x, let y):
path.addLine(to: fromViewport(x, y))
case .curve(let x0, let y0, let x1, let y1, let x2, let y2):
path.addCurve(
to: fromViewport(x2, y2),
control1: fromViewport(x0, y0),
control2: fromViewport(x1, y1)
)
}
}
return path
}
}
private struct ClipShape: Shape {
var progress: CGFloat
var animatableData: Double {
get { return self.progress }
set { self.progress = newValue }
}
func path(in rect: CGRect) -> Path {
let w = rect.width
let h = rect.height
return Path(.init(x: 0, y: h * (1 - self.progress), width: w, height: h))
}
}
}
}
private let kColor: SwiftUI.Color = .red
private let kSize: CGFloat = 256
private let kViewportSize: CGFloat = 56
private let kStrokeWidth: CGFloat = 2 * kSize / kViewportSize
private let kRotationAnchor: UnitPoint = .init(
x: 28 / kViewportSize,
y: 37.3 / kViewportSize
)
private enum PathCommand {
case move(_ x: CGFloat, _ y: CGFloat)
case line(_ x: CGFloat, _ y: CGFloat)
case curve(_ x0: CGFloat, _ y0: CGFloat, _ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat)
}
private let kHeartStrokeLeft: [PathCommand] = [
.move(28.72, 38.3),
.line(25.67, 35.55),
.curve(21.62, 31.79, 18.02, 28.89, 18.02, 24.85),
.curve(18.02, 21.59, 20.63, 19.97, 23.63, 19.97),
.curve(25, 19.97, 26.8, 21.18, 28.64, 23.13)
]
private let kHeartStrokeRight: [PathCommand] = [
.move(27.23, 38.29),
.line(30.76, 35.2),
.curve(34.83, 31.24, 37.75, 29.12, 38, 25.08),
.curve(38.17, 22.46, 35.77, 20.03, 33.38, 20.03),
.curve(30.43, 20.03, 29.67, 21.05, 27.23, 23.13)
]
private let kHeartFilled: [PathCommand] = [
.move(28, 39),
.line(26.41, 37.57),
.curve(20.74, 32.47, 17, 29.11, 17, 24.99),
.curve(17, 21.63, 19.66, 19, 23.05, 19),
.curve(24.96, 19, 26.8, 19.88, 28, 21.27),
.curve(29.2, 19.88, 31.04, 19, 32.95, 19),
.curve(36.34, 19, 39, 21.63, 39, 24.99),
.curve(39, 29.11, 35.26, 32.47, 29.6, 37.57),
.line(28, 39)
]
private let kHeartBreakLeft: [PathCommand] = [
.move(28.03, 21.05),
.curve(28.02, 21.07, 28.01, 21.08, 28, 21.09),
.curve(26.91, 19.81, 25.24, 19, 23.5, 19),
.curve(20.42, 19, 18, 21.42, 18, 24.5),
.curve(18, 28.28, 21.4, 31.36, 26.55, 36.03),
.line(28, 37.35),
.line(27.78, 36.99),
.line(28.49, 36.07),
.line(27.51, 34.76),
.line(28.78, 33.03),
.line(26.94, 31.01),
.line(29.15, 28.72),
.line(27.12, 27.14),
.line(29.15, 25.02),
.line(26.49, 22.98),
.line(28.03, 21.05),
]
private let kHeartBreakRight: [PathCommand] = [
.move(28.03, 21.05),
.curve(28.17, 20.89, 28.32, 20.74, 28.47, 20.6),
.line(28.91, 20.23),
.curve(29.93, 19.46, 31.19, 19, 32.5, 19),
.curve(35.58, 19, 38, 21.42, 38, 24.5),
.curve(38, 28.28, 34.6, 31.36, 29.45, 36.04),
.line(28, 37.35),
.line(27.78, 36.99),
.line(28.49, 36.07),
.line(27.51, 34.76),
.line(28.78, 33.03),
.line(26.94, 31.01),
.line(29.15, 28.72),
.line(27.12, 27.14),
.line(29.15, 25.02),
.line(26.49, 22.98),
.line(28.03, 21.05),
]
#Preview {
@Previewable @State var isSelected: Bool = false
HeartBreakView(isSelected: $isSelected)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment