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 ?
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 ?
Share Improve this question edited Feb 5 at 23:03 Benzy Neez 22.2k3 gold badges14 silver badges41 bronze badges asked Feb 4 at 16:37 batmanbatman 2,4422 gold badges27 silver badges51 bronze badges 5- When a dot comes in near to the 3 o'clock angle, the bulge has a cut in it. This may be a symptom of an angle going negative, or going past 360 degrees. It might help to perform the effect at some safe position, say at 12 o'clock, and then rotate the bulge into the required position. – Benzy Neez Commented Feb 4 at 17:04
- Good spot @BenzyNeez, I tried fixing it but it didn't work quite as intended. I'm afk so I can't post an update to the code. – batman Commented Feb 5 at 13:10
- @BenzyNeez I think I've managed to solve the 3 o'clock dot issue (code updated in the question). However I haven't been able to replicate the exact animation. The current animation looks like it is originating from further inside the circle rather than from the surface (circumference). I think if that is addressed, it should be closer to the end result I'm looking for. – batman Commented Feb 5 at 14:59
- @BenzyNeez What I mean by that is the ends of the curve look much sharper at the point of contact with the circumference instead of looking like an extension of the circumference. – batman Commented Feb 5 at 15:04
- Purely FWIW to do this sort of thing you use a soft body physics engine - usually en.wikipedia./wiki/Bullet_(software) pybullet.. enjoy! – Fattie Commented Feb 6 at 0:13
1 Answer
Reset to default 4I 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 replacesBulgeEffect
.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.