I'm trying to create a pulsating visualisation animation as can be seen here. Here's what I've come up with so far.
struct ContentView: View {
private let offsetAnimDuration: Double = 0.2
private let barCount = 200
@State private var barOffset: CGFloat = 0
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ForEach(0..<barCount) { i in
Bar(barMinHeight: 1, barMaxHeight: 50, barWidth: 2, barColor: .white)
.offset(y: barOffset)
.animation(.easeInOut(duration: offsetAnimDuration), value: barOffset)
.rotationEffect(.degrees(360/Double(barCount)) * Double(i))
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in
barOffset = -CGFloat.random(in: 100...150)
}
}
}
}
struct Bar: View {
let barMinHeight: CGFloat
let barMaxHeight: CGFloat
let barWidth: CGFloat
let barColor: Color
private let animDuration: Double = 0.5
@State private var animHeight: CGFloat = 0
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: barWidth / 2)
.foregroundStyle(.white)
.frame(width: barWidth, height: animHeight)
.animation(.easeInOut(duration: animDuration), value: animHeight)
.onAppear {
Timer.scheduledTimer(withTimeInterval: animDuration, repeats: true) { _ in
animHeight = CGFloat.random(in: barMinHeight...barMaxHeight)
}
}
}
.frame(height: barMaxHeight, alignment: .bottom)
}
}
The issues that I'm seeing are
Also, is it possible to achieve the animation seen here ? It looks similar to the first effect except there are groups of bars animating at a time with some extended shadowy-glow effect which I haven't been able to wrap my head around. Any help is appreciated.
To overcome the offset issues, I would suggest drawing a black circle in the center of the view and then adjusting its size with animation. This way, the offset of the individual bars can remain contstant.
In the original animation, the bars are not solid but have dashed ends. One way to get this effect is to stroke the bars as lines, using a StrokeStyle
with dash effect.
This answer by lorem ipsum provides a good overview of ways in which timed events can be triggered. For the animation here, it works well to use task(id:)
.
Here is an updated version of your example that tries to emulate the first animation:
EDIT Following up on the comments, the CPU load can be reduced by grouping the bars together. Solution updated.
struct ContentView: View {
private let barMinHeight: CGFloat = 20
private let barMaxHeight: CGFloat = 80
private let nGroups = 20
private let nBarsPerGroup = 10
private let minCoreSize: CGFloat = 180
private let maxCoreSize: CGFloat = 200
private let coreAnimDuration: Double = 0.2
@State private var coreSize: CGFloat = 50
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ForEach(0..<nGroups, id: \.self) { i in
BarGroup(
barMinHeight: barMinHeight,
barMaxHeight: barMaxHeight,
barOffset: minCoreSize / 2,
barWidth: 2,
barColor: .white,
nBars: nBarsPerGroup
)
.rotationEffect(.degrees(360/Double(nGroups * nBarsPerGroup)) * Double(i))
}
Circle()
.fill(.black)
.frame(width: coreSize)
.animation(.easeInOut(duration: coreAnimDuration), value: coreSize)
.task(id: coreSize) {
try? await Task.sleep(for: .seconds(coreAnimDuration))
coreSize = CGFloat.random(in: minCoreSize...maxCoreSize)
}
}
}
}
struct BarGroup: View {
let barMinHeight: CGFloat
let barMaxHeight: CGFloat
let barOffset: CGFloat
let barWidth: CGFloat
let barColor: Color
let nBars: Int
private let animDuration = Double.random(in: 0.1...0.2)
@State private var barHeights: [CGFloat]
struct Line: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
return path
}
}
init(barMinHeight: CGFloat, barMaxHeight: CGFloat, barOffset: CGFloat, barWidth: CGFloat, barColor: Color, nBars: Int) {
self.barMinHeight = barMinHeight
self.barMaxHeight = barMaxHeight
self.barOffset = barOffset
self.barWidth = barWidth
self.barColor = barColor
self.nBars = nBars
self.barHeights = [CGFloat](repeating: 0, count: nBars)
}
var body: some View {
ZStack {
ForEach(0..<nBars, id: \.self) { i in
Line()
.stroke(style: .init(
lineWidth: barWidth,
lineCap: .round,
dash: [2, barWidth, 2, barWidth, 3, barWidth, 4, barWidth, 1000]
))
.frame(width: barWidth, height: barHeights[i])
.frame(maxHeight: barMaxHeight, alignment: .bottom)
.frame(width: 2 * barMaxHeight, height: 2 * barMaxHeight, alignment: .top)
.offset(y: -barOffset)
.rotationEffect(.degrees(360/Double(nBars)) * Double(i))
}
}
.foregroundStyle(barColor)
.animation(.easeInOut(duration: animDuration), value: barHeights)
.task(id: barHeights) {
try? await Task.sleep(for: .seconds(animDuration))
var newHeights = [CGFloat]()
for _ in 0..<nBars {
newHeights.append(CGFloat.random(in: barMinHeight...barMaxHeight))
}
barHeights = newHeights
}
}
}
For the second animation, the following differences apply:
If you have difficulties getting the second animation to work then I would suggest it would be best if it was addressed in a separate post.