Last active
February 16, 2025 22:56
-
-
Save alexjlockwood/50bd479d4879679055ba7e07ca9f8305 to your computer and use it in GitHub Desktop.
Favorite/unfavorite heart break animation in SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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