SwiftUI performance of controlled drawn animations

Today, I’ll look into SwiftUI renders under performing when too much computation is going on. Our use-case is an animation that we control and not an animation that can run once or loop forever. This means that we can NOT predetermine the duration and speed. If this sounds useful for you, please proceed.

The following video demonstrates an hypotetical list of progress animated views and what’s seen is the desired result, smooth and no jumpiness.

In the example we’ve seen a Timer is scheduled to occur in a very small time interval manner, to force the performance issue something like this might cause to our application.

Timer.scheduledTimer(withTimeInterval: (1/240), repeats: true) { _ in
  // updates ObservableObject
}

It’s a common practice to allocate the animation render to composite the view’s contents into an offscreen image before final display by using the modifier called drawingGroup() - common, when and only there are performance issues, as this generaly slows down the application and SwiftUI. But it is decoupled off-screen and passed to Metal, which is Apple’s framework for working directly with the GPU offering faster graphical computation.

While this practice is great to cases where our component has gradients and stuff, it might not hold truthy for the cases similar to our video demo; where the animation are small increments from a value that causes SwiftUI to draw onto the scene; which is done by Core Animation. And does this so many times, so quick that shoot us on the foot.

Low performance

The source-code for a single view of ProgressView is shared bellow. The onReceive is triggered everytime the Timer that is scheduled at 1/240 (or 0.004166666666…), it’s a bit too much but the whole point here is too push this to start toasting bread on the CPU. But let’s spare butter for a second and proceed.

struct ProgressView: View {
    var progress: Progress
    @State var position: CGFloat = -1
    
    var body: some View {
        ZStack {
            Circle()
                .stroke(style: StrokeStyle(lineWidth: 12.0))
                .foregroundColor(Color.black)
                .opacity(0.3)
                .padding()
            Circle()
                .trim(
                    from: 0,
                    to: self.position
                )
                .stroke(
                    style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round)
                )
                .rotation(Angle(degrees: -90))
                .padding()
        }
        .drawingGroup()
        .onReceive(self.progress.$position) { val in
           self.position = CGFloat(val)
        }
    }
}

By simply removing the .drawingGroup() from our source code, the CPU performance lowers to about 24%, which is still not very satisfying.

Hopefully, we’re at the same frequency and the examples above serve as a way to address this whole implementation differently. First, we know that the Timer interval is set to a record high 1/240 which is quite ridiculous, given that nowadays the highest fps a video-game runs is 120 fps (I’m not a gamer but remember reading something like that before), so we may want to start by reducing the interval to a sane value.

Reducing the number to 1/60, seems quite alright for the use-case, but we’ve reduced it by 4x. And the performance seems stable at about 8%. Surely, if we reduce to 1/30…and keep increasing the performance improves, right? This unfortunately comes with a price, the animation refresh rate keeps decreasing and starts looking rough…

To expose this I’ve exagerated and reduce it to 1/4, ok it’s obiously awful, duh! And we wouldn’t watch Hoolywood less then 24fps anyway. Yeh true, but the goal here is to explore an option that I’ll comment on just after.

Reducing the interval, controllable and keeping it smooth#

Considering the lowest possible times we want to compute the value that causes the animation, we can use Animation to fill the holes. This leads to an increase in the animation smoothness as desired, and overall a reduction on performance.

struct ProgressView: View {
    var progress: Progress
    @State var position: CGFloat = -1
    
    var body: some View {
        ZStack {
            Circle()
                .stroke(style: StrokeStyle(lineWidth: 12.0))
                .foregroundColor(Color.black)
                .opacity(0.3)
                .padding()
            Circle()
                .trim(
                    from: 0,
                    to: self.position
                )
                .stroke(
                    style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round)
                )
                .rotation(Angle(degrees: -90))
                .padding()
        }
        .onReceive(self.progress.$position) { val in
            // the clause is to prevent the animation from animating backwards when the valeue goes from 1 to 0
            withAnimation(Animation.linear(duration: val > 0 && self.position > -1 ? timeSignature : 0)) {
                self.position = CGFloat(val)
            }
        }
    }
}

The performance reaches an average of 2%, which is great!

So, next time you find an issue on your animations and reach performance hiccups, please consider leaving the heavy lifting to Core Animation. Have in mind that for most cases where you can just utilize Animation, there are cases where you need control of the animation state.

I believe that Apple will come up with a solution for this, such as the seek method GreenSock GSAP provides, to help us keep track of computational animations and reach for and control the state. Until then, hope this is helpful to some body out there.

comments powered by Disqus