Reputation: 2478
I'm trying to create a sort of liquid animation as seen here (static image). A video of the effect can be seen in this youtube video from around 35s mark. Dots spawn on the outermost circle and move inwards. As they approach the innermost circle displaying charging information, the point of contact of the dot with the circle sort of animates upwards gradually until it makes contact with the moving dot and then flatlines back to the circumference of the circle. Here's my code but the animation is not quite there, the circumference sort of abruptly scales up and back down and is not fluid.
struct MovingDot: Identifiable {
let id = UUID()
var startAngle: Double
var progress: CGFloat
var scale: CGFloat = 1.0
}
struct BulgeEffect: Shape {
var targetAngle: Double
var bulgeHeight: CGFloat
var bulgeWidth: Double
var animatableData: AnimatablePair<Double, CGFloat> {
get { AnimatablePair(targetAngle, bulgeHeight) }
set {
targetAngle = newValue.first
bulgeHeight = newValue.second
}
}
func path(in rect: CGRect) -> Path {
let radius = rect.width / 2
var path = Path()
stride(from: 0, to: 2 * .pi, by: 0.01).forEach { angle in
let normalizedAngle = (angle - targetAngle + .pi * 2).truncatingRemainder(dividingBy: 2 * .pi)
let distanceFromCenter = min(normalizedAngle, 2 * .pi - normalizedAngle)
let bulgeEffect = distanceFromCenter < bulgeWidth
? bulgeHeight * pow(cos(distanceFromCenter / bulgeWidth * .pi / 2), 2)
: 0
let x = rect.midX + (radius + bulgeEffect) * cos(angle)
let y = rect.midY + (radius + bulgeEffect) * sin(angle)
if angle == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
path.closeSubpath()
return path
}
}
struct LiquidAnimation: View {
let outerDiameter: CGFloat
let innerDiameter: CGFloat
let dotSize: CGFloat
@State private var movingDots: [MovingDot] = []
@State private var bulgeHeight: CGFloat = 0
@State private var targetAngle: Double = 0
var body: some View {
ZStack {
ForEach(movingDots) { dot in
Circle()
.frame(width: dotSize * 2, height: dotSize * 2)
.scaleEffect(dot.scale)
.position(
x: outerDiameter/2 + cos(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2)),
y: outerDiameter/2 + sin(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2))
)
}
BulgeEffect(targetAngle: targetAngle, bulgeHeight: bulgeHeight, bulgeWidth: 0.6)
.fill()
.frame(width: innerDiameter, height: innerDiameter)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: bulgeHeight)
}
.frame(width: outerDiameter, height: outerDiameter)
.onAppear(perform: startSpawningDots)
}
private func startSpawningDots() {
Timer.scheduledTimer(withTimeInterval: Double.random(in: 2...5), repeats: true) { _ in
let startAngle = Double.random(in: 0...(2 * .pi))
let newDot = MovingDot(startAngle: startAngle, progress: 0)
movingDots.append(newDot)
withAnimation(.easeIn(duration: 1.5)) {
movingDots[movingDots.count - 1].progress = 0.8
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
targetAngle = startAngle
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
bulgeHeight = dotSize * 8
}
withAnimation(.easeOut(duration: 0.3)) {
movingDots[movingDots.count - 1].scale = 1.2
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeOut(duration: 0.3)) {
movingDots[movingDots.count - 1].progress = 1
movingDots[movingDots.count - 1].scale = 0.1
}
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
bulgeHeight = 0
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
movingDots.removeAll { $0.id == newDot.id }
}
}
}
}
struct ContentView: View {
var body: some View {
ZStack {
LiquidAnimation(
outerDiameter: 350,
innerDiameter: 150,
dotSize: 4
)
}
}
}
How can I achieve the same effect as in the video ?
Upvotes: 2
Views: 103
Reputation: 20922
I would describe this animation effect as the reverse of the droplet motion commonly seen in coffee advertisements. A liquid drop normally causes a "rebound" with a small circular drop escaping the surface tension. The effect in this animation seems to start with that circular drop, so it's like playing the droplet motion backwards. Not easy to implement!
You have managed to get quite far with your example, but the shape of the bulge is not quite right. I've focused on trying to make this part better.
I would suggest building the bulge shape by adding arcs to the path. The following diagram illustrates how the bulge can be based on the outline of two adjoining circles:
The bulge starts at point A, proceeding along the circumference of the circle with center point B. When it reaches the tangent with the smaller circle, it proceeds along the circumference of the smaller circle. This makes the point of the bulge. The reverse arc is then applied on the other side.
Here is an implementation of a shape that works this way:
struct Bulge: Shape {
let bulgeAngle: Angle // alpha
let circleRadius: CGFloat
let bulgeBeginRadius: CGFloat
var bulgePointRadius: CGFloat
var animatableData: CGFloat {
get { bulgePointRadius }
set { bulgePointRadius = newValue }
}
func path(in rect: CGRect) -> Path {
Path { path in
let sinAlpha = CGFloat(sin(bulgeAngle.radians))
let cosAlpha = CGFloat(cos(bulgeAngle.radians))
let pointA = CGPoint(
x: rect.midX - (circleRadius * sinAlpha),
y: rect.midY - (circleRadius * cosAlpha)
)
let pointB = CGPoint(
x: rect.midX - ((circleRadius + bulgeBeginRadius) * sinAlpha),
y: rect.midY - ((circleRadius + bulgeBeginRadius) * cosAlpha)
)
let beta = min(
(Double.pi / 2) - bulgeAngle.radians,
acos(Double(rect.midX - pointB.x) / (bulgeBeginRadius + bulgePointRadius))
)
let pointC = CGPoint(
x: rect.midX,
y: pointB.y + (sin(beta) * (bulgeBeginRadius + bulgePointRadius))
)
let pointD = CGPoint(
x: rect.midX + ((circleRadius + bulgeBeginRadius) * sinAlpha),
y: pointB.y
)
path.move(to: pointA)
path.addArc(
center: pointB,
radius: bulgeBeginRadius,
startAngle: .radians(Double.pi / 2) - bulgeAngle,
endAngle: .radians(beta),
clockwise: true
)
path.addArc(
center: pointC,
radius: bulgePointRadius,
startAngle: .radians(Double.pi + beta),
endAngle: .radians(-beta),
clockwise: false
)
path.addArc(
center: pointD,
radius: bulgeBeginRadius,
startAngle: .radians(Double.pi - beta),
endAngle: .radians(Double.pi / 2) + bulgeAngle,
clockwise: true
)
}
}
}
The bulge can be animated by changing the radius for the small circle (the bulge point), as illustrated with this demo:
struct BulgeDemo: View {
let bulgeAngle = Angle.degrees(25) // alpha
let circleRadius: CGFloat = 75
let bulgeBeginRadius: CGFloat = 100
@State private var bulgePointRadius: CGFloat = 10
var body: some View {
ZStack {
Circle()
.stroke()
.frame(width: circleRadius * 2, height: circleRadius * 2)
Bulge(
bulgeAngle: bulgeAngle,
circleRadius: circleRadius,
bulgeBeginRadius: bulgeBeginRadius,
bulgePointRadius: bulgePointRadius
)
.stroke(.blue, lineWidth: 3)
}
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
bulgePointRadius = circleRadius
}
}
}
}
This bulge can now be plugged into your original LiquidAnimation
. The main changes needed:
A circle is now the first layer in the ZStack
.
The new Bulge
shape replaces BulgeEffect
.
A .rotationEffect
is used to align the bulge with the incoming dot.
Before I was able to work out the cap to apply to the angle beta, I found that a .spring
animation caused some strange effects. This is fixed now, but using a simpler animation like .easeIn
works quite well anyway.
struct LiquidAnimation: View {
let outerDiameter: CGFloat
let innerDiameter: CGFloat
let dotSize: CGFloat
let bulgeAngle = Angle.degrees(25) // alpha
let bulgeBeginRadius: CGFloat = 100
let minBulgePointRadius: CGFloat = 10
@State private var movingDots: [MovingDot] = []
@State private var targetAngle: Double = 0
@State private var bulgePointRadius: CGFloat = 0
var body: some View {
ZStack {
Circle()
.frame(width: innerDiameter, height: innerDiameter)
Bulge(
bulgeAngle: bulgeAngle,
circleRadius: innerDiameter / 2,
bulgeBeginRadius: bulgeBeginRadius,
bulgePointRadius: bulgePointRadius
)
.rotationEffect(.radians(targetAngle + (Double.pi / 2)))
.onAppear { bulgePointRadius = innerDiameter / 2 }
ForEach(movingDots) { dot in
Circle()
.frame(width: dotSize * 2, height: dotSize * 2)
.scaleEffect(dot.scale)
.position(
x: outerDiameter/2 + cos(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2)),
y: outerDiameter/2 + sin(dot.startAngle) * (outerDiameter/2 - dot.progress * (outerDiameter/2 - innerDiameter/2))
)
}
}
.frame(width: outerDiameter, height: outerDiameter)
.onAppear(perform: startSpawningDots)
}
private func startSpawningDots() {
Timer.scheduledTimer(withTimeInterval: Double.random(in: 2...5), repeats: true) { _ in
let startAngle = Double.random(in: 0...(2 * .pi))
let newDot = MovingDot(startAngle: startAngle, progress: 0)
movingDots.append(newDot)
withAnimation(.easeIn(duration: 1.5)) {
movingDots[movingDots.count - 1].progress = 0.8
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
targetAngle = startAngle
// withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
withAnimation(.easeIn) {
bulgePointRadius = minBulgePointRadius
}
withAnimation(.easeOut(duration: 0.3)) {
movingDots[movingDots.count - 1].scale = 1.2
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeOut(duration: 0.3)) {
movingDots[movingDots.count - 1].progress = 1
movingDots[movingDots.count - 1].scale = 0.1
}
// withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
withAnimation(.easeIn) {
bulgePointRadius = innerDiameter / 2
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
movingDots.removeAll { $0.id == newDot.id }
}
}
}
}
The animation could still do with some polishing, but hopefully it gets you further.
Upvotes: 2