import SwiftUI
// Animation effect that applies parabolic path to the hearts appearing out of the center
// of the heart icon
struct HeartEffect: GeometryEffect {
var offsetValue: Double
var target: CGFloat
var animatableData: Double {
get { offsetValue }
set { offsetValue = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let reducedValue: Double = offsetValue - floor(offsetValue)
let translation = CGFloat(Double(target) * pow(reducedValue, 1/4)) - CGFloat(Double(target) * reducedValue)
let affineTransform = CGAffineTransform(translationX: translation, y: 0)
return ProjectionTransform(affineTransform)
}
}
// Animation effect that applies small shake to the left and right with some angular rotation
// when active icon is moving from active to inactive state
struct Shake: GeometryEffect {
var amount: CGFloat = 5
var shakesPerUnit = 3
var offsetValue: Double
var animatableData: Double {
get { offsetValue }
set { offsetValue = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let angle = sin(-2 * Double.pi * offsetValue) * 0.1
let affineTransform = CGAffineTransform(translationX: size.width * 0.5, y: size.height)
.rotated(by: CGFloat(angle))
.translatedBy(
x: -size.width * 0.5 + (amount * sin(CGFloat(offsetValue) * .pi * CGFloat(shakesPerUnit))),
y: -size.height
)
return ProjectionTransform(affineTransform)
}
}
// Structure to hold animated background hearts target positions, color and saturation
struct Heart: Hashable {
var size: CGFloat = 15
var duration: Double = 1.5
var color: Color = Color.green
var saturation: Double = 1
var x: CGFloat = 0
var y: CGFloat = 0
}
struct SpotifyLike: View {
@State private var touch: Bool = false
@State private var start: Bool = true
@State private var finish: Bool = false
@State private var animate: Double = 0
// Using closure for simplicty's sake which generates an array of Heart objects.
// Each heart depending on its position in an array receives size, saturation and y parameters
// based on couple of trigonometric functions.
private var heartsData: [Heart] {
var hearts: [Heart] = []
for i in 0...10 {
hearts.append(Heart(
size: scaleValue(i, 3),
saturation: scaleSaturation(i, 2),
x: -60 + CGFloat(Double(i) * 12),
y: -50 + scaleVertical(i, 2)
))
}
return hearts
}
var body: some View {
ZStack {
// Background Hearts should be in the background
if self.start {
hearts
}
// Primary Icon button with active an inactive state
ZStack {
Image(systemName: "heart")
.resizable()
.aspectRatio(contentMode: .fit)
.opacity(touch ? 0 : 1)
.foregroundColor(Color.white)
.animation(Animation.easeInOut(duration: 0.2))
.modifier(Shake(offsetValue: self.finish ? self.animate : 0))
Image(systemName: "heart.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.opacity(touch ? 1 : 0)
.foregroundColor(Color(UIColor.systemGreen))
.animation(Animation.easeInOut(duration: 0.2))
.scaleEffect(touch ? 1 : 0.7)
.animation(Animation.timingCurve(0.01, 2, 0.65, 0.65, duration: 0.5))
}.frame(width: 60)
// This section renders 3 Circles create simple ripple effect. Each Circle is animated a bit after previous one.
if self.start {
Circle()
.fill(Color.green)
.opacity(touch ? 1 : 0)
.frame(width: 90)
.mask(
Circle()
.scale(x: touch ? 1 : 0.5, y: touch ? 1 : 0.5)
.stroke(lineWidth: touch ? 0 : 10)
)
.animation(Animation.easeInOut(duration: 0.4))
Circle()
.fill(Color.green)
.opacity(touch ? 1 : 0)
.frame(width: 110)
.mask(
Circle()
.scale(x: touch ? 1 : 0.4, y: touch ? 1 : 0.4)
.stroke(lineWidth: touch ? 0 : 10)
)
.animation(Animation.easeInOut(duration: 0.4).delay(0.1))
Circle()
.fill(Color.green)
.opacity(touch ? 1 : 0)
.frame(width: 120)
.mask(
Circle()
.scale(x: touch ? 1 : 0.3, y: touch ? 1 : 0.3)
.stroke(lineWidth: touch ? 0 : 10)
)
.animation(Animation.easeInOut(duration: 0.4).delay(0.2))
}
}
.frame(width: 120)
.onTapGesture{
self.touch.toggle()
// We have to keep track of start and finish state to make sure that some
// elements of the animation are shown during inactive -> active transition
// and hidden during active -> inactive transition
if self.touch {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
if self.touch {
self.finish = true
}
}
} else {
self.start = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !self.touch {
self.start = true
self.finish = false
}
}
}
withAnimation(Animation.easeInOut(duration: 0.5).delay(0.2)) {
self.animate += 1
}
}
}
private var hearts: some View {
ZStack {
ForEach(heartsData, id: \.self) { heart in
Image(systemName: "heart.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: heart.size, height: heart.size)
.foregroundColor(heart.color)
.saturation(heart.saturation)
.opacity(self.touch ? 1 : 0)
.offset(x: self.touch ? heart.x : 0, y: self.touch ? heart.y : 20)
.animation(Animation.easeInOut(duration: 0.5).delay(0.2))
.opacity(self.touch ? 0 : 1)
.animation(Animation.linear(duration: 0.3).delay(0.4))
.modifier(HeartEffect(offsetValue: self.animate, target: heart.x))
}
}
}
// Returns desired size of a Heart based on its position in an array using cosine function
func scaleValue(_ idx: Int, _ total: Int) -> CGFloat {
let x = Double(idx) / Double(total)
let y = (cos(2 * .pi * x) + 1) / 2.0
return 10 + 15 * CGFloat(y)
}
// Returns color saturation of a Heart based on its position in an array using cosine function
func scaleSaturation(_ idx: Int, _ total: Int) -> Double {
let x = Double(idx) / Double(total)
let y = (cos(.pi * x))
return 0.3 + y
}
// Returns desired vertical offset of a Heart based on its position in an array using sine function
func scaleVertical(_ idx: Int, _ total: Int) -> CGFloat {
let x = Double(idx) / Double(total)
let y = (sin(.pi * x))
return 5 + 30 * CGFloat(y)
}
}
Spotify Like button animation in SwiftUI
by Kane
Following previous Twitter Like component, I implemented approximation of a Spotify Like button animation.
Unlike with Twitter component, this one uses GeometryEffect protocol in order to apply additional transformation to animated elements which otherwise wouldn't be possible with simple `.animation()`.
Unlike with Twitter component, this one uses GeometryEffect protocol in order to apply additional transformation to animated elements which otherwise wouldn't be possible with simple `.animation()`.