0

I am adding a border and blur effect on a shape during an animation (root view appearing on page) and seeing FPS drops when using an AngularGradient as the color of the border. I dont see the drops when using a normal color. The drop is noticeable when setting a device to "low battery" mode.

My code is set up as a custom view modifier, and the border is added as overlays in the shape of myShape, where myShape matches the shape of the view/shape being modified.

struct BorderBlur: ViewModifier {
    var fillColor: some ShapeStyle {
        AngularGradient(
            colors: [.blue, .purple, .green, .blue],
            center: .center)
        .opacity(borderOpacity)
    }
    
    var myShape : some Shape {
        RoundedRectangle(cornerRadius:36)
    }
    
    
    let borderWidth : CGFloat
    let blurRadius: CGFloat
    let borderOpacity: CGFloat
    
    init(borderWidth: CGFloat, blurRadius: CGFloat, borderOpacity: CGFloat) {
        self.borderWidth = borderWidth
        self.blurRadius = blurRadius
        self.borderOpacity = borderOpacity
    }
    
    public func body(content: Content) -> some View {
        content
            .overlay(
                myShape
                    .stroke(lineWidth: borderWidth)
                    .fill(fillColor)
                    .padding(borderWidth)
            )
            .overlay(
                myShape
                    .stroke(lineWidth: borderWidth)
                    .fill(fillColor)
                    .blur(radius: blurRadius)
                    .padding(borderWidth)
            )
            .overlay(
                myShape
                    .stroke(lineWidth: borderWidth)
                    .fill(fillColor)
                    .blur(radius: blurRadius / 2)
                    .padding(borderWidth)
            )
    }
}

extension View {
    public func borderBlur(borderWidth: CGFloat, blurRadius: CGFloat, borderOpacity: CGFloat) -> some View {
        return modifier(BorderBlur(borderWidth: borderWidth, blurRadius: blurRadius, borderOpacity: borderOpacity))
    }
}

struct MyRootView: View {
    
    @State var didAppear = false
    var borderWidth: CGFloat {
        didAppear ? 3 : 0
    }
    var borderBlurRadius: CGFloat {
        didAppear ? 10 : 0
    }
    var borderOpacity: CGFloat {
        didAppear ? 1 : 0
    }
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius:36).fill(.clear)
                .borderBlur(borderWidth: borderWidth, blurRadius: borderBlurRadius, borderOpacity: borderOpacity)
                .frame(width: didAppear ? 300 : 100, height: didAppear ? 400 : 100)
                .offset(y: didAppear ? 0 : -400)
        }
        .onAppear {
            withAnimation(.linear(duration:2.0)) {
                didAppear = true
            }
        }
    }
}

If I change the fillColor to a standard color like Color.blue, I see no FPS drops. Any ideas as to how to make the gradient render more efficiently? I tried drawingGroup(), which does improve FPS, but it makes the blur look bad, and I also want to be able to animate blur size (+ border width, opacity).

2
  • 1
    What is "page appear"? The code you have shown does not involve any animations. Please post a minimal reproducible example.
    – Sweeper
    Commented Apr 13 at 0:37
  • Updated. By 'page appear' I meant the root SwiftUI view first appears. It's shown by a UIViewController, and first appears when the UIViewController is presented. But that isn't important to the lag
    – AntcDev
    Commented Apr 13 at 1:31

1 Answer 1

-1

You're observing FPS drops with AngularGradient + blur during animation—especially on low power mode—because:

  1. Gradients are GPU-intensive, especially AngularGradient, which involves trigonometric calculations to determine color at each point.

  2. Blurs are expensive, especially when applied multiple times.

  3. You're layering 3 overlays, each with a blur and a gradient, which multiplies the cost.

  4. Animation forces frequent re-renders, especially on property changes like blur, opacity, and frame size.

You're on the right track with drawingGroup(), which rasterizes the view, but it introduces visual artifacts with blur (common in SwiftUI).

✅ Suggestions to Improve Performance While Keeping Effects

1. Use a cached, pre-rendered gradient if possible

You can convert your AngularGradient to an Image, especially if it's static (not animated across the hue wheel).

letgradientImage: Image = { let gradient = AngularGradient(colors: [.blue, .purple, .green, .blue], center: .center) let shape = RoundedRectangle(cornerRadius: 36) return ImageRenderer(content: shape.fill(gradient).frame(width: 300, height: 400)).uiImage.map { Image(uiImage: $0) } ?? Image(systemName: "circle") // fallback }()

Then use the Image instead of rebuilding the gradient every frame.

2. Reduce Overlays

Each overlay adds compositing cost. Consider merging them into one:

.overlay {     myShape         .strokeBorder(lineWidth: borderWidth)         .fill(fillColor)         .blur(radius: blurRadius)         .opacity(borderOpacity)         .padding(borderWidth) } 

You can animate multiple parameters on a single shape instead of 3 overlays.

3. Rasterize Only When Static

Wrap only the parts that don’t animate in a .drawingGroup():

.overlay {     if !isAnimating {         myShape             .strokeBorder(lineWidth: borderWidth)             .fill(fillColor)             .blur(radius: blurRadius)             .padding(borderWidth)             .drawingGroup()     } else {         myShape             .strokeBorder(lineWidth: borderWidth)             .fill(fillColor)             .blur(radius: blurRadius)             .padding(borderWidth)     } } 

You can drive isAnimating with DispatchQueue.main.asyncAfter(deadline:) if you want to disable rasterization after animation.

4. Consider a simpler gradient like LinearGradient

If the angular look isn’t critical, LinearGradient is significantly cheaper and less taxing on blur.


🧪 Bonus Tip: Check Layer Count

In Instruments (Xcode → Profile → Core Animation), check the layer count while animating. Try to keep it under 100 for best performance on older devices or low-power mode.


⚡️ TL;DR Optimization

Here’s a simplified and optimized version:

.overlay {     myShape         .stroke(lineWidth: borderWidth)         .fill(AngularGradient(colors: [.blue, .purple, .green, .blue], center: .center))         .blur(radius: blurRadius)         .opacity(borderOpacity)         .padding(borderWidth) } 

If that’s still heavy, pre-render your gradient as an image or defer blur until the animation is done.

2
  • 1
    Was AI used to help make this answer?
    – chux
    Commented Apr 13 at 22:04
  • Thanks for the detailed response. Unfortunately the simplified version suggested doesn't produce the desired visual result, instead it just results in a blur, a solid border is not rendered. I did find a more optimal version of having a single overlay with a ZStack containing two shapes, one with just stroke, and the other with the blur + stroke; rather than the 3 I originally had. It's better, but still some FPS issues
    – AntcDev
    Commented Apr 14 at 2:43

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.