‹ All articles
Apple
iOSiPadOSOptimizationSwiftUI

Optimizing a SwiftUI Animated Background with Core Animation

Three colored circles drifting behind an interface. Yet leaving that screen open kept the application using around 8.5% CPU. What happened?! Let's find out.

Carolane Lefebvre10 min
Optimizing a SwiftUI Animated Background with Core Animation

Three colored circles drifting behind an interface. No scrolling, no audio playback, no user interaction. Yet leaving that screen open kept the application using around 8.5% CPU.

Nothing about the effect looked particularly demanding. The animation was slow and subtle, and the implementation used familiar SwiftUI APIs: Circle, .blur, and .phaseAnimator.

Profiling the original version in Instruments produced these results:

Recording

Average app CPU usage

1

7.96%

2

8.79%

3

8.81%

Average

8.52%

Jeez! That seems.. a lot right? Well, for an app that wasn’t going to be published anyway, that’s okay-ish. However, you know that we love optimization here, and after changing how the background was drawn and animated, the application’s average CPU usage dropped to below 0.1% in all three comparison recordings.

How can a visually similar effect produce such a different result?

To understand the difference, we need to look at the implementation, and distinguish the CPU work inside the application from the work required to render the final image.


Meet the app

MusicApp is a SwiftUI application for searching the iTunes music catalog, listening to previews, saving favorites, and revisiting recently played tracks. It also includes a recommendation flow that uses Apple’s Foundation Models framework to suggest music based on a few preferences.

The interface uses soft colors and a gently animated background. Three translucent halos move between predefined positions, creating a changing composition behind the content.

The movement is deliberately slow: each halo takes seven seconds to travel from one position to another, then returns.

The original implementation drew each halo as a blurred SwiftUI circle:

Circle()
    .fill(color.opacity(0.28))
    .frame(width: size.width * 0.7)
    .blur(radius: 35)
    .position(
        x: size.width * (shifted ? 0.85 : 0.65),
        y: size.height * (shifted ? 0.3 : 0.05)
    )
// Don't mind the magic numbers, I know.. I knooow...

A phaseAnimator alternated between the two positions:

.phaseAnimator([false, true]) { _, shifted in
    circles(
        size: geometry.size,
        shifted: shifted
    )
} animation: { _ in
    .easeInOut(duration: 7)
}

This was a straightforward implementation. Positions and sizes were proportional to the available space, and the animation already stopped when the view disappeared, the app became inactive, or Reduce Motion was enabled.

The comparison therefore wasn’t between an uncontrolled animation and one with proper lifecycle handling. Those protections existed in both versions.


What is Core Animation?

Core Animation is Apple’s infrastructure for animating and composing graphical content. Its APIs are available through the QuartzCore framework.

It works with layers, represented by CALayer. A layer has properties such as its position, bounds, opacity, and visual content. Specialized layers provide additional drawing capabilities.

For this background, the relevant types are:

API

Role in the implementation

CAGradientLayer

Draws each halo as a radial gradient.

CABasicAnimation

Animates the layer’s position between two points.

CAMediaTimingFunction

Controls how the movement accelerates and slows down.

CATransaction

Groups layer changes and controls implicit animation behavior.

UIViewRepresentable

Makes the UIKit container available to SwiftUI.

With an explicit animation, we describe the property to animate and its endpoints. Core Animation interpolates between those values. For example, CABasicAnimation(keyPath: "position") targets the layer’s position.

Using these APIs directly does not mean that SwiftUI lacks an efficient animation system. The question is whether this particular effect benefits from a different implementation.


Implementation, step by step

The implementation is split into four pieces:

  • HaloConfiguration: describes each halo

  • GlowView: creates, positions, and animates the layers

  • GlowRepresentable: Connects the UIKit view to SwiftUI

  • AnimatedGlow: Supplies color and animation lifecycle state

The following excerpts focus on the relevant methods. Project-specific namespace wrappers are omitted where they would distract from the explanation.

1. Describe the appearance and movement separately

Each halo has four configurable properties:

struct HaloConfiguration {
    let widthRatio: CGFloat
    let opacity: CGFloat
    let start: CGPoint
    let end: CGPoint
}

One configuration looks like this:

HaloConfiguration(
    widthRatio: 0.7,
    opacity: 0.3,
    start: CGPoint(x: 0.65, y: 0.05),
    end: CGPoint(x: 0.85, y: 0.3)
)

The coordinates are proportions of the container, rather than fixed screen positions.

For a container measuring 400 × 800 points, this configuration produces:

  • A diameter of 280 points.

  • A starting center at (260, 40).

  • An ending center at (340, 240).

Separating this configuration from the animation code makes it easier to adjust the composition without changing how the layers work.

2. Draw gradients instead of blurring circles

GlowView is a UIView containing one gradient layer per halo:

private let glowLayers = HaloConfiguration.halos.map { _ in
    CAGradientLayer()
}

These layers are created once per GlowView instance.

During configuration, each layer becomes a radial gradient and is added to the view’s backing layer:

private func configure() {
    isOpaque = false
    isUserInteractionEnabled = false

    for glowLayer in glowLayers {
        glowLayer.type = .radial
        glowLayer.startPoint = Configuration.center
        glowLayer.endPoint = Configuration.edge

        layer.addSublayer(glowLayer)
    }
}

Here, Configuration.center is (0.5, 0.5) and Configuration.edge is (1, 1), expressed in the gradient layer’s normalized coordinate space.

Setting isOpaque to false tells UIKit that the view contains transparency. Disabling interaction ensures that this decorative container does not handle touches.

The colors make each gradient fade toward transparency:

glowLayer.colors = [
    color.withAlphaComponent(halo.opacity).cgColor,
    color.withAlphaComponent(0).cgColor
]

The original version blurred a filled circle. The new version draws a radial gradient directly. Both create a soft halo, but they are not pixel-identical effects tho.

And I found out that this distinction matters when interpreting the performance results!

3. Calculate geometry when the container changes size

SwiftUI determines the size of the UIKit container. The layers inside it are laid out using that available space.

A helper converts proportional coordinates into actual positions:

private func position(for point: CGPoint) -> CGPoint {
    CGPoint(
        x: bounds.minX + bounds.width * point.x,
        y: bounds.minY + bounds.height * point.y
    )
}

Each layer receives its dimensions and starting position:

let diameter = bounds.width * halo.widthRatio

glowLayer.bounds = CGRect(
    origin: .zero,
    size: CGSize(
        width: diameter,
        height: diameter
    )
)

glowLayer.position = position(for: halo.start)

bounds describes the layer’s local geometry. position places its anchor point in the parent layer’s coordinate space. With the default center anchor, it positions the center of the halo.

This work happens in layoutSubviews():

override func layoutSubviews() {
    super.layoutSubviews()

    guard bounds != previousBounds else { return }
    previousBounds = bounds

    stopAnimations()
    updateLayerLayout()
    updateAnimations()
}

The bounds check avoids restarting animations when UIKit performs another layout pass without changing the container’s size.

When the dimensions do change, the implementation recalculates the geometry and restarts the movement. It does not preserve the animation’s current phase across resizing.

4. Prevent unwanted implicit animations

Changing a layer property can trigger an implicit animation. When updating the halo geometry, we want the new size and starting position to take effect without an additional automatic transition.

The layout updates are therefore wrapped in a transaction:

CATransaction.begin()
CATransaction.setDisableActions(true)

// Update the layers' bounds and starting positions.

CATransaction.commit()

begin() and commit() delimit the group of changes. setDisableActions(true) suppresses implicit actions for changes made within that transaction. The color updates use the same approach.

This does not stop an explicitly attached animation. Removing an existing animation is a separate operation.

5. Add an explicit position animation

Once the geometry is ready, each layer receives a movement animation:

private func addMovement(
    to glowLayer: CAGradientLayer,
    configuration: HaloConfiguration
) {
    let animation = CABasicAnimation(keyPath: "position")

    animation.fromValue = NSValue(
        cgPoint: position(for: configuration.start)
    )

    animation.toValue = NSValue(
        cgPoint: position(for: configuration.end)
    )

    animation.duration = Configuration.animationDuration
    animation.autoreverses = true
    animation.repeatCount = .infinity

    animation.timingFunction = CAMediaTimingFunction(
        name: .easeInEaseOut
    )

    glowLayer.add(
        animation,
        forKey: Configuration.animationKey
    )
}

The duration is seven seconds. autoreverses adds the return journey, so one complete cycle lasts approximately fourteen seconds.

NSValue wraps the CGPoint values for the animation API. The timing function makes the movement start and finish gradually.

There are two different identifiers here:

  • "position" selects the property being animated.

  • Configuration.animationKey identifies the attached animation so it can be found or removed later.

Before adding an animation, the implementation checks whether one already exists under that key:

guard glowLayer.animation(
    forKey: Configuration.animationKey
) == nil else {
    continue
}

This prevents an ordinary update from repeatedly restarting an animation that is already running.

Stopping the movement removes the animation:

glowLayer.removeAnimation(
    forKey: Configuration.animationKey
)

An explicit animation changes what is presented onscreen without automatically updating the layer’s stored position. In this implementation, that stored position remains the starting point. Removing the animation therefore returns the halo there.

This is a stop-and-restart behavior, rather than a pause-and-resume mechanism.

6. Bridge the UIKit view into SwiftUI

UIViewRepresentable provides the creation, update, and teardown hooks needed to integrate a UIKit view into a SwiftUI hierarchy.

Our wrapper passes two inputs: a color and an animation flag.

struct GlowRepresentable: UIViewRepresentable {
    let color: Color
    let isAnimating: Bool

    func makeUIView(context: Context) -> GlowView {
        GlowView(frame: .zero)
    }

    func updateUIView(
        _ uiView: GlowView,
        context: Context
    ) {
        uiView.updateColor(
            UIColor(color).resolvedColor(
                with: uiView.traitCollection
            )
        )

        uiView.setAnimation(isAnimating)
    }

    static func dismantleUIView(
        _ uiView: GlowView,
        coordinator: ()
    ) {
        uiView.setAnimation(false)
    }
}

The initial frame is zero because SwiftUI will assign the container’s layout.

updateUIView forwards the current inputs. The color is converted to UIColor and resolved against the view’s traits before being passed to the gradient layers.

dismantleUIView stops the animation when the represented view is being removed.

7. Keep lifecycle and accessibility decisions in SwiftUI

The public SwiftUI component decides whether animation is appropriate:

@Environment(\.accessibilityReduceMotion)
private var reduceMotion

@Environment(\.scenePhase)
private var scenePhase

@State private var isVisible = false

private var shouldAnimate: Bool {
    isVisible
        && scenePhase == .active
        && !reduceMotion
}

It then supplies that decision to the wrapper:

GlowRepresentable(
    color: color,
    isAnimating: shouldAnimate
)
.allowsHitTesting(false)
.accessibilityHidden(true)
.onAppear { isVisible = true }
.onDisappear { isVisible = false }

The UIKit side adds two practical checks before starting:

guard shouldAnimate,
      window != nil,
      !bounds.isEmpty else {
    stopAnimations()
    return
}

The view must be attached to a window and have usable dimensions. didMoveToWindow() reevaluates the animation state when that attachment changes.

The background remains visible when movement is disabled, but stays static.

These lifecycle protections were retained from the original implementation. They are useful behavior, rather than a new optimization that can explain the measured difference.


Results and conclusion

Both versions were built in Release and measured on the same iPhone 15 Pro running iOS 27.2, using Time Profiler and Activity Monitor.

For each version:

  • Three recordings were captured with a 45-second time limit.

  • The home screen remained stationary, with the halos visibly moving.

  • No scrolling or audio playback was performed.

  • The first ten seconds were excluded from the main analysis.

The application remained foreground-active throughout the analyzed windows, and Instruments reported a nominal thermal state in all six recordings.

Recording

SwiftUI blurred circles

Core Animation radial gradients

1

7.96%

< 0.1%

2

8.79%

< 0.1%

3

8.81%

< 0.1%

The main figures are time-weighted averages of the app CPU values exported by Activity Monitor over the 10–45 second window.

At very low CPU usage, updates become sparse and Instruments may hold the last reported value over a longer interval. Cumulative CPU counters were therefore also checked over a common 10–35 second window. They supported the same order of magnitude.

For the new implementation, “below 0.1%” is more meaningful than publishing tiny decimal values as if they represented exact precision.


What improved?

The new background requires substantially less CPU work inside the application during this scenario. A decorative effect that previously maintained roughly 8.5% app CPU usage now leaves the process close to idle.

That reduces the CPU demand associated with keeping this screen open. It does not, by itself, prove better frame pacing, longer battery life, or a particular energy saving.

The CPU figure belongs to the MusicApp process. It does not include all work performed by the GPU or system rendering processes.


This implementation adds complexity: a UIKit container, a SwiftUI bridge, and explicit management of layer geometry and animation state.

For this particular background, the measurements give that additional code a concrete justification. The visual effect keeps its intended role while the application spends far less CPU time maintaining it.

The useful lesson is to examine both what is being drawn and how it is being animated. A small amount of SwiftUI code can describe substantial rendering work, while a longer implementation can sometimes reduce the work the application needs to perform. Instruments makes that tradeoff something we can evaluate rather than guess.