swiftuigeometryreaderios-animations

Decrease Expense of an Animation


I really enjoy the look of this animation running in the background of my app, but it is incredibly expensive as it stands. Is there a way to make this animation lighter on people's cpu's while maintaining its dynamic movement and changes in opacity?

To make the question simpler: How can this animation code be refactored to make it less taxing?

import SwiftUI

struct RainAnimationView: View {
    var rows = 17
    @State var textContent = Array("bokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokworm").map({ String($0) }).shuffled()
    var randomAnimationDuration = 15.0...20.5
    @State var animateRain: Bool = false
    
    func shuffleTextContent() {
        textContent.shuffle()
    }
    
    let animation = Animation.easeIn(duration: Double.random(in: 15.0...20.5)).repeatForever(autoreverses: false)
    
    @Binding var shareVar: Bool {
        didSet {
            withAnimation(.easeIn(duration: Double.random(in: randomAnimationDuration)).repeatForever(autoreverses: false)) {
                animateRain = true }
        }
    }
    
    @State private var opacity: Double = 0.0
    
    var body: some View {
        GeometryReader { geometry in
            HStack() {
                Spacer()
                ForEach(0..<rows, id: \.self) { index in
                    VStack {
                        Spacer()
                        ForEach(0..<textContent.count, id: \.self) { index in
                            Text(textContent[index])
                                .foregroundColor(Color.brown)
                                .font(.custom("AmericanTypewriter", size: 17))
                            Spacer()
                        }
                    }
                    .offset(y: -geometry.size.height - 100)
                    .offset(x: 0, y: animateRain ? 2*geometry.size.height : -geometry.size.height)
                    .rotation3DEffect(animateRain ? .degrees(49) : .degrees(1), axis: (x: 0, y: 1, z: 0))
                    .opacity(animateRain ? 1.0 : 0.0)
                    .animation(Animation.easeInOut(duration: Double.random(in: randomAnimationDuration)).repeatForever(autoreverses: true), value: animateRain)
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height + 200)
            .clipped()
            .onAppear() { DispatchQueue.main.async { withAnimation(animation) {
                animateRain = false
                withAnimation(animation) {
                    animateRain = true
                }
            }}}
            .ignoresSafeArea(edges: .top)
        }
    }
}

I tried just running it when a view loads for the first time.

Used less rows in general.

I tried running the animation for only a brief period of time.

It's still really taxing unfortunately.


Solution

  • If you want to know if changes are making an improvement, it is important to benchmark the performance of the solution as you currently have it.

    Running on an iPhone 16 simulator with iOS 18.0, I found the main processes consuming CPU were the following (with approximate CPU values):

    These values were roughly the same for both DEBUG and RELEASE builds.

    One reason why the animation is expensive is because you are building each column as a VStack containing the letters in the array, then applying the animated changes to the VStack. So a way to reduce this cost is to replace the VStack with a Text view that displays all the characters in the array as a single string. Then:

    I couldn't really understand why you had so many state variables and why the .onAppear was so convoluted. Maybe you are using the state variables and the function shuffleTextContent in ways you have not shown in the example? To keep it simple, the version below uses as few state variables as possible and .onAppear is also very basic.

    struct RainAnimationView: View {
        let colWidth: CGFloat = 23
        let randomAnimationDuration = 15.0...20.5
        let textContent = Array( "bokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokwormbokworm")
            .map({ String($0) })
            .shuffled()
            .joined(separator: "\n")
        @State private var animateRain: Bool = false
    
        var body: some View {
            GeometryReader { geometry in
                let nCols = Int(geometry.size.width / colWidth)
                HStack(alignment: .top, spacing: 0) {
                    ForEach(0..<nCols, id: \.self) { _ in
                        Text(textContent)
                            .frame(width: colWidth)
                            .offset(y: animateRain ? 0 : -2 * geometry.size.height)
                            .rotation3DEffect(.degrees(animateRain ? 49 : 1), axis: (x: 0, y: 1, z: 0))
                            .opacity(animateRain ? 1.0 : 0.0)
                            .animation(
                                .easeInOut(duration: Double.random(in: randomAnimationDuration))
                                .repeatForever(autoreverses: true),
                                value: animateRain
                            )
                    }
                }
                .fixedSize()
                .foregroundStyle(.brown)
                .font(.custom("AmericanTypewriter", size: 17))
                .lineSpacing(10)
                .onAppear { animateRain = true }
                .ignoresSafeArea(edges: .top)
            }
        }
    }
    

    Measuring again on an iPhone 16 simulator with iOS 18.0, the CPU usage is now approximately as follows:

    You will see that the CPU usage of the main app has been reduced significantly, but the other processes are using a little more CPU than before. So the optimisations certainly bring an improvement, but the animation is still quite CPU-heavy.