NSViewRepresentable updates triggered by .onChange ignore SwiftUI Transactions on macOS

I am encountering a systemic issue on macOS where NSViewRepresentable (and some native container views like Table) completely discard explicit SwiftUI animations when the state change is handled via an .onChange modifier.

While the exact same reactive architecture produces fluid animations on iOS, the AppKit bridge on macOS snaps the frame updates instantly. I have filed a formal bug report for this behavior, but I want to open this up to the community to see if anyone has found a cleaner architectural workaround.

The Problem

When observing a state change (e.g., via @AppStorage, @SceneStorage, or local state) using .onChange, applying a withAnimation block fails to animate the underlying layer changes in an AppKit representable view.

// The Reactive Pattern that breaks on macOS
.onChange(of: toggle) { newValue in
    withAnimation(.easeInOut(duration: 0.5)) {
        self.targetColor = newValue ? .systemBlue : .systemRed
    }
}

The Diagnostic Anomaly

If you inspect context.transaction inside the updateNSView(_:context:) method during this lifecycle pass, SwiftUI reports that the transaction is animated:

func updateNSView(_ nsView: NSView, context: Context) {
    // Prints 'true', indicating SwiftUI thinks it's animating
    print("Is Animated: \(context.transaction.animation != nil)") 
    
    // Result: Snaps instantly. No animation occurs.
    nsView.layer?.backgroundColor = targetColor.cgColor 
}

Why It Happens (The Double-Commit)

It appears that on macOS, .onChange flushes a static layout transaction to the window layer immediately upon the state mutating. By the time the withAnimation block evaluates inside the closure, the AppKit backing layer has already processed a implicit setDisableActions(true) directive. The GPU pipeline for that transaction frame is effectively closed, despite what the context.transaction metadata claims.

The Low-Level Workaround

To force the AppKit bridge to respect the animation intent, I have to manually drop into Core Animation inside updateNSView and explicitly veto SwiftUI's action-disabling behavior:

func updateNSView(_ nsView: NSView, context: Context) {
    CATransaction.begin()
    
    if context.transaction.animation != nil {
        // Explicitly override SwiftUI's implicit frame lock
        CATransaction.setDisableActions(false) 
        CATransaction.setAnimationDuration(0.5) // Hardcoded fallback match
    } else {
        CATransaction.setDisableActions(true)
    }
    
    nsView.layer?.backgroundColor = targetColor.cgColor
    CATransaction.commit()
}

My Questions:

  1. Is this intentional behavior due to how AppKit's layer-backed architectures handle frame integrity vs. iOS's fluid layout engine?
  2. Has anyone found a way to bridge SwiftUI's Animation type curves (like .spring()) cleanly down into the CATransaction or NSAnimationContext layer without hardcoding durations inside updateNSView?
  3. Is there a purely "Reactive" paradigm that avoids mutating state at the primary action source (e.g., forcing a Button to own the animation logic) while maintaining fluid transitions on macOS?
Answered by Frameworks Engineer in 892499022

Thanks for the question!

Please try this out on macOS 27 if you haven't already - we've made changes in this area.

For this case you could try using Animatable to allow SwiftUI to drive the animation if that is appropriate for this case. The Use SwiftUI with AppKit and UIKit session from this year has a great example of this.

For this example, this should also work by using NSAnimationContext.animate(_:changes:completion:) in the updateNSView callback similar to:

if let currentAnimation = context.transaction.animation {
    NSAnimationContext.animate(currentAnimation) {
        nsView.layer?.backgroundColor = targetColor.cgColor
    }
}

Please also attach the feedback ID here so that we can be sure it's tracked properly.

Thanks for the question!

Please try this out on macOS 27 if you haven't already - we've made changes in this area.

For this case you could try using Animatable to allow SwiftUI to drive the animation if that is appropriate for this case. The Use SwiftUI with AppKit and UIKit session from this year has a great example of this.

For this example, this should also work by using NSAnimationContext.animate(_:changes:completion:) in the updateNSView callback similar to:

if let currentAnimation = context.transaction.animation {
    NSAnimationContext.animate(currentAnimation) {
        nsView.layer?.backgroundColor = targetColor.cgColor
    }
}

Please also attach the feedback ID here so that we can be sure it's tracked properly.

NSViewRepresentable updates triggered by .onChange ignore SwiftUI Transactions on macOS
 
 
Q