iosanimationswiftui

Offset animation issue in SwiftUI


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

  1. The pulsating animation isn't smooth as in the link.
  2. Animating the y-offset results in the bars sometimes displaying detached above or below the offset i.e. not anchored (see screenshot)

enter image description here

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.


Solution

  • 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
            }
        }
    }
    

    Animation

    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.